diff --git a/.changeset/quiet-schemas-rollback.md b/.changeset/quiet-schemas-rollback.md new file mode 100644 index 0000000000..52a97ed50a --- /dev/null +++ b/.changeset/quiet-schemas-rollback.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Make `schema init --default` validate and stage config changes before installing a schema, and roll back both files if either install fails. diff --git a/docs-lab/Notes.md b/docs-lab/Notes.md index c461ff21b8..f4217fbf52 100644 --- a/docs-lab/Notes.md +++ b/docs-lab/Notes.md @@ -76,10 +76,6 @@ the agent to consume it. Running `openspec update` should refresh them. Product issues found while verifying the schema system (all file refs current as of today): -- `schema init --default` writes a `defaultSchema:` key to openspec/config.yaml that nothing - reads (schema.ts:961-978; readProjectConfig parses only schema/context/rules/operations/ - references/store). The flag should write `schema:` or be removed. The docs now say to set - `schema:` by hand. - `schema init` next-steps output prints a command that doesn't exist in that form: "Use with: openspec new --schema " (schema.ts:999); real syntax is `openspec new change --schema `. diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index d8af89fc6a..57982c7214 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -1317,11 +1317,13 @@ With no `--description` and no `--artifacts` in an interactive terminal, init pr |---|---| | `--description ` | Schema description. Default: `Custom workflow schema for `. | | `--artifacts ` | Comma-separated artifact IDs from `proposal`, `specs`, `design`, `tasks`. Default: all four. | -| `--default` | Writes `defaultSchema` to `openspec/config.yaml`. Nothing reads that key. To make the schema the default, set `schema: ` there yourself. | +| `--default` | Writes `schema: ` to the existing `openspec/config.yaml` or `openspec/config.yml`. Creates `openspec/config.yaml` if neither exists. New changes use this schema. | | `--no-default` | Skip the prompt about the default. | | `--force` | Overwrite an existing schema with the same name. | | `--json` | Print the result as JSON. | +Schema creation and the `--default` config update are one operation. If OpenSpec cannot validate or write the config, it leaves both the config and any existing schema unchanged. + **Output** ``` diff --git a/openspec/specs/schema-init-command/spec.md b/openspec/specs/schema-init-command/spec.md index 88fb170382..12df38d29c 100644 --- a/openspec/specs/schema-init-command/spec.md +++ b/openspec/specs/schema-init-command/spec.md @@ -50,16 +50,34 @@ The CLI SHALL offer to set the newly created schema as the project default. #### Scenario: Set as default interactively - **WHEN** user runs `openspec schema init my-workflow` in interactive mode - **AND** user confirms setting as default -- **THEN** system updates `openspec/config.yaml` with `defaultSchema: my-workflow` +- **THEN** system updates an existing `openspec/config.yaml` or `openspec/config.yml` in place with `schema: my-workflow` +- **AND** removes the legacy `defaultSchema` key when updating an existing configuration +- **AND** creates `openspec/config.yaml` when neither configuration file exists #### Scenario: Set as default via flag - **WHEN** user runs `openspec schema init my-workflow --default` -- **THEN** system creates schema and updates `openspec/config.yaml` with `defaultSchema: my-workflow` +- **THEN** system creates the schema and updates an existing `openspec/config.yaml` or `openspec/config.yml` in place with `schema: my-workflow` +- **AND** removes the legacy `defaultSchema` key when updating an existing configuration +- **AND** creates `openspec/config.yaml` when neither configuration file exists #### Scenario: Skip setting default - **WHEN** user runs `openspec schema init my-workflow --no-default` - **THEN** system creates schema without modifying `openspec/config.yaml` +#### Scenario: Invalid config prevents schema creation +- **GIVEN** `openspec/config.yaml` or `openspec/config.yml` is invalid YAML, is not a YAML object, is not a regular file, or is not writable +- **WHEN** user runs `openspec schema init my-workflow --default` +- **THEN** the command exits with a non-zero status +- **AND** does not create `openspec/schemas/my-workflow/` +- **AND** leaves the config byte-for-byte unchanged + +#### Scenario: Config failure preserves a schema during forced replacement +- **GIVEN** `openspec/schemas/my-workflow/` already contains user-authored files +- **AND** the project config cannot be validated or atomically replaced +- **WHEN** user runs `openspec schema init my-workflow --force --default` +- **THEN** the command exits with a non-zero status +- **AND** restores the existing schema and config byte-for-byte + ### Requirement: Schema init outputs JSON format The CLI SHALL support `--json` flag for machine-readable output. @@ -92,4 +110,3 @@ The CLI SHALL validate all requested artifact IDs before replacing an existing p - **WHEN** the user runs `schema init` with `--force` and only valid artifact IDs - **THEN** the command replaces the existing schema with the newly generated schema - **AND** reports successful creation - diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 5ec8172be7..c05d2956fe 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; import ora from 'ora'; -import { stringify as stringifyYaml, parseDocument } from 'yaml'; +import { stringify as stringifyYaml, parseDocument, isMap } from 'yaml'; import { getSchemaDir, getProjectSchemasDir, @@ -14,6 +14,7 @@ import { } from '../core/artifact-graph/resolver.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js'; +import { resolveConfigFilePath } from '../core/project-config.js'; import { FileSystemUtils } from '../utils/file-system.js'; /** @@ -371,6 +372,100 @@ function fingerprintDir(dir: string): string { return hash.digest('hex'); } +interface PreparedConfigUpdate { + path: string; + content: Buffer; + originalContent: Buffer | null; + originalMode: number | null; +} + +/** @internal File-operation seam for transactional failure tests. */ +export const schemaInitFileOperations = { + renameSync: fs.renameSync, +}; + +async function prepareDefaultConfigUpdate( + projectRoot: string, + schemaName: string +): Promise { + const configPath = + resolveConfigFilePath(projectRoot) ?? + path.join(projectRoot, 'openspec', 'config.yaml'); + FileSystemUtils.assertProjectArtifactPath(projectRoot, configPath); + + if (fs.existsSync(configPath)) { + const stats = fs.lstatSync(configPath); + if (stats.isSymbolicLink()) { + throw new Error( + `Cannot set the default schema: ${path.basename(configPath)} must be a regular file, not a symbolic link` + ); + } + if (!stats.isFile()) { + throw new Error( + `Cannot set the default schema: ${path.basename(configPath)} must be a regular file` + ); + } + if ( + !(await FileSystemUtils.canWriteFile(configPath)) || + !(await FileSystemUtils.canWriteFile(path.dirname(configPath))) + ) { + throw new Error( + `Cannot set the default schema: ${path.basename(configPath)} is not writable` + ); + } + + const originalContent = fs.readFileSync(configPath); + const config = parseDocument(originalContent.toString('utf-8')); + if (config.errors.length > 0) { + throw new Error( + `Cannot set the default schema: ${path.basename(configPath)} is invalid YAML` + ); + } + if (config.contents !== null && !isMap(config.contents)) { + throw new Error( + `Cannot set the default schema: ${path.basename(configPath)} must contain a YAML object` + ); + } + config.set('schema', schemaName); + config.delete('defaultSchema'); + + return { + path: configPath, + content: Buffer.from(config.toString()), + originalContent, + originalMode: stats.mode, + }; + } + + if (!(await FileSystemUtils.canWriteFile(configPath))) { + throw new Error( + `Cannot set the default schema: ${path.dirname(configPath)} is not writable` + ); + } + + return { + path: configPath, + content: Buffer.from(stringifyYaml({ schema: schemaName })), + originalContent: null, + originalMode: null, + }; +} + +function configMatchesPreparedState(prepared: PreparedConfigUpdate): boolean { + if (prepared.originalContent === null) { + return !fs.existsSync(prepared.path); + } + if (!fs.existsSync(prepared.path)) return false; + + const stats = fs.lstatSync(prepared.path); + return ( + stats.isFile() && + !stats.isSymbolicLink() && + stats.mode === prepared.originalMode && + fs.readFileSync(prepared.path).equals(prepared.originalContent) + ); +} + /** * Default artifacts with descriptions for schema init. */ @@ -1103,53 +1198,175 @@ export function registerSchemaCommand(program: Command): void { }; } - // Replace only after all inputs have been collected and validated - if (schemaExists) { - if (spinner) spinner.start(`Removing existing schema '${name}'...`); - fs.rmSync(schemaDir, { recursive: true }); - } + // Parse and serialize the config before staging any schema files. This + // makes malformed, non-object, linked, and read-only configs fail before + // an existing schema can be moved or a new one can appear. + const preparedConfig = options?.default + ? await prepareDefaultConfigUpdate(projectRoot, name) + : null; + const schemasDir = getProjectSchemasDir(projectRoot); + FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir); + const authorizedSchemaFingerprint = schemaExists + ? fingerprintDir(schemaDir) + : null; - // Create schema directory if (spinner) spinner.start(`Creating schema '${name}'...`); - fs.mkdirSync(schemaDir, { recursive: true }); - - fs.writeFileSync( - path.join(schemaDir, 'schema.yaml'), - stringifyYaml(schema) + fs.mkdirSync(schemasDir, { recursive: true }); + const schemaStagingDir = fs.mkdtempSync( + path.join(schemasDir, '.init-staging-') ); + let configStagingDir: string | null = null; + let stagedConfigPath: string | null = null; - // Create template files in templates/ subdirectory (standard location) - const templatesDir = path.join(schemaDir, 'templates'); - for (const artifact of selectedArtifacts) { - const templatePath = path.join(templatesDir, artifact.template); - const templateDir = path.dirname(templatePath); + try { + fs.writeFileSync( + path.join(schemaStagingDir, 'schema.yaml'), + stringifyYaml(schema) + ); - if (!fs.existsSync(templateDir)) { - fs.mkdirSync(templateDir, { recursive: true }); + const templatesDir = path.join(schemaStagingDir, 'templates'); + for (const artifact of selectedArtifacts) { + const templatePath = path.join(templatesDir, artifact.template); + fs.mkdirSync(path.dirname(templatePath), { recursive: true }); + fs.writeFileSync(templatePath, createDefaultTemplate(artifact.id)); } - // Create default template content - const templateContent = createDefaultTemplate(artifact.id); - fs.writeFileSync(templatePath, templateContent); - } + const validation = validateSchema(schemaStagingDir); + if (!validation.valid) { + throw new Error( + `Generated schema failed validation: ${validation.issues + .map((issue) => issue.message) + .join('; ')}` + ); + } - // Update config if --default - if (options?.default) { - const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); + if (preparedConfig) { + const configDir = path.dirname(preparedConfig.path); + configStagingDir = fs.mkdtempSync( + path.join(configDir, '.schema-init-config-') + ); + stagedConfigPath = path.join( + configStagingDir, + path.basename(preparedConfig.path) + ); + fs.writeFileSync(stagedConfigPath, preparedConfig.content); + if (preparedConfig.originalMode !== null) { + fs.chmodSync(stagedConfigPath, preparedConfig.originalMode); + } + } - if (fs.existsSync(configPath)) { - const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml'); - const configContent = fs.readFileSync(configPath, 'utf-8'); - const config = parseYaml(configContent) || {}; - config.defaultSchema = name; - fs.writeFileSync(configPath, stringifyYaml2(config)); - } else { - // Create config file - const configDir = path.dirname(configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); + // Re-resolve both destinations immediately before the first move so + // a parent symlink swap during staging cannot redirect the commit. + FileSystemUtils.assertProjectArtifactPath(projectRoot, schemaDir); + if (preparedConfig) { + FileSystemUtils.assertProjectArtifactPath(projectRoot, preparedConfig.path); + } + + const currentSchemaFingerprint = fs.existsSync(schemaDir) + ? fingerprintDir(schemaDir) + : null; + if (currentSchemaFingerprint !== authorizedSchemaFingerprint) { + throw new Error( + `Schema '${name}' changed on disk while initialization was being prepared. ` + + 'Aborted to preserve those concurrent changes.' + ); + } + if (preparedConfig && !configMatchesPreparedState(preparedConfig)) { + throw new Error( + `${path.basename(preparedConfig.path)} changed on disk while initialization was being prepared. ` + + 'Aborted to preserve those concurrent changes.' + ); + } + + const token = `${process.pid}-${Date.now()}`; + const schemaBackup = `${schemaDir}.init-backup-${token}`; + const configBackup = preparedConfig + ? `${preparedConfig.path}.init-backup-${token}` + : null; + let schemaBackedUp = false; + let configBackedUp = false; + let schemaInstalled = false; + let configInstalled = false; + + try { + if (schemaExists) { + schemaInitFileOperations.renameSync(schemaDir, schemaBackup); + schemaBackedUp = true; + } + if (preparedConfig && preparedConfig.originalContent !== null) { + schemaInitFileOperations.renameSync(preparedConfig.path, configBackup!); + configBackedUp = true; + } + + schemaInitFileOperations.renameSync(schemaStagingDir, schemaDir); + schemaInstalled = true; + if (preparedConfig && stagedConfigPath) { + schemaInitFileOperations.renameSync(stagedConfigPath, preparedConfig.path); + configInstalled = true; + } + } catch (installError) { + const rollbackErrors: string[] = []; + try { + if (configInstalled && preparedConfig) { + fs.rmSync(preparedConfig.path, { force: true }); + } + if (configBackedUp && preparedConfig && configBackup) { + schemaInitFileOperations.renameSync(configBackup, preparedConfig.path); + } + } catch (rollbackError) { + rollbackErrors.push(`config: ${(rollbackError as Error).message}`); + } + try { + if (schemaInstalled) { + fs.rmSync(schemaDir, { recursive: true, force: true }); + } + if (schemaBackedUp) { + schemaInitFileOperations.renameSync(schemaBackup, schemaDir); + } + } catch (rollbackError) { + rollbackErrors.push(`schema: ${(rollbackError as Error).message}`); + } + + if (rollbackErrors.length > 0) { + throw new Error( + `Schema initialization failed and rollback was incomplete (${rollbackErrors.join(', ')}). ` + + `Recovery backups may remain beside ${schemaDir} and ${preparedConfig?.path ?? 'the config file'}.`, + { cause: installError } + ); + } + throw installError; + } + + // The transaction is committed. Cleanup cannot turn success into a + // false failure, so leave a recoverable backup and warn if removal is + // blocked instead of reporting that initialization failed. + for (const backup of [ + schemaBackedUp ? schemaBackup : null, + configBackedUp ? configBackup : null, + ]) { + if (!backup) continue; + try { + fs.rmSync(backup, { recursive: true, force: true }); + } catch (cleanupError) { + console.error( + `Warning: initialization succeeded, but the backup at ${backup} could not be removed: ${(cleanupError as Error).message}` + ); + } + } + } catch (error) { + try { + fs.rmSync(schemaStagingDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup must not hide the operation's real error. + } + throw error; + } finally { + if (configStagingDir) { + try { + fs.rmSync(configStagingDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup. A committed config has already moved out. } - fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name })); } } diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index 9571bacf2f..aa8807e95c 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -3,14 +3,51 @@ import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; - -async function runSchemaCommand(args: string[]): Promise { - const { registerSchemaCommand } = await import('../../src/commands/schema.js'); +import { runCLI } from '../helpers/run-cli.js'; + +async function runSchemaCommand( + args: string[], + schemaModule?: typeof import('../../src/commands/schema.js') +): Promise { + const { registerSchemaCommand } = + schemaModule ?? (await import('../../src/commands/schema.js')); const program = new Command(); registerSchemaCommand(program); await program.parseAsync(['node', 'openspec', 'schema', ...args]); } +function snapshotTree(root: string): Array<{ path: string; type: string; content?: string }> | null { + if (!fs.existsSync(root)) return null; + + const entries: Array<{ path: string; type: string; content?: string }> = []; + const walk = (current: string, relative: string): void => { + for (const entry of fs + .readdirSync(current, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + const absolutePath = path.join(current, entry.name); + const relativePath = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + entries.push({ path: relativePath, type: 'directory' }); + walk(absolutePath, relativePath); + } else if (entry.isFile()) { + entries.push({ + path: relativePath, + type: 'file', + content: fs.readFileSync(absolutePath).toString('base64'), + }); + } else { + entries.push({ + path: relativePath, + type: 'other', + content: fs.readlinkSync(absolutePath), + }); + } + } + }; + walk(root, ''); + return entries; +} + describe('schema command', () => { let tempDir: string; let originalCwd: string; @@ -387,6 +424,297 @@ artifacts: }); describe('schema init', () => { + const failureModes = [ + { label: 'new schema', force: false }, + { label: 'forced replacement', force: true }, + ]; + + function prepareSchemaForFailure(force: boolean): { + schemaDir: string; + before: ReturnType; + } { + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'my-workflow'); + if (force) { + fs.mkdirSync(path.join(schemaDir, 'nested'), { recursive: true }); + fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), 'original schema bytes\n'); + fs.writeFileSync(path.join(schemaDir, 'nested', 'keep.bin'), Buffer.from([0, 1, 255])); + } + return { schemaDir, before: snapshotTree(schemaDir) }; + } + + async function runDefaultInit( + force: boolean, + schemaModule?: typeof import('../../src/commands/schema.js') + ): Promise { + await runSchemaCommand( + [ + 'init', + 'my-workflow', + ...(force ? ['--force'] : []), + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ], + schemaModule + ); + } + + it('uses the configured default in the next new change command', async () => { + const initialized = await runCLI( + [ + 'schema', + 'init', + 'my-workflow', + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ], + { cwd: tempDir } + ); + expect(initialized.exitCode).toBe(0); + + const created = await runCLI(['new', 'change', 'uses-default', '--json'], { + cwd: tempDir, + }); + expect(created.exitCode).toBe(0); + expect( + fs.readFileSync( + path.join(tempDir, 'openspec', 'changes', 'uses-default', '.openspec.yaml'), + 'utf-8' + ) + ).toContain('schema: my-workflow'); + }); + + describe.each(failureModes)('$label with --default', ({ force }) => { + it('preserves the schema and invalid YAML config byte-for-byte', async () => { + const { schemaDir, before } = prepareSchemaForFailure(force); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + const configBytes = Buffer.from('schema: [unterminated\n'); + fs.writeFileSync(configPath, configBytes); + + await runDefaultInit(force); + + expect(process.exitCode).toBe(1); + expect(snapshotTree(schemaDir)).toEqual(before); + expect(fs.readFileSync(configPath)).toEqual(configBytes); + }); + + it('preserves the schema and scalar YAML config byte-for-byte', async () => { + const { schemaDir, before } = prepareSchemaForFailure(force); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + const configBytes = Buffer.from('not-an-object\n'); + fs.writeFileSync(configPath, configBytes); + + await runDefaultInit(force); + + expect(process.exitCode).toBe(1); + expect(snapshotTree(schemaDir)).toEqual(before); + expect(fs.readFileSync(configPath)).toEqual(configBytes); + }); + + it('preserves the schema when the config path is a directory', async () => { + const { schemaDir, before } = prepareSchemaForFailure(force); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.mkdirSync(configPath); + fs.writeFileSync(path.join(configPath, 'keep.txt'), 'keep me'); + + await runDefaultInit(force); + + expect(process.exitCode).toBe(1); + expect(snapshotTree(schemaDir)).toEqual(before); + expect(snapshotTree(configPath)).toEqual([ + { + path: 'keep.txt', + type: 'file', + content: Buffer.from('keep me').toString('base64'), + }, + ]); + }); + + it('preserves the schema and read-only config byte-for-byte', async () => { + if (process.platform === 'win32') return; + + const { schemaDir, before } = prepareSchemaForFailure(force); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + const configBytes = Buffer.from('schema: existing\n'); + fs.writeFileSync(configPath, configBytes, { mode: 0o444 }); + + try { + await runDefaultInit(force); + + expect(process.exitCode).toBe(1); + expect(snapshotTree(schemaDir)).toEqual(before); + expect(fs.readFileSync(configPath)).toEqual(configBytes); + } finally { + fs.chmodSync(configPath, 0o644); + } + }); + + it('preserves the schema and an external config symlink target', async () => { + if (process.platform === 'win32') return; + + const { schemaDir, before } = prepareSchemaForFailure(force); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-schema-config-')); + const outsideConfig = path.join(outsideDir, 'config.yaml'); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + const configBytes = Buffer.from('schema: untouched\n'); + fs.writeFileSync(outsideConfig, configBytes); + fs.symlinkSync(outsideConfig, configPath, 'file'); + + try { + await runDefaultInit(force); + + expect(process.exitCode).toBe(1); + expect(snapshotTree(schemaDir)).toEqual(before); + expect(fs.readFileSync(outsideConfig)).toEqual(configBytes); + expect(fs.lstatSync(configPath).isSymbolicLink()).toBe(true); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + }); + + it('rolls back a forced schema replacement when installing the config fails', async () => { + const { schemaDir, before } = prepareSchemaForFailure(true); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + const configBytes = Buffer.from('schema: existing\ncontext: keep me\n'); + fs.writeFileSync(configPath, configBytes); + const schemaModule = await import('../../src/commands/schema.js'); + const { schemaInitFileOperations } = schemaModule; + const renameSync = schemaInitFileOperations.renameSync; + const renameCalls: Array<[string, string]> = []; + schemaInitFileOperations.renameSync = (source, destination) => { + renameCalls.push([String(source), String(destination)]); + if (String(source).includes('.schema-init-config-')) { + throw new Error('simulated config install failure'); + } + renameSync(source, destination); + }; + + try { + await runDefaultInit(true, schemaModule); + } finally { + schemaInitFileOperations.renameSync = renameSync; + } + + expect(process.exitCode).toBe(1); + expect(renameCalls[3]).toEqual([ + expect.stringContaining('.schema-init-config-'), + expect.stringMatching(/[/\\]openspec[/\\]config\.yaml$/), + ]); + expect(snapshotTree(schemaDir)).toEqual(before); + expect(fs.readFileSync(configPath)).toEqual(configBytes); + }); + + it('makes the new schema the one the config loader resolves when --default is given', async () => { + const { readProjectConfig } = await import('../../src/core/project-config.js'); + + await runSchemaCommand([ + 'init', + 'my-workflow', + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ]); + + expect(process.exitCode).toBeUndefined(); + // Asserted through the loader, not the raw YAML: --default's whole job is + // that the next `new change` picks the schema up, and the key it has to + // write to make that happen is the one readProjectConfig looks at (#1708). + expect(readProjectConfig(tempDir)?.schema).toBe('my-workflow'); + }); + + it('keeps the rest of an existing config when --default rewrites it', async () => { + const { readProjectConfig } = await import('../../src/core/project-config.js'); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.writeFileSync(configPath, 'schema: spec-driven\ncontext: keep me\n'); + + await runSchemaCommand([ + 'init', + 'my-workflow', + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ]); + + const config = readProjectConfig(tempDir); + expect(config?.schema).toBe('my-workflow'); + expect(config?.context).toBe('keep me'); + }); + + it('updates config.yml in place without hiding its settings behind a new config.yaml', async () => { + const { readProjectConfig } = await import('../../src/core/project-config.js'); + const configPath = path.join(tempDir, 'openspec', 'config.yml'); + fs.writeFileSync( + configPath, + '# project context\nschema: spec-driven\ncontext: keep me\n' + ); + + await runSchemaCommand([ + 'init', + 'my-workflow', + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ]); + + expect(fs.existsSync(path.join(tempDir, 'openspec', 'config.yaml'))).toBe(false); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('# project context'); + expect(readProjectConfig(tempDir)).toMatchObject({ + schema: 'my-workflow', + context: 'keep me', + }); + }); + + it('does not set the default through a config symlink outside the project', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-schema-config-')); + const outsideConfig = path.join(outsideDir, 'config.yaml'); + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.writeFileSync(outsideConfig, 'schema: untouched\n'); + fs.symlinkSync(outsideConfig, configPath, 'file'); + + try { + await runSchemaCommand([ + 'init', + 'my-workflow', + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ]); + + expect(process.exitCode).toBe(1); + expect(fs.readFileSync(outsideConfig, 'utf-8')).toBe('schema: untouched\n'); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('clears the dead defaultSchema key a previous run left behind', async () => { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.writeFileSync(configPath, 'defaultSchema: stale-workflow\n'); + + await runSchemaCommand([ + 'init', + 'my-workflow', + '--artifacts', + 'proposal,specs,tasks', + '--default', + '--json', + ]); + + // Both keys present would leave the file naming two different defaults, + // one of which does nothing. + const written = fs.readFileSync(configPath, 'utf-8'); + expect(written).toContain('schema: my-workflow'); + expect(written).not.toContain('defaultSchema'); + }); + it('should preserve an existing schema when forced init rejects an artifact', async () => { const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'tdd-driven'); const schemaPath = path.join(schemaDir, 'schema.yaml');