Compare commits

...
Author SHA1 Message Date
openspec-release-bot[bot]andgithub-actions[bot] 3c7a05c5dc Version Packages (#996)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-21 16:14:12 +00:00
Tabish Bidiwale d1f3861d9e Add changeset for v1.3.1 patch fixes (#995) 2026-04-21 16:09:49 +00:00
7a39e887bb fix: escape glob-special characters in directory paths (#984)
* fix: escape glob-special chars in directory paths (#974)

Parentheses and square brackets in project directory paths broke
fast-glob matching, causing glob-based artifact outputs to silently
return empty results. Escape these characters in the directory portion
before passing to fast-glob, preserving glob semantics in the generates
pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use cwd for artifact output globs

---------

Co-authored-by: furao <furao@didiglobal.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-04-21 16:00:10 +00:00
swithekandTabishB 18c445a48d fix: handle XDG_CONFIG_HOME and %APPDATA% in telemetry config path (#990)
* fix: handle XDG_CONFIG_HOME and %APPDATA% in telemetry config path

* fix: migrate telemetry config to resolved path

* fix: address telemetry config review feedback

---------

Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-04-21 15:31:23 +00:00
Tabish Bidiwale 900174000b docs: remove teams slack mention from readme (#991) 2026-04-20 02:22:12 +00:00
Tabish Bidiwale f529b25968 test: align path assertions with canonical helper (#975) 2026-04-15 10:32:03 +00:00
Tabish Bidiwale 93f7b797cf fix: prefer native realpath for canonical paths (#972) 2026-04-14 07:43:43 +00:00
10 changed files with 384 additions and 72 deletions
+12
View File
@@ -1,5 +1,17 @@
# @fission-ai/openspec
## 1.3.1
### Patch Changes
- [#995](https://github.com/Fission-AI/OpenSpec/pull/995) [`d1f3861`](https://github.com/Fission-AI/OpenSpec/commit/d1f3861d9ec694cc924b042b5da01963dcf93137) Thanks [@TabishB](https://github.com/TabishB)! - ### Bug Fixes
- **Canonical artifact paths** — Workflow artifact paths are now resolved via the native `realpath`, so symlinks and case-insensitive filesystems no longer cause path mismatches during apply and archive.
- **Glob apply instructions** — Apply instructions with glob artifact outputs now resolve correctly, and literal artifact outputs are enforced to be file paths.
- **Hidden main spec requirements** — Requirements nested inside fenced code blocks or otherwise hidden in main specs are now detected during validation.
- **Clean `--json` output** — Spinner progress text no longer leaks into stderr when `--json` is passed, so AI agents that combine stdout and stderr can parse the JSON reliably.
- **Silent telemetry in firewalled environments** — PostHog network errors are now swallowed with a 1s timeout and retries/remote config disabled, so OpenSpec no longer surfaces `PostHogFetchNetworkError` in locked-down networks. Telemetry opt-out is documented earlier in the README, installation guide, and CLI reference.
## 1.3.0
### Minor Changes
-4
View File
@@ -42,10 +42,6 @@ Our philosophy:
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
</p>
### Teams
Using OpenSpec in a team? [Email here](mailto:teams@openspec.dev) for access to our Slack channel.
<!-- TODO: Add GIF demo of /opsx:propose → /opsx:archive workflow -->
## See it in action
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.3.0",
"version": "1.3.1",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+5 -6
View File
@@ -15,21 +15,20 @@ export function isGlobPattern(pattern: string): boolean {
* Returns absolute file paths. Glob matches are sorted for deterministic output.
*/
export function resolveArtifactOutputs(changeDir: string, generates: string): string[] {
const fullPattern = path.join(changeDir, generates);
if (!isGlobPattern(generates)) {
const fullPath = path.join(changeDir, generates);
try {
return fs.statSync(fullPattern).isFile()
? [FileSystemUtils.canonicalizeExistingPath(fullPattern)]
return fs.statSync(fullPath).isFile()
? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
: [];
} catch {
return [];
}
}
const normalizedPattern = FileSystemUtils.toPosixPath(fullPattern);
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
const matches = fg
.sync(normalizedPattern, { onlyFiles: true })
.sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true })
.map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
return Array.from(new Set(matches)).sort();
+116 -19
View File
@@ -1,10 +1,19 @@
/**
* Global configuration for telemetry state.
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
* Stores anonymous ID and notice-seen flag in the platform-appropriate config directory.
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import {
GLOBAL_CONFIG_DIR_NAME,
GLOBAL_CONFIG_FILE_NAME,
getGlobalConfigDir,
} from '../core/global-config.js';
// Constants
export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
export interface TelemetryConfig {
anonymousId?: string;
@@ -16,13 +25,112 @@ export interface GlobalConfig {
[key: string]: unknown; // Preserve other fields
}
type ConfigReadResult =
| { status: 'missing' }
| { status: 'ok'; config: GlobalConfig }
| { status: 'invalid'; config: GlobalConfig };
function getConfigDir(): string {
return getGlobalConfigDir();
}
function getLegacyConfigPath(): string {
return path.join(os.homedir(), '.config', CONFIG_DIR_NAME, CONFIG_FILE_NAME);
}
async function readConfigFile(configPath: string): Promise<ConfigReadResult> {
try {
const content = await fs.readFile(configPath, 'utf-8');
return { status: 'ok', config: JSON.parse(content) as GlobalConfig };
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { status: 'missing' };
}
// If parse fails or another read error occurs, ignore the file.
return { status: 'invalid', config: {} };
}
}
async function writeConfigFile(configPath: string, config: GlobalConfig): Promise<void> {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
}
function hasMissingTelemetryFields(config: GlobalConfig): boolean {
const telemetry = config.telemetry;
return (
!telemetry ||
telemetry.anonymousId === undefined ||
telemetry.noticeSeen === undefined
);
}
function mergeLegacyTelemetry(config: GlobalConfig, legacyConfig: GlobalConfig): GlobalConfig | undefined {
const legacyTelemetry = legacyConfig.telemetry;
if (!legacyTelemetry) {
return undefined;
}
const currentTelemetry = config.telemetry ?? {};
const shouldMigrate =
(currentTelemetry.anonymousId === undefined && legacyTelemetry.anonymousId !== undefined) ||
(currentTelemetry.noticeSeen === undefined && legacyTelemetry.noticeSeen !== undefined);
if (!shouldMigrate) {
return undefined;
}
return {
...config,
telemetry: {
...legacyTelemetry,
...currentTelemetry,
},
};
}
async function migrateLegacyTelemetryConfig(
configPath: string,
config: GlobalConfig,
persist: boolean,
): Promise<GlobalConfig> {
const legacyConfigPath = getLegacyConfigPath();
if (path.resolve(configPath) === path.resolve(legacyConfigPath) || !hasMissingTelemetryFields(config)) {
return config;
}
const legacyRead = await readConfigFile(legacyConfigPath);
if (legacyRead.status !== 'ok') {
return config;
}
const migrated = mergeLegacyTelemetry(config, legacyRead.config);
if (!migrated) {
return config;
}
if (persist) {
try {
await writeConfigFile(configPath, migrated);
} catch {
// Preserve telemetry for this run even if the one-time migration cannot be persisted.
}
}
return migrated;
}
/**
* Get the path to the global config file.
* Uses ~/.config/openspec/config.json on all platforms.
* Follows XDG Base Directory Specification and platform conventions.
*
* - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
* - Unix/macOS fallback: ~/.config/openspec/
* - Windows fallback: %APPDATA%/openspec/
*/
export function getConfigPath(): string {
const configDir = path.join(os.homedir(), '.config', 'openspec');
return path.join(configDir, 'config.json');
const configDir = getConfigDir();
return path.join(configDir, CONFIG_FILE_NAME);
}
/**
@@ -31,16 +139,9 @@ export function getConfigPath(): string {
*/
export async function readConfig(): Promise<GlobalConfig> {
const configPath = getConfigPath();
try {
const content = await fs.readFile(configPath, 'utf-8');
return JSON.parse(content) as GlobalConfig;
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
// If parse fails or other error, return empty config
return {};
}
const read = await readConfigFile(configPath);
const config = read.status === 'ok' ? read.config : {};
return migrateLegacyTelemetryConfig(configPath, config, read.status !== 'invalid');
}
/**
@@ -49,10 +150,6 @@ export async function readConfig(): Promise<GlobalConfig> {
*/
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
const configPath = getConfigPath();
const configDir = path.dirname(configPath);
// Ensure directory exists
await fs.mkdir(configDir, { recursive: true });
// Read existing config and merge
const existing = await readConfig();
@@ -63,7 +160,7 @@ export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void>
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
}
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
await writeConfigFile(configPath, merged);
}
/**
+11 -3
View File
@@ -1,6 +1,9 @@
import { promises as fs, constants as fsConstants, realpathSync } from 'fs';
import * as nodeFs from 'fs';
import path from 'path';
const fs = nodeFs.promises;
const { constants: fsConstants } = nodeFs;
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
let leftIndex = markerIndex - 1;
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
@@ -56,9 +59,14 @@ export class FileSystemUtils {
*/
static canonicalizeExistingPath(targetPath: string): string {
try {
return realpathSync(targetPath);
// Prefer the native resolver so Windows short-path aliases are expanded.
return nodeFs.realpathSync.native(targetPath);
} catch {
return path.resolve(targetPath);
try {
return nodeFs.realpathSync(targetPath);
} catch {
return path.resolve(targetPath);
}
}
}
+6 -5
View File
@@ -3,11 +3,14 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { runCLI } from '../helpers/run-cli.js';
import { FileSystemUtils } from '../../src/utils/file-system.js';
describe('artifact-workflow CLI commands', () => {
let tempDir: string;
let changesDir: string;
const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath);
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-artifact-workflow-'));
changesDir = path.join(tempDir, 'openspec', 'changes');
@@ -411,10 +414,8 @@ describe('artifact-workflow CLI commands', () => {
expect(result.stderr).toBe('');
const json = JSON.parse(result.stdout);
const expectedProposalPath = await fs.realpath(path.join(changesDir, 'json-apply', 'proposal.md'));
const expectedSpecPath = await fs.realpath(
path.join(changesDir, 'json-apply', 'specs', 'test-spec.md')
);
const expectedProposalPath = canonical(path.join(changesDir, 'json-apply', 'proposal.md'));
const expectedSpecPath = canonical(path.join(changesDir, 'json-apply', 'specs', 'test-spec.md'));
expect(json.changeName).toBe('json-apply');
expect(json.schemaName).toBe('spec-driven');
expect(json.state).toBe('ready');
@@ -472,7 +473,7 @@ apply:
);
expect(applyResult.exitCode).toBe(0);
const applyJson = JSON.parse(applyResult.stdout);
const resolvedSpecPath = await fs.realpath(specPath);
const resolvedSpecPath = canonical(specPath);
expect(applyJson.state).toBe('ready');
expect(applyJson.missingArtifacts).toBeUndefined();
expect(applyJson.contextFiles).toEqual({
+77 -8
View File
@@ -2,11 +2,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileSystemUtils } from '../../../src/utils/file-system.js';
import { artifactOutputExists, resolveArtifactOutputs } from '../../../src/core/artifact-graph/outputs.js';
describe('artifact-graph/outputs', () => {
let tempDir: string;
const canonical = (targetPath: string): string => FileSystemUtils.canonicalizeExistingPath(targetPath);
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `openspec-outputs-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
@@ -20,7 +23,7 @@ describe('artifact-graph/outputs', () => {
const filePath = path.join(tempDir, 'proposal.md');
fs.writeFileSync(filePath, 'content');
expect(resolveArtifactOutputs(tempDir, 'proposal.md')).toEqual([fs.realpathSync(filePath)]);
expect(resolveArtifactOutputs(tempDir, 'proposal.md')).toEqual([canonical(filePath)]);
expect(artifactOutputExists(tempDir, 'proposal.md')).toBe(true);
});
@@ -38,7 +41,7 @@ describe('artifact-graph/outputs', () => {
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(filePath, 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([fs.realpathSync(filePath)]);
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([canonical(filePath)]);
expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(true);
});
@@ -50,7 +53,7 @@ describe('artifact-graph/outputs', () => {
fs.writeFileSync(matching, 'content');
fs.writeFileSync(nonMatching, 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/foo*.md')).toEqual([fs.realpathSync(matching)]);
expect(resolveArtifactOutputs(tempDir, 'specs/foo*.md')).toEqual([canonical(matching)]);
});
it('supports question-mark glob patterns', () => {
@@ -60,7 +63,7 @@ describe('artifact-graph/outputs', () => {
fs.writeFileSync(matching, 'content');
fs.writeFileSync(path.join(specsDir, 'a10.md'), 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/a?.md')).toEqual([fs.realpathSync(matching)]);
expect(resolveArtifactOutputs(tempDir, 'specs/a?.md')).toEqual([canonical(matching)]);
});
it('supports character class glob patterns', () => {
@@ -73,8 +76,8 @@ describe('artifact-graph/outputs', () => {
fs.writeFileSync(path.join(specsDir, 'c.md'), 'content');
expect(resolveArtifactOutputs(tempDir, 'specs/[ab].md')).toEqual([
fs.realpathSync(aPath),
fs.realpathSync(bPath),
canonical(aPath),
canonical(bPath),
]);
});
@@ -92,10 +95,10 @@ describe('artifact-graph/outputs', () => {
fs.symlinkSync(realChangeDir, aliasChangeDir, process.platform === 'win32' ? 'junction' : 'dir');
expect(resolveArtifactOutputs(aliasChangeDir, 'proposal.md')).toEqual([
fs.realpathSync(proposalPath),
canonical(proposalPath),
]);
expect(resolveArtifactOutputs(aliasChangeDir, 'specs/*/spec.md')).toEqual([
fs.realpathSync(specPath),
canonical(specPath),
]);
});
@@ -103,4 +106,70 @@ describe('artifact-graph/outputs', () => {
expect(resolveArtifactOutputs(tempDir, 'specs/*/spec.md')).toEqual([]);
expect(artifactOutputExists(tempDir, 'specs/*/spec.md')).toBe(false);
});
describe('glob-special characters in directory paths', () => {
it('resolves glob patterns when directory contains parentheses', () => {
const dirWithParens = path.join(tempDir, 'project (work)');
const specDir = path.join(dirWithParens, 'specs', 'cap-a');
const specFile = path.join(specDir, 'spec.md');
fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(specFile, 'content');
expect(resolveArtifactOutputs(dirWithParens, 'specs/*/spec.md')).toEqual([
canonical(specFile),
]);
expect(artifactOutputExists(dirWithParens, 'specs/*/spec.md')).toBe(true);
});
it('resolves glob patterns when directory contains square brackets', () => {
const dirWithBrackets = path.join(tempDir, '[projects]');
const specDir = path.join(dirWithBrackets, 'specs', 'cap-a');
const specFile = path.join(specDir, 'spec.md');
fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(specFile, 'content');
expect(resolveArtifactOutputs(dirWithBrackets, 'specs/*/spec.md')).toEqual([
canonical(specFile),
]);
expect(artifactOutputExists(dirWithBrackets, 'specs/*/spec.md')).toBe(true);
});
it('resolves glob patterns when directory contains curly braces', () => {
const dirWithBraces = path.join(tempDir, '{workspace}');
const specDir = path.join(dirWithBraces, 'specs', 'cap-a');
const specFile = path.join(specDir, 'spec.md');
fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(specFile, 'content');
expect(resolveArtifactOutputs(dirWithBraces, 'specs/*/spec.md')).toEqual([
canonical(specFile),
]);
expect(artifactOutputExists(dirWithBraces, 'specs/*/spec.md')).toBe(true);
});
it('resolves glob patterns when directory contains brace expansion syntax', () => {
const dirWithBraceExpansion = path.join(tempDir, 'project {a,b}');
const specDir = path.join(dirWithBraceExpansion, 'specs', 'cap-a');
const specFile = path.join(specDir, 'spec.md');
fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(specFile, 'content');
expect(resolveArtifactOutputs(dirWithBraceExpansion, 'specs/*/spec.md')).toEqual([
canonical(specFile),
]);
expect(artifactOutputExists(dirWithBraceExpansion, 'specs/*/spec.md')).toBe(true);
});
it('resolves non-glob generates when directory contains special characters', () => {
const dirWithParens = path.join(tempDir, 'project (work)');
const proposalFile = path.join(dirWithParens, 'proposal.md');
fs.mkdirSync(dirWithParens, { recursive: true });
fs.writeFileSync(proposalFile, 'content');
expect(resolveArtifactOutputs(dirWithParens, 'proposal.md')).toEqual([
canonical(proposalFile),
]);
expect(artifactOutputExists(dirWithParens, 'proposal.md')).toBe(true);
});
});
});
+138 -25
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { randomUUID } from 'node:crypto';
import {
getConfigPath,
@@ -13,35 +14,60 @@ import {
describe('telemetry/config', () => {
let tempDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalEnv: NodeJS.ProcessEnv;
function restoreEnv(env: NodeJS.ProcessEnv): void {
for (const key of Object.keys(process.env)) {
delete process.env[key];
}
Object.assign(process.env, env);
}
function defaultConfigDir(): string {
return os.platform() === 'win32'
? path.join(tempDir, 'appdata', 'openspec')
: path.join(tempDir, '.config', 'openspec');
}
function defaultConfigPath(): string {
return path.join(defaultConfigDir(), 'config.json');
}
beforeEach(() => {
// Create temp directory for tests
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Mock HOME/USERPROFILE to point to temp dir
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
originalEnv = { ...process.env };
delete process.env.XDG_CONFIG_HOME;
process.env.APPDATA = path.join(tempDir, 'appdata');
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;
});
afterEach(() => {
// Restore HOME/USERPROFILE
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
// Restore environment
restoreEnv(originalEnv);
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('should return path to config.json in .config/openspec', () => {
it('should return path to config.json in the default config directory', () => {
const result = getConfigPath();
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
expect(result).toBe(defaultConfigPath());
});
it('should use XDG_CONFIG_HOME when set', () => {
const xdgConfigHome = path.join(tempDir, 'xdg-config');
process.env.XDG_CONFIG_HOME = xdgConfigHome;
const result = getConfigPath();
expect(result).toBe(path.join(xdgConfigHome, 'openspec', 'config.json'));
});
});
@@ -52,8 +78,8 @@ describe('telemetry/config', () => {
});
it('should load valid config from file', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
const configDir = defaultConfigDir();
const configPath = defaultConfigPath();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
@@ -65,8 +91,8 @@ describe('telemetry/config', () => {
});
it('should return empty object for invalid JSON', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
const configDir = defaultConfigDir();
const configPath = defaultConfigPath();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{ invalid json }');
@@ -74,11 +100,86 @@ describe('telemetry/config', () => {
const config = await readConfig();
expect(config).toEqual({});
});
it('should migrate telemetry from legacy path when XDG_CONFIG_HOME is set', async () => {
const xdgConfigHome = path.join(tempDir, 'xdg-config');
const legacyConfigDir = path.join(tempDir, '.config', 'openspec');
const legacyConfigPath = path.join(legacyConfigDir, 'config.json');
const newConfigPath = path.join(xdgConfigHome, 'openspec', 'config.json');
process.env.XDG_CONFIG_HOME = xdgConfigHome;
fs.mkdirSync(legacyConfigDir, { recursive: true });
fs.writeFileSync(legacyConfigPath, JSON.stringify({
telemetry: { anonymousId: 'legacy-id', noticeSeen: true },
}));
const config = await readConfig();
expect(config.telemetry).toEqual({ anonymousId: 'legacy-id', noticeSeen: true });
expect(JSON.parse(fs.readFileSync(newConfigPath, 'utf-8')).telemetry).toEqual({
anonymousId: 'legacy-id',
noticeSeen: true,
});
});
it('should not overwrite invalid new config during legacy migration', async () => {
const xdgConfigHome = path.join(tempDir, 'xdg-config');
const legacyConfigDir = path.join(tempDir, '.config', 'openspec');
const legacyConfigPath = path.join(legacyConfigDir, 'config.json');
const newConfigDir = path.join(xdgConfigHome, 'openspec');
const newConfigPath = path.join(newConfigDir, 'config.json');
const invalidJson = '{ invalid json }';
process.env.XDG_CONFIG_HOME = xdgConfigHome;
fs.mkdirSync(legacyConfigDir, { recursive: true });
fs.writeFileSync(legacyConfigPath, JSON.stringify({
telemetry: { anonymousId: 'legacy-id', noticeSeen: true },
}));
fs.mkdirSync(newConfigDir, { recursive: true });
fs.writeFileSync(newConfigPath, invalidJson);
const config = await readConfig();
expect(config.telemetry).toEqual({ anonymousId: 'legacy-id', noticeSeen: true });
expect(fs.readFileSync(newConfigPath, 'utf-8')).toBe(invalidJson);
});
it('should fill only missing telemetry fields from legacy config', async () => {
const xdgConfigHome = path.join(tempDir, 'xdg-config');
const legacyConfigDir = path.join(tempDir, '.config', 'openspec');
const legacyConfigPath = path.join(legacyConfigDir, 'config.json');
const newConfigDir = path.join(xdgConfigHome, 'openspec');
const newConfigPath = path.join(newConfigDir, 'config.json');
process.env.XDG_CONFIG_HOME = xdgConfigHome;
fs.mkdirSync(legacyConfigDir, { recursive: true });
fs.writeFileSync(legacyConfigPath, JSON.stringify({
telemetry: { anonymousId: 'legacy-id', noticeSeen: true },
legacyOnly: 'ignored',
}));
fs.mkdirSync(newConfigDir, { recursive: true });
fs.writeFileSync(newConfigPath, JSON.stringify({
featureFlags: { existing: true },
telemetry: { anonymousId: 'new-id' },
}));
const config = await readConfig();
expect(config.featureFlags).toEqual({ existing: true });
expect(config.telemetry).toEqual({ anonymousId: 'new-id', noticeSeen: true });
expect((config as Record<string, unknown>).legacyOnly).toBeUndefined();
expect(JSON.parse(fs.readFileSync(newConfigPath, 'utf-8')).telemetry).toEqual({
anonymousId: 'new-id',
noticeSeen: true,
});
});
});
describe('writeConfig', () => {
it('should create directory if it does not exist', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configDir = defaultConfigDir();
await writeConfig({ telemetry: { noticeSeen: true } });
@@ -86,7 +187,19 @@ describe('telemetry/config', () => {
});
it('should write config to file', async () => {
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
const configPath = defaultConfigPath();
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('test-123');
});
it('should write config to XDG_CONFIG_HOME when set', async () => {
const xdgConfigHome = path.join(tempDir, 'xdg-config');
const configPath = path.join(xdgConfigHome, 'openspec', 'config.json');
process.env.XDG_CONFIG_HOME = xdgConfigHome;
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
@@ -96,8 +209,8 @@ describe('telemetry/config', () => {
});
it('should preserve existing fields when updating', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
const configDir = defaultConfigDir();
const configPath = defaultConfigPath();
// Create initial config with other fields
fs.mkdirSync(configDir, { recursive: true });
@@ -116,8 +229,8 @@ describe('telemetry/config', () => {
});
it('should deep merge telemetry fields', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
const configDir = defaultConfigDir();
const configPath = defaultConfigPath();
// Create initial config
fs.mkdirSync(configDir, { recursive: true });
@@ -142,8 +255,8 @@ describe('telemetry/config', () => {
});
it('should return telemetry section from config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
const configDir = defaultConfigDir();
const configPath = defaultConfigPath();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
@@ -159,15 +272,15 @@ describe('telemetry/config', () => {
it('should create telemetry config when none exists', async () => {
await updateTelemetryConfig({ anonymousId: 'new-id' });
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
const configPath = defaultConfigPath();
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('new-id');
});
it('should merge with existing telemetry config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
const configDir = defaultConfigDir();
const configPath = defaultConfigPath();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
+18 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as nodeFs from 'fs';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
@@ -92,6 +93,22 @@ describe('FileSystemUtils', () => {
});
});
describe('canonicalizeExistingPath', () => {
it('should prefer the native realpath resolver when available', async () => {
const filePath = path.join(testDir, 'canonical.txt');
await fs.writeFile(filePath, 'content');
const nativeSpy = vi.spyOn(nodeFs.realpathSync, 'native');
const resolved = FileSystemUtils.canonicalizeExistingPath(filePath);
expect(nativeSpy).toHaveBeenCalledWith(filePath);
expect(resolved).toBe(nodeFs.realpathSync.native(filePath));
nativeSpy.mockRestore();
});
});
describe('writeFile', () => {
it('should write content to file', async () => {
const filePath = path.join(testDir, 'output.txt');