mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a88186909 | ||
|
|
493605756f |
@@ -1,17 +1,5 @@
|
||||
# @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
|
||||
|
||||
@@ -42,6 +42,10 @@ 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
@@ -15,20 +15,21 @@ 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(fullPath).isFile()
|
||||
? [FileSystemUtils.canonicalizeExistingPath(fullPath)]
|
||||
return fs.statSync(fullPattern).isFile()
|
||||
? [FileSystemUtils.canonicalizeExistingPath(fullPattern)]
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
||||
const normalizedPattern = FileSystemUtils.toPosixPath(fullPattern);
|
||||
const matches = fg
|
||||
.sync(normalizedPattern, { cwd: changeDir, onlyFiles: true, absolute: true })
|
||||
.sync(normalizedPattern, { onlyFiles: true })
|
||||
.map((match) => FileSystemUtils.canonicalizeExistingPath(path.normalize(match)));
|
||||
|
||||
return Array.from(new Set(matches)).sort();
|
||||
|
||||
+19
-116
@@ -1,19 +1,10 @@
|
||||
/**
|
||||
* Global configuration for telemetry state.
|
||||
* Stores anonymous ID and notice-seen flag in the platform-appropriate config directory.
|
||||
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
|
||||
*/
|
||||
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;
|
||||
@@ -25,112 +16,13 @@ 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.
|
||||
* 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/
|
||||
* Uses ~/.config/openspec/config.json on all platforms.
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
const configDir = getConfigDir();
|
||||
return path.join(configDir, CONFIG_FILE_NAME);
|
||||
const configDir = path.join(os.homedir(), '.config', 'openspec');
|
||||
return path.join(configDir, 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,9 +31,16 @@ export function getConfigPath(): string {
|
||||
*/
|
||||
export async function readConfig(): Promise<GlobalConfig> {
|
||||
const configPath = getConfigPath();
|
||||
const read = await readConfigFile(configPath);
|
||||
const config = read.status === 'ok' ? read.config : {};
|
||||
return migrateLegacyTelemetryConfig(configPath, config, read.status !== 'invalid');
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,6 +49,10 @@ 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();
|
||||
@@ -160,7 +63,7 @@ export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
|
||||
}
|
||||
|
||||
await writeConfigFile(configPath, merged);
|
||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,70 +106,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-138
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -14,60 +13,35 @@ import {
|
||||
|
||||
describe('telemetry/config', () => {
|
||||
let tempDir: string;
|
||||
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');
|
||||
}
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for tests
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Mock HOME/USERPROFILE to point to temp dir
|
||||
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
|
||||
originalEnv = { ...process.env };
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
process.env.APPDATA = path.join(tempDir, 'appdata');
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.USERPROFILE = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore environment
|
||||
restoreEnv(originalEnv);
|
||||
// Restore HOME/USERPROFILE
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getConfigPath', () => {
|
||||
it('should return path to config.json in the default config directory', () => {
|
||||
it('should return path to config.json in .config/openspec', () => {
|
||||
const result = getConfigPath();
|
||||
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'));
|
||||
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,8 +52,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should load valid config from file', async () => {
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
@@ -91,8 +65,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should return empty object for invalid JSON', async () => {
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, '{ invalid json }');
|
||||
@@ -100,86 +74,11 @@ 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 = defaultConfigDir();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
@@ -187,19 +86,7 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should write config to file', async () => {
|
||||
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;
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
|
||||
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
|
||||
|
||||
@@ -209,8 +96,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should preserve existing fields when updating', async () => {
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config with other fields
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -229,8 +116,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should deep merge telemetry fields', async () => {
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -255,8 +142,8 @@ describe('telemetry/config', () => {
|
||||
});
|
||||
|
||||
it('should return telemetry section from config', async () => {
|
||||
const configDir = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
@@ -272,15 +159,15 @@ describe('telemetry/config', () => {
|
||||
it('should create telemetry config when none exists', async () => {
|
||||
await updateTelemetryConfig({ anonymousId: 'new-id' });
|
||||
|
||||
const configPath = defaultConfigPath();
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
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 = defaultConfigDir();
|
||||
const configPath = defaultConfigPath();
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user