@fission-ai/openspec 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. package/schemas/spec-driven/templates/proposal.md +6 -4
package/README.md CHANGED
@@ -248,7 +248,9 @@ OpenSpec collects anonymous usage stats.
248
248
 
249
249
  We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
250
250
 
251
- **Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
251
+ **Opt-out (any one is enough):**
252
+ - `openspec config set telemetry.enabled false` (global config; unset means on)
253
+ - `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config)
252
254
 
253
255
  </details>
254
256
 
@@ -5,6 +5,21 @@ declare const program: Command;
5
5
  * For example: 'change show' -> 'change:show'
6
6
  */
7
7
  export declare function getCommandPath(command: Command): string;
8
+ /**
9
+ * True when the executing command asked for JSON output — used to suppress the
10
+ * first-run telemetry notice so stdout stays a single valid JSON document.
11
+ *
12
+ * `--json` reaches commands three ways, so a single parsed option is not enough:
13
+ * - declared on the leaf (`openspec status --json`) → `opts().json`
14
+ * - declared on a parent group and read via globals (`openspec workset --json list`)
15
+ * → `optsWithGlobals().json`
16
+ * - a residual arg on a permissive group that never declares the option
17
+ * (`openspec store --json`, which detects it from `command.args`) → `args`
18
+ *
19
+ * Suppressing is always safe: the disclosure is only deferred to the next
20
+ * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
21
+ */
22
+ export declare function isJsonRun(command: Command): boolean;
8
23
  export { program };
9
24
  export declare function runCli(argv?: string[]): void;
10
25
  //# sourceMappingURL=index.d.ts.map
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'module';
4
4
  import ora from 'ora';
5
5
  import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { promises as fs } from 'fs';
7
+ import { existsSync, promises as fs } from 'fs';
8
8
  import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
10
  import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, } from '../core/version-check.js';
@@ -72,6 +72,24 @@ export function getCommandPath(command) {
72
72
  }
73
73
  return names.join(':') || 'openspec';
74
74
  }
75
+ /**
76
+ * True when the executing command asked for JSON output — used to suppress the
77
+ * first-run telemetry notice so stdout stays a single valid JSON document.
78
+ *
79
+ * `--json` reaches commands three ways, so a single parsed option is not enough:
80
+ * - declared on the leaf (`openspec status --json`) → `opts().json`
81
+ * - declared on a parent group and read via globals (`openspec workset --json list`)
82
+ * → `optsWithGlobals().json`
83
+ * - a residual arg on a permissive group that never declares the option
84
+ * (`openspec store --json`, which detects it from `command.args`) → `args`
85
+ *
86
+ * Suppressing is always safe: the disclosure is only deferred to the next
87
+ * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
88
+ */
89
+ export function isJsonRun(command) {
90
+ return (command.optsWithGlobals().json === true ||
91
+ command.args.includes('--json'));
92
+ }
75
93
  program
76
94
  .name('openspec')
77
95
  .description('AI-native system for spec-driven development')
@@ -87,8 +105,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
87
105
  if (opts.color === false) {
88
106
  process.env.NO_COLOR = '1';
89
107
  }
90
- // Show first-run telemetry notice (if not seen)
91
- await maybeShowTelemetryNotice();
108
+ // Show first-run telemetry notice (if not seen). Suppress it whenever the run
109
+ // asked for JSON so stdout stays a single valid JSON document (see isJsonRun).
110
+ await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
92
111
  // Track command execution (use actionCommand to get the actual subcommand)
93
112
  const commandPath = getCommandPath(actionCommand);
94
113
  await trackCommand(commandPath, version);
@@ -97,7 +116,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
97
116
  program.hook('postAction', async () => {
98
117
  await shutdown();
99
118
  });
100
- const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
119
+ const availableToolIds = AI_TOOLS
120
+ .filter((tool) => tool.skillsDir || tool.globalSkillsDir)
121
+ .map((tool) => tool.value);
101
122
  const toolAliasNote = Object.entries(TOOL_ID_ALIASES)
102
123
  .map(([retired, current]) => `${retired} (now ${current})`)
103
124
  .join(', ');
@@ -109,6 +130,8 @@ program
109
130
  .option('--force', 'Auto-cleanup legacy files without prompting')
110
131
  .option('--profile <profile>', 'Override global config profile (core or custom)')
111
132
  .option('--no-animation', 'Show a static welcome screen instead of the animated one')
133
+ .option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting')
134
+ .option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting')
112
135
  .action(async (targetPath = '.', options) => {
113
136
  try {
114
137
  // Validate that the path is a valid directory
@@ -137,6 +160,7 @@ program
137
160
  force: options?.force,
138
161
  profile: options?.profile,
139
162
  animation: options?.animation,
163
+ copilotCloud: options?.copilotCloud,
140
164
  });
141
165
  await initCommand.execute(targetPath);
142
166
  }
@@ -241,6 +265,9 @@ program
241
265
  const root = await resolveRootForCommand(options ?? {}, {
242
266
  json: options?.json,
243
267
  failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
268
+ // Preserve the cwd fallback for pre-config.yaml projects. The resolver
269
+ // still lets a registered/default store take precedence over it.
270
+ allowImplicitRoot: existsSync(path.join(process.cwd(), 'openspec', 'project.md')),
244
271
  });
245
272
  if (!root) {
246
273
  return;
@@ -378,6 +405,7 @@ program
378
405
  .option('--all', 'Validate all changes and specs')
379
406
  .option('--changes', 'Validate all changes')
380
407
  .option('--specs', 'Validate all specs')
408
+ .option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
381
409
  .option('--type <type>', 'Specify item type when ambiguous: change|spec')
382
410
  .option('--strict', 'Enable strict validation mode')
383
411
  .option('--json', 'Output validation results as JSON')
@@ -567,12 +595,18 @@ program
567
595
  .command('schemas')
568
596
  .description('List available workflow schemas with descriptions')
569
597
  .option('--json', 'Output as JSON (for agent use)')
598
+ .option('--store <id>', STORE_OPTION_DESCRIPTION)
599
+ .addOption(hiddenStorePathOption())
570
600
  .action(async (options) => {
571
601
  try {
572
602
  await schemasCommand(options);
573
603
  }
574
604
  catch (error) {
575
- failWithError(error);
605
+ failWithError(error, {
606
+ enabled: options.json,
607
+ payload: { schemas: [], root: null },
608
+ fallbackCode: 'schemas_error',
609
+ });
576
610
  process.exit(1);
577
611
  }
578
612
  });
@@ -7,6 +7,7 @@ import { ChangeParser } from '../core/parsers/change-parser.js';
7
7
  import { isInteractive } from '../utils/interactive.js';
8
8
  import { getActiveChangeIds } from '../utils/item-discovery.js';
9
9
  import { getTaskProgressForChange } from '../utils/task-progress.js';
10
+ import { FileSystemUtils } from '../utils/file-system.js';
10
11
  /**
11
12
  * True only when `target` is definitively absent. An EACCES or I/O failure
12
13
  * means existence cannot be determined, so callers fall through to their
@@ -95,12 +96,15 @@ export class ChangeCommand {
95
96
  }
96
97
  throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
97
98
  }
99
+ FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath);
98
100
  if (options?.json) {
101
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
99
102
  const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
100
103
  if (options.requirementsOnly) {
101
104
  console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
102
105
  }
103
106
  const parsed = JSON.parse(jsonOutput);
107
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
104
108
  const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
105
109
  const title = this.extractTitle(contentForTitle, changeName);
106
110
  const id = parsed.name;
@@ -115,6 +119,7 @@ export class ChangeCommand {
115
119
  console.log(JSON.stringify(output, null, 2));
116
120
  }
117
121
  else {
122
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
118
123
  const content = await fs.readFile(proposalPath, 'utf-8');
119
124
  console.log(content);
120
125
  }
@@ -147,6 +152,7 @@ export class ChangeCommand {
147
152
  return { id: changeName, title: changeName, deltaCount: 0, taskStatus };
148
153
  }
149
154
  try {
155
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
150
156
  const content = await fs.readFile(proposalPath, 'utf-8');
151
157
  const parser = new ChangeParser(content, changeDir);
152
158
  const change = await parser.parseChangeWithDeltas(changeName);
@@ -186,6 +192,7 @@ export class ChangeCommand {
186
192
  continue;
187
193
  }
188
194
  try {
195
+ FileSystemUtils.assertPathWithin(changeDir, proposalPath);
189
196
  const content = await fs.readFile(proposalPath, 'utf-8');
190
197
  const title = this.extractTitle(content, changeName);
191
198
  const parser = new ChangeParser(content, changeDir);
@@ -225,6 +232,9 @@ export class ChangeCommand {
225
232
  }
226
233
  }
227
234
  const changeDir = path.join(changesPath, changeName);
235
+ if (!isChangeDirectoryName(changesPath, changeDir)) {
236
+ throw new Error(`Change "${changeName}" not found at ${changeDir}`);
237
+ }
228
238
  try {
229
239
  await fs.access(changeDir);
230
240
  }
@@ -232,7 +242,12 @@ export class ChangeCommand {
232
242
  throw new Error(`Change "${changeName}" not found at ${changeDir}`);
233
243
  }
234
244
  const validator = new Validator(options?.strict || false);
235
- const report = await validator.validateChangeDeltaSpecs(changeDir);
245
+ const report = await validator.validateChangeDeltaSpecs(changeDir, {
246
+ // Derived from changesPath so the main specs come from the same root the
247
+ // change itself was resolved against.
248
+ mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
249
+ projectRoot: path.dirname(path.dirname(changesPath)),
250
+ });
236
251
  if (options?.json) {
237
252
  console.log(JSON.stringify(report, null, 2));
238
253
  }
@@ -10,6 +10,11 @@ interface ProfileStateDiff {
10
10
  hasChanges: boolean;
11
11
  lines: string[];
12
12
  }
13
+ interface WorkflowPromptMeta {
14
+ name: string;
15
+ description: string;
16
+ }
17
+ export declare const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta>;
13
18
  /**
14
19
  * Resolve the effective current profile state from global config defaults.
15
20
  */
@@ -8,7 +8,7 @@ import { OPENSPEC_DIR_NAME } from '../core/config.js';
8
8
  import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
10
  import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
11
- const WORKFLOW_PROMPT_META = {
11
+ export const WORKFLOW_PROMPT_META = {
12
12
  propose: {
13
13
  name: 'Propose change',
14
14
  description: 'Create proposal, design, and tasks from a request',
@@ -29,6 +29,10 @@ const WORKFLOW_PROMPT_META = {
29
29
  name: 'Apply tasks',
30
30
  description: 'Implement tasks from the current change',
31
31
  },
32
+ update: {
33
+ name: 'Update change',
34
+ description: 'Revise the planning artifacts of an existing change',
35
+ },
32
36
  ff: {
33
37
  name: 'Fast-forward',
34
38
  description: 'Run a faster implementation workflow',
@@ -1,9 +1,11 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
  import ora from 'ora';
4
- import { stringify as stringifyYaml } from 'yaml';
5
+ import { stringify as stringifyYaml, parseDocument } from 'yaml';
5
6
  import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
6
7
  import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
8
+ import { FileSystemUtils } from '../utils/file-system.js';
7
9
  /**
8
10
  * Check all three locations for a schema and return which ones exist.
9
11
  */
@@ -129,21 +131,30 @@ function validateSchema(schemaDir, verbose = false) {
129
131
  }
130
132
  return { valid: false, issues };
131
133
  }
132
- // Check template files exist
133
- // Templates can be in schemaDir directly or in a templates/ subdirectory
134
+ // Check template files exist in the same directory used at runtime.
134
135
  if (verbose) {
135
136
  console.log(' Checking template files...');
136
137
  }
137
138
  for (const artifact of schema.artifacts) {
138
- // Try templates subdirectory first (standard location), then root
139
- const templatePathInTemplates = path.join(schemaDir, 'templates', artifact.template);
140
- const templatePathInRoot = path.join(schemaDir, artifact.template);
141
- if (!fs.existsSync(templatePathInTemplates) && !fs.existsSync(templatePathInRoot)) {
139
+ const templatesDir = path.join(schemaDir, 'templates');
140
+ const existingTemplatePath = path.join(templatesDir, artifact.template);
141
+ if (!fs.existsSync(existingTemplatePath)) {
142
142
  issues.push({
143
143
  level: 'error',
144
144
  path: `artifacts.${artifact.id}.template`,
145
145
  message: `Template file '${artifact.template}' not found for artifact '${artifact.id}'`,
146
146
  });
147
+ continue;
148
+ }
149
+ try {
150
+ FileSystemUtils.assertPathWithin(templatesDir, existingTemplatePath);
151
+ }
152
+ catch {
153
+ issues.push({
154
+ level: 'error',
155
+ path: `artifacts.${artifact.id}.template`,
156
+ message: `Template file '${artifact.template}' points outside the schema templates directory`,
157
+ });
147
158
  }
148
159
  }
149
160
  // Dependency graph validation is already done by parseSchema
@@ -162,19 +173,121 @@ function isValidSchemaName(name) {
162
173
  /**
163
174
  * Copy a directory recursively.
164
175
  */
165
- function copyDirRecursive(src, dest) {
176
+ function resolveSchemaCopyPath(allowedRoot, sourcePath) {
177
+ try {
178
+ const canonicalRoot = fs.realpathSync(allowedRoot);
179
+ const canonicalPath = fs.realpathSync(sourcePath);
180
+ FileSystemUtils.assertPathWithin(canonicalRoot, canonicalPath);
181
+ return canonicalPath;
182
+ }
183
+ catch (error) {
184
+ const detail = error instanceof Error ? error.message : String(error);
185
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${sourcePath}: ${detail}`, { cause: error });
186
+ }
187
+ }
188
+ function copyDirRecursive(src, dest, allowedRoot = src, ancestors = new Set()) {
189
+ const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
190
+ if (ancestors.has(canonicalSrc)) {
191
+ throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
192
+ }
193
+ ancestors.add(canonicalSrc);
166
194
  fs.mkdirSync(dest, { recursive: true });
167
- const entries = fs.readdirSync(src, { withFileTypes: true });
168
- for (const entry of entries) {
169
- const srcPath = path.join(src, entry.name);
170
- const destPath = path.join(dest, entry.name);
171
- if (entry.isDirectory()) {
172
- copyDirRecursive(srcPath, destPath);
195
+ try {
196
+ const entries = fs.readdirSync(src, { withFileTypes: true });
197
+ for (const entry of entries) {
198
+ const srcPath = path.join(src, entry.name);
199
+ const destPath = path.join(dest, entry.name);
200
+ const canonicalEntry = resolveSchemaCopyPath(allowedRoot, srcPath);
201
+ const stats = fs.statSync(canonicalEntry);
202
+ if (stats.isDirectory()) {
203
+ copyDirRecursive(canonicalEntry, destPath, allowedRoot, ancestors);
204
+ }
205
+ else if (stats.isFile()) {
206
+ // Dereference confined links so the fork is an independent schema.
207
+ fs.copyFileSync(canonicalEntry, destPath);
208
+ }
209
+ else {
210
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${srcPath}`);
211
+ }
173
212
  }
174
- else {
175
- fs.copyFileSync(srcPath, destPath);
213
+ }
214
+ finally {
215
+ ancestors.delete(canonicalSrc);
216
+ }
217
+ }
218
+ /**
219
+ * Verifies a schema tree before replacing or creating the fork destination.
220
+ */
221
+ function assertSchemaTreeCanBeCopied(src, allowedRoot = src, ancestors = new Set()) {
222
+ const canonicalSrc = resolveSchemaCopyPath(allowedRoot, src);
223
+ if (ancestors.has(canonicalSrc)) {
224
+ throw new Error(`Cannot fork schema with a linked directory cycle: ${src}`);
225
+ }
226
+ ancestors.add(canonicalSrc);
227
+ try {
228
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
229
+ const entryPath = path.join(src, entry.name);
230
+ const canonicalEntry = resolveSchemaCopyPath(allowedRoot, entryPath);
231
+ const stats = fs.statSync(canonicalEntry);
232
+ if (stats.isDirectory()) {
233
+ assertSchemaTreeCanBeCopied(canonicalEntry, allowedRoot, ancestors);
234
+ }
235
+ else if (!stats.isFile()) {
236
+ throw new Error(`Cannot fork schema with linked or unsupported entry: ${entryPath}`);
237
+ }
176
238
  }
177
239
  }
240
+ finally {
241
+ ancestors.delete(canonicalSrc);
242
+ }
243
+ }
244
+ /**
245
+ * Produces a stable content fingerprint of a directory: a SHA-256 over every
246
+ * file's relative path AND its bytes (plus directory paths), walked in sorted
247
+ * order. Two directories with byte-identical trees produce the same digest, and
248
+ * ANY change to a file's contents, size, or the set of paths changes it. Used to
249
+ * detect a concurrent modification of a fork destination between the moment the
250
+ * overwrite is authorized and the moment it is actually moved/deleted, so those
251
+ * changes are never silently destroyed.
252
+ */
253
+ function fingerprintDir(dir) {
254
+ const hash = createHash('sha256');
255
+ const walk = (current, rel) => {
256
+ const entries = fs
257
+ .readdirSync(current, { withFileTypes: true })
258
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
259
+ for (const entry of entries) {
260
+ const abs = path.join(current, entry.name);
261
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
262
+ // Use the entry type from readdir (no separate lstat), then read the file
263
+ // directly — avoiding a stat-then-read check/use gap. Size is derived from
264
+ // the bytes actually read, so the digest still covers content and length.
265
+ if (entry.isDirectory()) {
266
+ hash.update(`D:${relPath}\n`);
267
+ walk(abs, relPath);
268
+ }
269
+ else if (entry.isFile()) {
270
+ const contents = fs.readFileSync(abs);
271
+ hash.update(`F:${relPath}:${contents.length}:`);
272
+ hash.update(contents);
273
+ hash.update('\n');
274
+ }
275
+ else {
276
+ // Symlinks / other entry types: record the type + path (and the link
277
+ // target when readable) so a swap of one for another is still detected.
278
+ let target = '';
279
+ try {
280
+ target = fs.readlinkSync(abs);
281
+ }
282
+ catch {
283
+ // Non-symlink or unreadable target; the type marker below suffices.
284
+ }
285
+ hash.update(`O:${relPath}:${target}\n`);
286
+ }
287
+ }
288
+ };
289
+ walk(dir, '');
290
+ return hash.digest('hex');
178
291
  }
179
292
  /**
180
293
  * Default artifacts with descriptions for schema init.
@@ -378,9 +491,9 @@ export function registerSchemaCommand(program) {
378
491
  console.log(` ${issue.level}: ${issue.message}`);
379
492
  }
380
493
  }
381
- if (anyInvalid) {
382
- process.exitCode = 1;
383
- }
494
+ }
495
+ if (anyInvalid) {
496
+ process.exitCode = 1;
384
497
  }
385
498
  return;
386
499
  }
@@ -423,9 +536,11 @@ export function registerSchemaCommand(program) {
423
536
  for (const issue of result.issues) {
424
537
  console.log(` ${issue.level}: ${issue.message}`);
425
538
  }
426
- process.exitCode = 1;
427
539
  }
428
540
  }
541
+ if (!result.valid) {
542
+ process.exitCode = 1;
543
+ }
429
544
  }
430
545
  catch (error) {
431
546
  if (options?.json) {
@@ -487,39 +602,158 @@ export function registerSchemaCommand(program) {
487
602
  // Determine source location
488
603
  const sourceResolution = getSchemaResolution(source, projectRoot);
489
604
  const sourceLocation = sourceResolution?.source || 'package';
605
+ // Validate the complete source before a forced fork removes anything.
606
+ const trustedSourceDir = fs.realpathSync(sourceDir);
607
+ assertSchemaTreeCanBeCopied(trustedSourceDir);
608
+ // Validate the source's schema content up front too, so a structurally
609
+ // invalid source is rejected before the --force path can remove an
610
+ // existing destination. This keeps `fork --force` atomic — an unusable
611
+ // source never destroys a valid destination — matching `schema init`,
612
+ // which likewise validates before it overwrites.
613
+ parseSchema(fs.readFileSync(path.join(trustedSourceDir, 'schema.yaml'), 'utf-8'));
490
614
  // Check destination
491
- const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);
492
- if (fs.existsSync(destinationDir)) {
493
- if (!options?.force) {
494
- if (options?.json) {
495
- console.log(JSON.stringify({
496
- forked: false,
497
- error: `Schema '${destinationName}' already exists`,
498
- suggestion: 'Use --force to overwrite',
499
- }, null, 2));
615
+ const schemasDir = getProjectSchemasDir(projectRoot);
616
+ const destinationDir = path.join(schemasDir, destinationName);
617
+ // Reject a self-fork. Forking a schema onto itself with --force would
618
+ // otherwise remove the source at the replacement step below and then
619
+ // fail the copy, destroying the only copy of the schema. Resolve both
620
+ // sides to their real paths (realpathSync follows symlinks; path.resolve
621
+ // is a fallback only for a destination that does not exist yet) so a
622
+ // symlink or a `.`/`..` spelling of the same directory is still caught.
623
+ const resolvedDestination = fs.existsSync(destinationDir)
624
+ ? fs.realpathSync(destinationDir)
625
+ : path.resolve(destinationDir);
626
+ if (resolvedDestination === trustedSourceDir) {
627
+ throw new Error(`Cannot fork schema '${source}' onto itself; choose a different destination name`);
628
+ }
629
+ const destinationExists = fs.existsSync(destinationDir);
630
+ if (destinationExists && !options?.force) {
631
+ if (options?.json) {
632
+ console.log(JSON.stringify({
633
+ forked: false,
634
+ error: `Schema '${destinationName}' already exists`,
635
+ suggestion: 'Use --force to overwrite',
636
+ }, null, 2));
637
+ }
638
+ else {
639
+ console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
640
+ console.error('Use --force to overwrite');
641
+ }
642
+ process.exitCode = 1;
643
+ return;
644
+ }
645
+ // Fingerprint the destination the user authorized us to overwrite, BEFORE
646
+ // we spend time staging. Staging can take a while, and a concurrent
647
+ // process may edit the destination in that window; the fingerprint lets
648
+ // us detect such a change and abort rather than clobber it.
649
+ const authorizedDestinationFingerprint = destinationExists
650
+ ? fingerprintDir(destinationDir)
651
+ : null;
652
+ // Stage the complete fork in a temporary sibling directory first, then
653
+ // swap it into place. This keeps `fork --force` atomic: an existing
654
+ // destination is only removed once the new fork has been fully copied,
655
+ // name-updated, and (via the up-front parseSchema above) validated. Any
656
+ // failure while staging leaves both the source and the existing
657
+ // destination exactly as they were.
658
+ if (spinner)
659
+ spinner.start(`Forking '${source}' to '${destinationName}'...`);
660
+ fs.mkdirSync(schemasDir, { recursive: true });
661
+ const stagingDir = fs.mkdtempSync(path.join(schemasDir, '.fork-staging-'));
662
+ try {
663
+ copyDirRecursive(trustedSourceDir, stagingDir);
664
+ // Update name in the staged schema.yaml via yaml's Document API
665
+ // instead of re-serializing the parsed object, so block scalars,
666
+ // comments, and key order in the source schema.yaml survive the fork.
667
+ const stagedSchemaPath = path.join(stagingDir, 'schema.yaml');
668
+ const schemaContent = fs.readFileSync(stagedSchemaPath, 'utf-8');
669
+ const doc = parseDocument(schemaContent);
670
+ doc.set('name', destinationName);
671
+ fs.writeFileSync(stagedSchemaPath, doc.toString());
672
+ // Authoritative gate: validate the COMPLETED staged schema — the exact
673
+ // bytes we are about to install — not just the source at the pre-check.
674
+ // The source files copyDirRecursive reads can change mid-copy, so a
675
+ // source that was valid up front can still produce an invalid staged
676
+ // fork. Validating here, before ANY destructive step, guarantees we
677
+ // never install an invalid fork or delete a valid destination for one.
678
+ try {
679
+ parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8'));
680
+ }
681
+ catch (validationError) {
682
+ throw new Error(`The staged fork of '${source}' is not a valid schema (the source may have changed during copy); ` +
683
+ `aborted, '${destinationName}' was not modified.`, { cause: validationError });
684
+ }
685
+ // Swap the staged fork into place. When a destination already exists,
686
+ // move it aside to a sibling backup FIRST, then install the staged
687
+ // fork; only once the install succeeds is the backup discarded. If the
688
+ // install rename itself fails (e.g. a Windows lock), the backup is
689
+ // moved back so the user's original destination is never lost.
690
+ if (destinationExists) {
691
+ if (spinner)
692
+ spinner.text = `Replacing existing schema '${destinationName}'...`;
693
+ // Revalidate immediately before the destructive move: if the
694
+ // destination changed on disk while we were staging (or was removed),
695
+ // its fingerprint no longer matches what the user authorized. Abort
696
+ // WITHOUT touching it, so the concurrent changes are preserved. The
697
+ // outer catch cleans up staging.
698
+ const currentFingerprint = fs.existsSync(destinationDir)
699
+ ? fingerprintDir(destinationDir)
700
+ : null;
701
+ if (currentFingerprint !== authorizedDestinationFingerprint) {
702
+ throw new Error(`Schema '${destinationName}' at ${destinationDir} changed on disk while the fork was being prepared. ` +
703
+ `Aborted to preserve those concurrent changes; nothing was overwritten. Re-run the fork to overwrite the current contents.`);
704
+ }
705
+ const backupDir = `${destinationDir}.fork-backup-${process.pid}-${Date.now()}`;
706
+ fs.renameSync(destinationDir, backupDir);
707
+ try {
708
+ fs.renameSync(stagingDir, destinationDir);
709
+ }
710
+ catch (installError) {
711
+ // Install failed after the original was moved aside. Try to move
712
+ // it back. If that restore ALSO fails, the original is stranded in
713
+ // the backup dir — surface an error naming both the backup and the
714
+ // destination so the user can recover manually, and attach the
715
+ // original install error as the cause. Never swallow this.
716
+ try {
717
+ fs.renameSync(backupDir, destinationDir);
718
+ }
719
+ catch (restoreError) {
720
+ throw new Error(`Failed to install the forked schema and could not restore the previous '${destinationName}'. ` +
721
+ `Your previous schema is preserved at ${backupDir}; move it back to ${destinationDir} to restore. ` +
722
+ `Restore error: ${restoreError.message}`, { cause: installError });
723
+ }
724
+ throw installError;
725
+ }
726
+ // Revalidate before discarding the backup: only delete it if it is
727
+ // still byte-for-byte the original destination we moved aside. If it
728
+ // changed during the install window (a concurrent write to the
729
+ // moved-aside directory), do NOT delete it — leave it in place and
730
+ // surface where it is so nothing is lost.
731
+ if (fingerprintDir(backupDir) === authorizedDestinationFingerprint) {
732
+ fs.rmSync(backupDir, { recursive: true, force: true });
500
733
  }
501
734
  else {
502
- console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
503
- console.error('Use --force to overwrite');
735
+ console.error(`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
736
+ `its pre-fork copy is preserved at ${backupDir}.`);
504
737
  }
505
- process.exitCode = 1;
506
- return;
507
738
  }
508
- // Remove existing
509
- if (spinner)
510
- spinner.start(`Removing existing schema '${destinationName}'...`);
511
- fs.rmSync(destinationDir, { recursive: true });
739
+ else {
740
+ fs.renameSync(stagingDir, destinationDir);
741
+ }
742
+ }
743
+ catch (error) {
744
+ // Remove only the staging directory we created this run; the source
745
+ // and any existing destination are left exactly as we found them.
746
+ // Guard the cleanup in its own try/catch so a failed removal (e.g. a
747
+ // locked file on Windows) can never mask the original error, then
748
+ // rethrow so the real failure still drives the JSON/exit-code report.
749
+ try {
750
+ fs.rmSync(stagingDir, { recursive: true, force: true });
751
+ }
752
+ catch {
753
+ // Best-effort cleanup; the original error below is what matters.
754
+ }
755
+ throw error;
512
756
  }
513
- // Copy schema
514
- if (spinner)
515
- spinner.start(`Forking '${source}' to '${destinationName}'...`);
516
- copyDirRecursive(sourceDir, destinationDir);
517
- // Update name in schema.yaml
518
- const destSchemaPath = path.join(destinationDir, 'schema.yaml');
519
- const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
520
- const schema = parseSchema(schemaContent);
521
- schema.name = destinationName;
522
- fs.writeFileSync(destSchemaPath, stringifyYaml(schema));
523
757
  if (spinner)
524
758
  spinner.succeed(`Forked '${source}' to '${destinationName}'`);
525
759
  if (options?.json) {