@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.
- package/README.md +3 -1
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +39 -5
- package/dist/commands/change.js +16 -1
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/schema.js +281 -47
- package/dist/commands/spec.js +30 -6
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/instructions.js +32 -27
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/commands/workflow/status.js +2 -2
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -0
- package/dist/core/archive.js +1272 -151
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
- package/dist/core/artifact-graph/instruction-loader.js +15 -5
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +49 -9
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +1 -0
- package/dist/core/change-metadata/schema.js +7 -0
- package/dist/core/change-status-policy.js +2 -2
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +13 -0
- package/dist/core/config-schema.d.ts +3 -0
- package/dist/core/config-schema.js +28 -1
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +40 -17
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +11 -0
- package/dist/core/init.d.ts +23 -0
- package/dist/core/init.js +253 -46
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +52 -28
- package/dist/core/migration.d.ts +6 -4
- package/dist/core/migration.js +78 -22
- package/dist/core/parsers/requirement-blocks.d.ts +10 -0
- package/dist/core/parsers/requirement-blocks.js +93 -1
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +17 -1
- package/dist/core/profile-sync-drift.js +40 -5
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +31 -1
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.js +66 -35
- package/dist/core/shared-skill-target.d.ts +31 -0
- package/dist/core/shared-skill-target.js +186 -0
- package/dist/core/specs-apply.d.ts +72 -0
- package/dist/core/specs-apply.js +459 -59
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +25 -182
- package/dist/core/templates/workflows/archive-change.js +8 -4
- package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
- package/dist/core/templates/workflows/continue-change.js +6 -6
- package/dist/core/templates/workflows/explore.js +42 -18
- package/dist/core/templates/workflows/onboard.js +9 -3
- package/dist/core/templates/workflows/propose.js +72 -20
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/sync-specs.js +96 -18
- package/dist/core/templates/workflows/update-change.js +10 -8
- package/dist/core/update.d.ts +1 -0
- package/dist/core/update.js +136 -30
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +26 -3
- package/dist/core/validation/validator.js +189 -19
- package/dist/core/version-check.js +6 -11
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +12 -5
- package/dist/telemetry/index.js +27 -9
- package/dist/utils/change-metadata.d.ts +17 -4
- package/dist/utils/change-metadata.js +26 -4
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +14 -2
- package/dist/utils/command-references.js +45 -3
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/interactive.d.ts +52 -0
- package/dist/utils/interactive.js +135 -0
- package/dist/utils/spec-discovery.d.ts +4 -4
- package/dist/utils/spec-discovery.js +19 -6
- package/dist/utils/task-progress.d.ts +51 -1
- package/dist/utils/task-progress.js +93 -41
- package/package.json +1 -1
- package/schemas/spec-driven/schema.yaml +8 -6
- 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
|
|
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
|
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
});
|
package/dist/commands/change.js
CHANGED
|
@@ -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
|
*/
|
package/dist/commands/config.js
CHANGED
|
@@ -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',
|
package/dist/commands/schema.js
CHANGED
|
@@ -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
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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(`
|
|
503
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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) {
|