@fission-ai/openspec 1.8.0 → 1.10.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/dist/cli/index.d.ts +32 -0
- package/dist/cli/index.js +80 -9
- package/dist/commands/change.js +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +19 -4
- package/dist/commands/feedback.js +28 -7
- package/dist/commands/schema.js +193 -28
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/core/archive.js +74 -10
- package/dist/core/artifact-graph/instruction-loader.js +2 -7
- package/dist/core/artifact-graph/outputs.d.ts +4 -0
- package/dist/core/artifact-graph/outputs.js +7 -0
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +14 -0
- 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/adapters/opencode.d.ts +2 -1
- package/dist/core/command-generation/adapters/opencode.js +13 -2
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completion-tip.d.ts +15 -0
- package/dist/core/completion-tip.js +133 -0
- package/dist/core/completions/command-registry.js +10 -0
- package/dist/core/completions/factory.d.ts +11 -0
- package/dist/core/completions/installers/bash-installer.d.ts +7 -0
- package/dist/core/completions/installers/bash-installer.js +15 -0
- package/dist/core/completions/installers/fish-installer.d.ts +7 -0
- package/dist/core/completions/installers/fish-installer.js +15 -0
- package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
- package/dist/core/completions/installers/powershell-installer.js +15 -0
- package/dist/core/config-prompts.js +19 -10
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +2 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +17 -15
- package/dist/core/global-config.d.ts +2 -0
- package/dist/core/init.d.ts +5 -0
- package/dist/core/init.js +110 -14
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +48 -26
- package/dist/core/parsers/requirement-blocks.js +41 -9
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +11 -2
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared-skill-target.d.ts +15 -0
- package/dist/core/shared-skill-target.js +30 -0
- package/dist/core/specs-apply.js +11 -4
- package/dist/core/templates/workflows/apply-change.js +3 -0
- package/dist/core/templates/workflows/feedback.js +1 -0
- package/dist/core/templates/workflows/onboard.js +4 -4
- package/dist/core/templates/workflows/propose.js +2 -2
- 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/update-change.js +4 -4
- package/dist/core/update.js +49 -11
- 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 +5 -2
- package/dist/core/validation/validator.js +57 -3
- package/dist/telemetry/index.d.ts +3 -1
- package/dist/telemetry/index.js +10 -3
- package/dist/utils/change-metadata.js +17 -13
- package/dist/utils/change-utils.js +5 -0
- package/dist/utils/interactive.d.ts +30 -1
- package/dist/utils/interactive.js +105 -2
- package/dist/utils/task-progress.d.ts +34 -1
- package/dist/utils/task-progress.js +52 -28
- package/package.json +3 -6
- package/schemas/spec-driven/schema.yaml +17 -7
- package/scripts/postinstall.js +0 -83
package/dist/cli/index.d.ts
CHANGED
|
@@ -5,6 +5,38 @@ 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;
|
|
23
|
+
/**
|
|
24
|
+
* True for the commands that exist to serve shell completions: the user-facing
|
|
25
|
+
* `openspec completion ...` group and the hidden `__complete` resolver that
|
|
26
|
+
* generated completion scripts call on every Tab press. Tipping either about
|
|
27
|
+
* completions is noise, and `__complete` would burn the one-shot tip invisibly.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isCompletionRun(commandPath: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* True when the first-run completions tip must be deferred rather than shown.
|
|
32
|
+
*
|
|
33
|
+
* Deferring keeps the tip unconsumed, so it still reaches the user on a later
|
|
34
|
+
* run that can actually carry it. All three cases are runs nobody would read a
|
|
35
|
+
* hint from: JSON output, the completion machinery itself, and a stderr that is
|
|
36
|
+
* not a terminal — pipes and the agent-driven runs that dominate this CLI's
|
|
37
|
+
* usage would otherwise burn the user's one-shot tip into a log nobody opens.
|
|
38
|
+
*/
|
|
39
|
+
export declare function shouldDeferCompletionTip(command: Command, stderrIsTty: boolean): boolean;
|
|
8
40
|
export { program };
|
|
9
41
|
export declare function runCli(argv?: string[]): void;
|
|
10
42
|
//# 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';
|
|
@@ -26,6 +26,7 @@ import { registerContextCommand } from '../commands/context.js';
|
|
|
26
26
|
import { registerWorksetCommand } from '../commands/workset.js';
|
|
27
27
|
import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
|
|
28
28
|
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
|
29
|
+
import { maybeShowCompletionTip } from '../core/completion-tip.js';
|
|
29
30
|
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
|
30
31
|
import { isInteractive } from '../utils/interactive.js';
|
|
31
32
|
const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
|
|
@@ -72,6 +73,45 @@ export function getCommandPath(command) {
|
|
|
72
73
|
}
|
|
73
74
|
return names.join(':') || 'openspec';
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* True when the executing command asked for JSON output — used to suppress the
|
|
78
|
+
* first-run telemetry notice so stdout stays a single valid JSON document.
|
|
79
|
+
*
|
|
80
|
+
* `--json` reaches commands three ways, so a single parsed option is not enough:
|
|
81
|
+
* - declared on the leaf (`openspec status --json`) → `opts().json`
|
|
82
|
+
* - declared on a parent group and read via globals (`openspec workset --json list`)
|
|
83
|
+
* → `optsWithGlobals().json`
|
|
84
|
+
* - a residual arg on a permissive group that never declares the option
|
|
85
|
+
* (`openspec store --json`, which detects it from `command.args`) → `args`
|
|
86
|
+
*
|
|
87
|
+
* Suppressing is always safe: the disclosure is only deferred to the next
|
|
88
|
+
* non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
|
|
89
|
+
*/
|
|
90
|
+
export function isJsonRun(command) {
|
|
91
|
+
return (command.optsWithGlobals().json === true ||
|
|
92
|
+
command.args.includes('--json'));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* True for the commands that exist to serve shell completions: the user-facing
|
|
96
|
+
* `openspec completion ...` group and the hidden `__complete` resolver that
|
|
97
|
+
* generated completion scripts call on every Tab press. Tipping either about
|
|
98
|
+
* completions is noise, and `__complete` would burn the one-shot tip invisibly.
|
|
99
|
+
*/
|
|
100
|
+
export function isCompletionRun(commandPath) {
|
|
101
|
+
return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* True when the first-run completions tip must be deferred rather than shown.
|
|
105
|
+
*
|
|
106
|
+
* Deferring keeps the tip unconsumed, so it still reaches the user on a later
|
|
107
|
+
* run that can actually carry it. All three cases are runs nobody would read a
|
|
108
|
+
* hint from: JSON output, the completion machinery itself, and a stderr that is
|
|
109
|
+
* not a terminal — pipes and the agent-driven runs that dominate this CLI's
|
|
110
|
+
* usage would otherwise burn the user's one-shot tip into a log nobody opens.
|
|
111
|
+
*/
|
|
112
|
+
export function shouldDeferCompletionTip(command, stderrIsTty) {
|
|
113
|
+
return isJsonRun(command) || isCompletionRun(getCommandPath(command)) || !stderrIsTty;
|
|
114
|
+
}
|
|
75
115
|
program
|
|
76
116
|
.name('openspec')
|
|
77
117
|
.description('AI-native system for spec-driven development')
|
|
@@ -87,15 +127,32 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
87
127
|
if (opts.color === false) {
|
|
88
128
|
process.env.NO_COLOR = '1';
|
|
89
129
|
}
|
|
90
|
-
// Show first-run telemetry notice (if not seen)
|
|
91
|
-
|
|
130
|
+
// Show first-run telemetry notice (if not seen). It's written to stderr, so it
|
|
131
|
+
// never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
|
|
132
|
+
// very first invocation stays free of any incidental output on either stream.
|
|
133
|
+
await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
|
|
92
134
|
// Track command execution (use actionCommand to get the actual subcommand)
|
|
93
135
|
const commandPath = getCommandPath(actionCommand);
|
|
94
136
|
await trackCommand(commandPath, version);
|
|
95
137
|
});
|
|
96
138
|
// Shutdown telemetry after command completes
|
|
97
|
-
program.hook('postAction', async () => {
|
|
98
|
-
|
|
139
|
+
program.hook('postAction', async (_thisCommand, actionCommand) => {
|
|
140
|
+
// Show the first-run shell-completions tip (on stderr, so piped stdout stays
|
|
141
|
+
// clean). postAction, not preAction: the tip trails the command's own output
|
|
142
|
+
// instead of pushing an error message or `init`'s setup summary down the
|
|
143
|
+
// screen. Deferred — not consumed — whenever nobody would read it: JSON runs,
|
|
144
|
+
// `openspec completion ...`, and a stderr that is not a terminal (agents and
|
|
145
|
+
// pipes would otherwise silently burn the user's one-shot tip).
|
|
146
|
+
try {
|
|
147
|
+
await maybeShowCompletionTip({
|
|
148
|
+
silent: shouldDeferCompletionTip(actionCommand, Boolean(process.stderr.isTTY)),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
// The flush runs even if the hint throws: parse() is synchronous, so a
|
|
153
|
+
// rejection here has no catch anywhere above it.
|
|
154
|
+
await shutdown();
|
|
155
|
+
}
|
|
99
156
|
});
|
|
100
157
|
const availableToolIds = AI_TOOLS
|
|
101
158
|
.filter((tool) => tool.skillsDir || tool.globalSkillsDir)
|
|
@@ -108,6 +165,7 @@ program
|
|
|
108
165
|
.command('init [path]')
|
|
109
166
|
.description('Initialize OpenSpec in your project')
|
|
110
167
|
.option('--tools <tools>', toolsOptionDescription)
|
|
168
|
+
.option('--language <language>', 'Write new OpenSpec artifacts in this language')
|
|
111
169
|
.option('--force', 'Auto-cleanup legacy files without prompting')
|
|
112
170
|
.option('--profile <profile>', 'Override global config profile (core or custom)')
|
|
113
171
|
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
|
|
@@ -138,6 +196,7 @@ program
|
|
|
138
196
|
const { InitCommand } = await import('../core/init.js');
|
|
139
197
|
const initCommand = new InitCommand({
|
|
140
198
|
tools: options?.tools,
|
|
199
|
+
language: options?.language,
|
|
141
200
|
force: options?.force,
|
|
142
201
|
profile: options?.profile,
|
|
143
202
|
animation: options?.animation,
|
|
@@ -246,6 +305,9 @@ program
|
|
|
246
305
|
const root = await resolveRootForCommand(options ?? {}, {
|
|
247
306
|
json: options?.json,
|
|
248
307
|
failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
|
308
|
+
// Preserve the cwd fallback for pre-config.yaml projects. The resolver
|
|
309
|
+
// still lets a registered/default store take precedence over it.
|
|
310
|
+
allowImplicitRoot: existsSync(path.join(process.cwd(), 'openspec', 'project.md')),
|
|
249
311
|
});
|
|
250
312
|
if (!root) {
|
|
251
313
|
return;
|
|
@@ -340,10 +402,12 @@ changeCmd
|
|
|
340
402
|
.action(async (changeName, options) => {
|
|
341
403
|
try {
|
|
342
404
|
const changeCommand = new ChangeCommand();
|
|
405
|
+
// validate() already sets process.exitCode, and Node honours it at
|
|
406
|
+
// natural exit. Calling process.exit() here would skip commander's
|
|
407
|
+
// postAction hook — the same trap called out for `update` below — which
|
|
408
|
+
// kills the telemetry flush and the first-run completions tip on what is
|
|
409
|
+
// a routine outcome, not an error: a change that fails validation.
|
|
343
410
|
await changeCommand.validate(changeName, options);
|
|
344
|
-
if (typeof process.exitCode === 'number' && process.exitCode !== 0) {
|
|
345
|
-
process.exit(process.exitCode);
|
|
346
|
-
}
|
|
347
411
|
}
|
|
348
412
|
catch (error) {
|
|
349
413
|
console.error(`Error: ${error.message}`);
|
|
@@ -383,6 +447,7 @@ program
|
|
|
383
447
|
.option('--all', 'Validate all changes and specs')
|
|
384
448
|
.option('--changes', 'Validate all changes')
|
|
385
449
|
.option('--specs', 'Validate all specs')
|
|
450
|
+
.option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
|
|
386
451
|
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
|
|
387
452
|
.option('--strict', 'Enable strict validation mode')
|
|
388
453
|
.option('--json', 'Output validation results as JSON')
|
|
@@ -572,12 +637,18 @@ program
|
|
|
572
637
|
.command('schemas')
|
|
573
638
|
.description('List available workflow schemas with descriptions')
|
|
574
639
|
.option('--json', 'Output as JSON (for agent use)')
|
|
640
|
+
.option('--store <id>', STORE_OPTION_DESCRIPTION)
|
|
641
|
+
.addOption(hiddenStorePathOption())
|
|
575
642
|
.action(async (options) => {
|
|
576
643
|
try {
|
|
577
644
|
await schemasCommand(options);
|
|
578
645
|
}
|
|
579
646
|
catch (error) {
|
|
580
|
-
failWithError(error
|
|
647
|
+
failWithError(error, {
|
|
648
|
+
enabled: options.json,
|
|
649
|
+
payload: { schemas: [], root: null },
|
|
650
|
+
fallbackCode: 'schemas_error',
|
|
651
|
+
});
|
|
581
652
|
process.exit(1);
|
|
582
653
|
}
|
|
583
654
|
});
|
package/dist/commands/change.js
CHANGED
|
@@ -246,6 +246,7 @@ export class ChangeCommand {
|
|
|
246
246
|
// Derived from changesPath so the main specs come from the same root the
|
|
247
247
|
// change itself was resolved against.
|
|
248
248
|
mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
|
|
249
|
+
projectRoot: path.dirname(path.dirname(changesPath)),
|
|
249
250
|
});
|
|
250
251
|
if (options?.json) {
|
|
251
252
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -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',
|
|
@@ -449,6 +453,7 @@ export function registerConfigCommand(program) {
|
|
|
449
453
|
delivery: currentState.delivery,
|
|
450
454
|
workflows: [...currentState.workflows],
|
|
451
455
|
};
|
|
456
|
+
let workflowSelectionChanged = false;
|
|
452
457
|
if (action === 'both' || action === 'delivery') {
|
|
453
458
|
const deliveryChoices = [
|
|
454
459
|
{
|
|
@@ -493,8 +498,11 @@ export function registerConfigCommand(program) {
|
|
|
493
498
|
};
|
|
494
499
|
};
|
|
495
500
|
const selectedWorkflows = await checkbox({
|
|
501
|
+
// The `instructions` option was removed in @inquirer/checkbox v5.
|
|
502
|
+
// Its replacement, the built-in keys help tip, renders
|
|
503
|
+
// "↑↓ navigate • space select • ⏎ submit" by default — a superset of
|
|
504
|
+
// the hint this used to pass — so no theme override is needed here.
|
|
496
505
|
message: 'Select workflows to make available:',
|
|
497
|
-
instructions: 'Space to toggle, Enter to confirm',
|
|
498
506
|
pageSize: ALL_WORKFLOWS.length,
|
|
499
507
|
theme: {
|
|
500
508
|
icon: {
|
|
@@ -505,7 +513,12 @@ export function registerConfigCommand(program) {
|
|
|
505
513
|
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
|
|
506
514
|
});
|
|
507
515
|
nextState.workflows = selectedWorkflows;
|
|
508
|
-
|
|
516
|
+
workflowSelectionChanged =
|
|
517
|
+
selectedWorkflows.length !== currentState.workflows.length ||
|
|
518
|
+
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
|
|
519
|
+
nextState.profile = workflowSelectionChanged
|
|
520
|
+
? deriveProfileFromWorkflowSelection(selectedWorkflows)
|
|
521
|
+
: currentState.profile;
|
|
509
522
|
}
|
|
510
523
|
const diff = diffProfileState(currentState, nextState);
|
|
511
524
|
if (!diff.hasChanges) {
|
|
@@ -520,7 +533,9 @@ export function registerConfigCommand(program) {
|
|
|
520
533
|
console.log();
|
|
521
534
|
config.profile = nextState.profile;
|
|
522
535
|
config.delivery = nextState.delivery;
|
|
523
|
-
|
|
536
|
+
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
|
|
537
|
+
config.workflows = nextState.workflows;
|
|
538
|
+
}
|
|
524
539
|
saveGlobalConfig(config);
|
|
525
540
|
// Check if inside an OpenSpec project
|
|
526
541
|
const projectDir = process.cwd();
|
|
@@ -2,6 +2,8 @@ import { execSync, execFileSync } from 'child_process';
|
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
|
+
const MAX_TITLE_LENGTH = 72;
|
|
6
|
+
const TITLE_PREFIX = 'Feedback: ';
|
|
5
7
|
/**
|
|
6
8
|
* Check if gh CLI is installed and available in PATH
|
|
7
9
|
* Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
|
|
@@ -69,18 +71,37 @@ Submitted via OpenSpec CLI
|
|
|
69
71
|
* Format the feedback title
|
|
70
72
|
*/
|
|
71
73
|
function formatTitle(message) {
|
|
72
|
-
|
|
74
|
+
const normalizedMessage = message.replace(/\s+/g, ' ').trim();
|
|
75
|
+
const title = `${TITLE_PREFIX}${normalizedMessage}`;
|
|
76
|
+
if (Array.from(title).length <= MAX_TITLE_LENGTH) {
|
|
77
|
+
return title;
|
|
78
|
+
}
|
|
79
|
+
const availableLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1;
|
|
80
|
+
let candidate = '';
|
|
81
|
+
let candidateLength = 0;
|
|
82
|
+
const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(normalizedMessage);
|
|
83
|
+
for (const { segment } of segments) {
|
|
84
|
+
const segmentLength = Array.from(segment).length;
|
|
85
|
+
if (candidateLength + segmentLength > availableLength) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
candidate += segment;
|
|
89
|
+
candidateLength += segmentLength;
|
|
90
|
+
}
|
|
91
|
+
candidate = candidate.trimEnd();
|
|
92
|
+
const lastSpace = candidate.lastIndexOf(' ');
|
|
93
|
+
const summary = lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate;
|
|
94
|
+
return `${TITLE_PREFIX}${summary}…`;
|
|
73
95
|
}
|
|
74
96
|
/**
|
|
75
97
|
* Format the full feedback body
|
|
76
98
|
*/
|
|
77
|
-
function formatBody(bodyText) {
|
|
78
|
-
const parts = [];
|
|
99
|
+
function formatBody(message, bodyText) {
|
|
100
|
+
const parts = ['## Summary', '', message];
|
|
79
101
|
if (bodyText) {
|
|
80
|
-
parts.push(bodyText);
|
|
81
|
-
parts.push(''); // Empty line before metadata
|
|
102
|
+
parts.push('', '## Details', '', bodyText);
|
|
82
103
|
}
|
|
83
|
-
parts.push(generateMetadata());
|
|
104
|
+
parts.push('', generateMetadata());
|
|
84
105
|
return parts.join('\n');
|
|
85
106
|
}
|
|
86
107
|
/**
|
|
@@ -216,7 +237,7 @@ export class FeedbackCommand {
|
|
|
216
237
|
async execute(message, options) {
|
|
217
238
|
// Format title and body once for all code paths
|
|
218
239
|
const title = formatTitle(message);
|
|
219
|
-
const body = formatBody(options?.body);
|
|
240
|
+
const body = formatBody(message, options?.body);
|
|
220
241
|
// Check if gh CLI is installed
|
|
221
242
|
if (!isGhInstalled()) {
|
|
222
243
|
handleFallback(title, body, 'missing');
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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';
|
|
7
8
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
@@ -240,6 +241,54 @@ function assertSchemaTreeCanBeCopied(src, allowedRoot = src, ancestors = new Set
|
|
|
240
241
|
ancestors.delete(canonicalSrc);
|
|
241
242
|
}
|
|
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');
|
|
291
|
+
}
|
|
243
292
|
/**
|
|
244
293
|
* Default artifacts with descriptions for schema init.
|
|
245
294
|
*/
|
|
@@ -556,39 +605,155 @@ export function registerSchemaCommand(program) {
|
|
|
556
605
|
// Validate the complete source before a forced fork removes anything.
|
|
557
606
|
const trustedSourceDir = fs.realpathSync(sourceDir);
|
|
558
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'));
|
|
559
614
|
// Check destination
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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 });
|
|
569
733
|
}
|
|
570
734
|
else {
|
|
571
|
-
console.error(`
|
|
572
|
-
|
|
735
|
+
console.error(`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
|
|
736
|
+
`its pre-fork copy is preserved at ${backupDir}.`);
|
|
573
737
|
}
|
|
574
|
-
process.exitCode = 1;
|
|
575
|
-
return;
|
|
576
738
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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;
|
|
581
756
|
}
|
|
582
|
-
// Copy schema
|
|
583
|
-
if (spinner)
|
|
584
|
-
spinner.start(`Forking '${source}' to '${destinationName}'...`);
|
|
585
|
-
copyDirRecursive(trustedSourceDir, destinationDir);
|
|
586
|
-
// Update name in schema.yaml
|
|
587
|
-
const destSchemaPath = path.join(destinationDir, 'schema.yaml');
|
|
588
|
-
const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
|
|
589
|
-
const schema = parseSchema(schemaContent);
|
|
590
|
-
schema.name = destinationName;
|
|
591
|
-
fs.writeFileSync(destSchemaPath, stringifyYaml(schema));
|
|
592
757
|
if (spinner)
|
|
593
758
|
spinner.succeed(`Forked '${source}' to '${destinationName}'`);
|
|
594
759
|
if (options?.json) {
|
|
@@ -2,6 +2,7 @@ interface ExecuteOptions {
|
|
|
2
2
|
all?: boolean;
|
|
3
3
|
changes?: boolean;
|
|
4
4
|
specs?: boolean;
|
|
5
|
+
archived?: boolean;
|
|
5
6
|
type?: string;
|
|
6
7
|
strict?: boolean;
|
|
7
8
|
json?: boolean;
|
|
@@ -29,6 +30,28 @@ export declare class ValidateCommand {
|
|
|
29
30
|
private printReport;
|
|
30
31
|
private printNextSteps;
|
|
31
32
|
private runBulkValidation;
|
|
33
|
+
/**
|
|
34
|
+
* Lists archived change ids from the resolved root's archive directory,
|
|
35
|
+
* mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
|
|
36
|
+
* rather than a cwd-relative path). Directories only, hidden entries skipped.
|
|
37
|
+
*
|
|
38
|
+
* Only a missing archive directory (ENOENT) is an empty list; a permission
|
|
39
|
+
* error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
|
|
40
|
+
* failure and must not read as "no archived changes" — that would let a
|
|
41
|
+
* pre-commit lint pass without inspecting anything (#205).
|
|
42
|
+
*/
|
|
43
|
+
private listArchivedChangeIds;
|
|
44
|
+
/**
|
|
45
|
+
* Validates that every archived change has all of its tasks completed.
|
|
46
|
+
*
|
|
47
|
+
* An archived change is expected to be finished; an archived change with
|
|
48
|
+
* unchecked tasks is a real integrity problem the normal validate flow never
|
|
49
|
+
* surfaces, because active-change discovery excludes the archive directory
|
|
50
|
+
* (#205). Reuses the same task-progress counting `status`, `list`, and
|
|
51
|
+
* `archive` rely on, so what counts as a task never forks. Changes with no
|
|
52
|
+
* tasks pass (nothing to complete).
|
|
53
|
+
*/
|
|
54
|
+
private runArchivedTaskValidation;
|
|
32
55
|
}
|
|
33
56
|
export {};
|
|
34
57
|
//# sourceMappingURL=validate.d.ts.map
|