@codewalla_india/openspec 1.3.1 → 1.3.2
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/LICENSE +22 -22
- package/README.md +272 -191
- package/bin/openspec.js +5 -5
- package/dist/cli/index.js +99 -62
- package/dist/commands/change.d.ts +0 -2
- package/dist/commands/change.js +107 -86
- package/dist/commands/config.js +14 -16
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/feedback.js +79 -30
- package/dist/commands/modify.d.ts +26 -0
- package/dist/commands/modify.js +147 -0
- package/dist/commands/schema.js +170 -91
- package/dist/commands/show.js +5 -2
- package/dist/commands/spec.js +49 -29
- package/dist/commands/store.js +15 -21
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +43 -20
- package/dist/commands/workflow/index.d.ts +2 -2
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/instructions.d.ts +6 -26
- package/dist/commands/workflow/instructions.js +183 -376
- package/dist/commands/workflow/new-change.d.ts +0 -4
- package/dist/commands/workflow/new-change.js +10 -28
- package/dist/commands/workflow/shared.d.ts +34 -19
- package/dist/commands/workflow/shared.js +34 -4
- package/dist/commands/workflow/status.js +10 -32
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/commands/workset.d.ts +2 -2
- package/dist/commands/workset.js +19 -22
- package/dist/comprehension-quiz/fingerprint.d.ts +39 -0
- package/dist/comprehension-quiz/fingerprint.js +84 -0
- package/dist/comprehension-quiz/index.d.ts +42 -0
- package/dist/comprehension-quiz/index.js +47 -0
- package/dist/comprehension-quiz/pass-record.d.ts +44 -0
- package/dist/comprehension-quiz/pass-record.js +98 -0
- package/dist/comprehension-quiz/providers/anthropic.d.ts +34 -0
- package/dist/comprehension-quiz/providers/anthropic.js +53 -0
- package/dist/comprehension-quiz/providers/factory.d.ts +44 -0
- package/dist/comprehension-quiz/providers/factory.js +174 -0
- package/dist/comprehension-quiz/providers/interface.d.ts +59 -0
- package/dist/comprehension-quiz/providers/interface.js +63 -0
- package/dist/comprehension-quiz/providers/local.d.ts +34 -0
- package/dist/comprehension-quiz/providers/local.js +53 -0
- package/dist/comprehension-quiz/providers/ollama.d.ts +34 -0
- package/dist/comprehension-quiz/providers/ollama.js +53 -0
- package/dist/comprehension-quiz/providers/openai.d.ts +34 -0
- package/dist/comprehension-quiz/providers/openai.js +53 -0
- package/dist/comprehension-quiz/question-generator.d.ts +69 -0
- package/dist/comprehension-quiz/question-generator.js +141 -0
- package/dist/comprehension-quiz/quiz-executor.d.ts +70 -0
- package/dist/comprehension-quiz/quiz-executor.js +165 -0
- package/dist/comprehension-quiz/types.d.ts +115 -0
- package/dist/comprehension-quiz/types.js +11 -0
- package/dist/core/archive.d.ts +14 -1
- package/dist/core/archive.js +1350 -190
- package/dist/core/artifact-graph/graph.d.ts +28 -9
- package/dist/core/artifact-graph/graph.js +94 -43
- 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 +30 -3
- package/dist/core/artifact-graph/instruction-loader.js +70 -12
- 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 +14 -0
- package/dist/core/artifact-graph/resolver.js +69 -15
- 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 +2 -0
- package/dist/core/change-metadata/schema.js +13 -0
- package/dist/core/change-status-policy.d.ts +10 -1
- package/dist/core/change-status-policy.js +30 -2
- package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
- package/dist/core/command-generation/adapters/amazon-q.js +11 -5
- package/dist/core/command-generation/adapters/antigravity.js +6 -5
- package/dist/core/command-generation/adapters/auggie.js +7 -6
- package/dist/core/command-generation/adapters/bob.d.ts +5 -1
- package/dist/core/command-generation/adapters/bob.js +11 -10
- package/dist/core/command-generation/adapters/claude.d.ts +1 -1
- package/dist/core/command-generation/adapters/claude.js +12 -17
- package/dist/core/command-generation/adapters/cline.js +5 -5
- package/dist/core/command-generation/adapters/codebuddy.js +8 -7
- package/dist/core/command-generation/adapters/continue.js +8 -7
- package/dist/core/command-generation/adapters/costrict.js +7 -6
- package/dist/core/command-generation/adapters/crush.js +9 -9
- package/dist/core/command-generation/adapters/cursor.js +8 -8
- package/dist/core/command-generation/adapters/devin.d.ts +19 -0
- package/dist/core/command-generation/adapters/devin.js +36 -0
- package/dist/core/command-generation/adapters/factory.js +7 -6
- package/dist/core/command-generation/adapters/gemini.js +40 -5
- package/dist/core/command-generation/adapters/github-copilot.js +6 -5
- package/dist/core/command-generation/adapters/iflow.js +9 -8
- package/dist/core/command-generation/adapters/index.d.ts +4 -2
- package/dist/core/command-generation/adapters/index.js +4 -2
- package/dist/core/command-generation/adapters/junie.js +6 -5
- package/dist/core/command-generation/adapters/kilocode.js +1 -1
- package/dist/core/command-generation/adapters/kiro.js +6 -5
- package/dist/core/command-generation/adapters/lingma.js +9 -9
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +21 -0
- package/dist/core/command-generation/adapters/oh-my-pi.js +42 -0
- package/dist/core/command-generation/adapters/opencode.js +6 -8
- package/dist/core/command-generation/adapters/pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/pi.js +7 -10
- package/dist/core/command-generation/adapters/qoder.js +9 -9
- package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
- package/dist/core/command-generation/adapters/qwen.js +14 -9
- package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
- package/dist/core/command-generation/adapters/roocode.js +9 -9
- package/dist/core/command-generation/adapters/trae.d.ts +13 -0
- package/dist/core/command-generation/adapters/trae.js +28 -0
- package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
- package/dist/core/command-generation/adapters/zcode.js +33 -0
- package/dist/core/command-generation/generator.d.ts +8 -0
- package/dist/core/command-generation/generator.js +15 -1
- package/dist/core/command-generation/index.d.ts +1 -1
- package/dist/core/command-generation/index.js +1 -1
- package/dist/core/command-generation/invocation.d.ts +73 -0
- package/dist/core/command-generation/invocation.js +77 -0
- package/dist/core/command-generation/registry.js +8 -4
- package/dist/core/command-generation/types.d.ts +9 -2
- package/dist/core/command-generation/yaml.d.ts +20 -6
- package/dist/core/command-generation/yaml.js +31 -21
- package/dist/core/command-surface.d.ts +15 -0
- package/dist/core/command-surface.js +33 -0
- package/dist/core/completions/command-registry.js +16 -61
- package/dist/core/completions/generators/bash-generator.js +41 -41
- package/dist/core/completions/generators/fish-generator.js +7 -7
- package/dist/core/completions/generators/powershell-generator.js +44 -37
- package/dist/core/completions/generators/zsh-generator.js +33 -33
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
- package/dist/core/completions/installers/zsh-installer.js +20 -4
- package/dist/core/completions/templates/bash-templates.js +24 -24
- package/dist/core/completions/templates/fish-templates.js +38 -38
- package/dist/core/completions/templates/powershell-templates.js +28 -28
- package/dist/core/completions/templates/zsh-templates.js +39 -39
- package/dist/core/config-prompts.d.ts +1 -1
- package/dist/core/config-prompts.js +14 -1
- package/dist/core/config-schema.d.ts +10 -1
- package/dist/core/config-schema.js +67 -1
- package/dist/core/config.d.ts +15 -0
- package/dist/core/config.js +46 -5
- 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 +16 -0
- package/dist/core/init.d.ts +58 -0
- package/dist/core/init.js +474 -73
- package/dist/core/legacy-cleanup.d.ts +59 -0
- package/dist/core/legacy-cleanup.js +253 -24
- package/dist/core/list.js +23 -16
- package/dist/core/migration.d.ts +73 -1
- package/dist/core/migration.js +360 -11
- package/dist/core/modify/conflict-detector.d.ts +26 -0
- package/dist/core/modify/conflict-detector.js +62 -0
- package/dist/core/modify/dependency-propagator.d.ts +38 -0
- package/dist/core/modify/dependency-propagator.js +103 -0
- package/dist/core/modify/history-tracker.d.ts +34 -0
- package/dist/core/modify/history-tracker.js +114 -0
- package/dist/core/modify/index.d.ts +7 -0
- package/dist/core/modify/index.js +7 -0
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/openspec-root.js +18 -15
- package/dist/core/parsers/change-parser.d.ts +17 -2
- package/dist/core/parsers/change-parser.js +35 -21
- package/dist/core/parsers/code-fence.d.ts +15 -0
- package/dist/core/parsers/code-fence.js +49 -0
- package/dist/core/parsers/markdown-parser.d.ts +0 -3
- package/dist/core/parsers/markdown-parser.js +7 -60
- package/dist/core/parsers/requirement-blocks.d.ts +29 -0
- package/dist/core/parsers/requirement-blocks.js +150 -31
- package/dist/core/parsers/requirement-text.d.ts +48 -0
- package/dist/core/parsers/requirement-text.js +98 -0
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +20 -30
- package/dist/core/profile-sync-drift.d.ts +0 -8
- package/dist/core/profile-sync-drift.js +49 -50
- package/dist/core/profiles.d.ts +2 -2
- package/dist/core/profiles.js +2 -2
- package/dist/core/project-config.d.ts +27 -18
- package/dist/core/project-config.js +101 -34
- package/dist/core/references.js +42 -4
- package/dist/core/relationship-health.d.ts +8 -0
- package/dist/core/relationship-health.js +13 -0
- package/dist/core/root-selection.d.ts +9 -6
- package/dist/core/root-selection.js +42 -5
- package/dist/core/schemas/base.schema.js +7 -2
- package/dist/core/shared/allowed-tools.d.ts +12 -0
- package/dist/core/shared/allowed-tools.js +12 -0
- 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-generation.js +17 -13
- 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.d.ts +27 -7
- package/dist/core/shared/tool-detection.js +167 -37
- package/dist/core/shared-skill-target.d.ts +16 -0
- package/dist/core/shared-skill-target.js +156 -0
- package/dist/core/specs-apply.d.ts +80 -33
- package/dist/core/specs-apply.js +667 -145
- package/dist/core/store/git.d.ts +13 -0
- package/dist/core/store/git.js +24 -0
- package/dist/core/store/operations.js +20 -0
- package/dist/core/templates/skill-templates.d.ts +3 -2
- package/dist/core/templates/skill-templates.js +3 -2
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +186 -224
- package/dist/core/templates/workflows/archive-change.js +387 -260
- package/dist/core/templates/workflows/bulk-archive-change.js +655 -475
- package/dist/core/templates/workflows/continue-change.js +210 -219
- package/dist/core/templates/workflows/explore.js +479 -443
- package/dist/core/templates/workflows/feedback.js +97 -97
- package/dist/core/templates/workflows/ff-change.js +200 -191
- package/dist/core/templates/workflows/modify-change.d.ts +4 -1
- package/dist/core/templates/workflows/modify-change.js +237 -115
- package/dist/core/templates/workflows/new-change.js +127 -134
- package/dist/core/templates/workflows/onboard.js +548 -542
- package/dist/core/templates/workflows/propose.js +273 -217
- 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 +500 -273
- package/dist/core/templates/workflows/update-change.d.ts +10 -0
- package/dist/core/templates/workflows/update-change.js +177 -0
- package/dist/core/templates/workflows/verify-change.js +324 -326
- package/dist/core/update.d.ts +40 -5
- package/dist/core/update.js +411 -96
- package/dist/core/validation/constants.d.ts +4 -1
- package/dist/core/validation/constants.js +4 -1
- package/dist/core/validation/plan-validator.d.ts +8 -0
- package/dist/core/validation/plan-validator.js +45 -0
- 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 +27 -3
- package/dist/core/validation/validator.js +337 -48
- package/dist/core/version-check.d.ts +152 -0
- package/dist/core/version-check.js +731 -0
- package/dist/core/view.js +13 -18
- package/dist/prompts/searchable-multi-select.js +1 -1
- package/dist/telemetry/caller-detection.d.ts +27 -0
- package/dist/telemetry/caller-detection.js +85 -0
- package/dist/telemetry/caller-detection.test.d.ts +5 -0
- package/dist/telemetry/config.d.ts +48 -2
- package/dist/telemetry/config.js +113 -11
- package/dist/telemetry/config.test.d.ts +5 -0
- package/dist/telemetry/deduplication.d.ts +35 -0
- package/dist/telemetry/deduplication.js +82 -0
- package/dist/telemetry/deduplication.test.d.ts +5 -0
- package/dist/telemetry/identity.d.ts +62 -22
- package/dist/telemetry/identity.js +147 -102
- package/dist/telemetry/index.d.ts +126 -15
- package/dist/telemetry/index.js +569 -37
- package/dist/telemetry/sanitization.d.ts +40 -0
- package/dist/telemetry/sanitization.js +114 -0
- package/dist/telemetry/sanitization.test.d.ts +5 -0
- package/dist/telemetry/throttling.d.ts +20 -0
- package/dist/telemetry/throttling.js +70 -0
- package/dist/ui/welcome-screen.d.ts +9 -1
- package/dist/ui/welcome-screen.js +91 -38
- package/dist/utils/change-metadata.d.ts +43 -0
- package/dist/utils/change-metadata.js +123 -7
- package/dist/utils/change-utils.d.ts +10 -4
- package/dist/utils/change-utils.js +20 -13
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +92 -6
- package/dist/utils/command-references.js +180 -7
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/interactive.d.ts +23 -0
- package/dist/utils/interactive.js +32 -0
- package/dist/utils/item-discovery.d.ts +19 -0
- package/dist/utils/item-discovery.js +30 -48
- package/dist/utils/shell-detection.d.ts +1 -1
- package/dist/utils/shell-detection.js +64 -11
- package/dist/utils/spec-discovery.d.ts +40 -0
- package/dist/utils/spec-discovery.js +115 -0
- package/dist/utils/task-progress.d.ts +29 -1
- package/dist/utils/task-progress.js +109 -15
- package/package.json +96 -84
- package/schemas/spec-driven/schema.yaml +252 -184
- package/schemas/spec-driven/templates/design.md +19 -19
- package/schemas/spec-driven/templates/plan-template.md +91 -0
- package/schemas/spec-driven/templates/proposal.md +29 -24
- package/schemas/spec-driven/templates/spec.md +11 -8
- package/schemas/spec-driven/templates/tasks.md +9 -9
- package/scripts/postinstall.js +83 -83
- package/dist/core/command-generation/adapters/codex.d.ts +0 -16
- package/dist/core/command-generation/adapters/codex.js +0 -39
- package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
- package/dist/core/command-generation/adapters/windsurf.js +0 -38
- package/dist/core/comprehension/config.d.ts +0 -20
- package/dist/core/comprehension/config.js +0 -23
- package/dist/core/comprehension/fingerprint.d.ts +0 -14
- package/dist/core/comprehension/fingerprint.js +0 -33
- package/dist/core/comprehension/index.d.ts +0 -57
- package/dist/core/comprehension/index.js +0 -87
- package/dist/core/comprehension/pass-record.d.ts +0 -29
- package/dist/core/comprehension/pass-record.js +0 -64
- package/dist/core/comprehension/stats.d.ts +0 -35
- package/dist/core/comprehension/stats.js +0 -133
- package/dist/core/templates/workflows/comprehension-guidance.d.ts +0 -9
- package/dist/core/templates/workflows/comprehension-guidance.js +0 -73
- package/dist/core/templates/workflows/mcp-guidance.d.ts +0 -13
- package/dist/core/templates/workflows/mcp-guidance.js +0 -131
- package/dist/core/templates/workflows/user-prompt-guidance.d.ts +0 -16
- package/dist/core/templates/workflows/user-prompt-guidance.js +0 -48
- package/dist/telemetry/caller.d.ts +0 -5
- package/dist/telemetry/caller.js +0 -29
- package/dist/telemetry/client.d.ts +0 -27
- package/dist/telemetry/client.js +0 -127
- package/dist/telemetry/command-context.d.ts +0 -13
- package/dist/telemetry/command-context.js +0 -59
- package/dist/telemetry/comprehension.d.ts +0 -44
- package/dist/telemetry/comprehension.js +0 -105
- package/dist/telemetry/content.d.ts +0 -10
- package/dist/telemetry/content.js +0 -56
- package/dist/telemetry/git-stats.d.ts +0 -12
- package/dist/telemetry/git-stats.js +0 -69
- package/dist/telemetry/identify-cache.d.ts +0 -7
- package/dist/telemetry/identify-cache.js +0 -47
- package/dist/telemetry/input.d.ts +0 -17
- package/dist/telemetry/input.js +0 -68
- package/dist/telemetry/marker.d.ts +0 -37
- package/dist/telemetry/marker.js +0 -67
- package/dist/telemetry/workflow.d.ts +0 -85
- package/dist/telemetry/workflow.js +0 -318
- package/schemas/spec-driven/templates/plan.md +0 -19
package/dist/core/archive.js
CHANGED
|
@@ -1,12 +1,92 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
1
|
+
import { constants, createReadStream, promises as fs } from 'fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import chalk from 'chalk';
|
|
2
4
|
import path from 'path';
|
|
3
5
|
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
|
6
|
+
import { formatLocalDate } from '../utils/date.js';
|
|
7
|
+
import { toRootOutput, withStoreFlag } from './root-selection.js';
|
|
8
|
+
import { VALIDATION_MESSAGES } from './validation/constants.js';
|
|
4
9
|
import { Validator } from './validation/validator.js';
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, } from './specs-apply.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
+
import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, isStoreSelectedRoot, } from './root-selection.js';
|
|
11
|
+
import { trackChangeArchived } from '../telemetry/index.js';
|
|
12
|
+
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
|
|
13
|
+
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
14
|
+
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
|
15
|
+
import { isNonInteractivePromptError } from '../utils/interactive.js';
|
|
16
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
17
|
+
import { folderStyleNameProblem } from './id.js';
|
|
18
|
+
function isMissingPathError(error) {
|
|
19
|
+
return (typeof error === 'object' &&
|
|
20
|
+
error !== null &&
|
|
21
|
+
'code' in error &&
|
|
22
|
+
error.code === 'ENOENT');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Matches the `YYYY-MM-DD-` prefix that archiving prepends to a change name.
|
|
26
|
+
* A change whose name already starts with one (a common authoring convention)
|
|
27
|
+
* is archived under its existing name so the prefix is never stacked (#1309).
|
|
28
|
+
*/
|
|
29
|
+
const ARCHIVE_DATE_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}-/;
|
|
30
|
+
/**
|
|
31
|
+
* True when the ONLY thing wrong with a rebuilt spec is that it has no
|
|
32
|
+
* requirements. That is the exact failure retiring a capability replaces
|
|
33
|
+
* (#1302); anything else means the spec is broken in a way the author still has
|
|
34
|
+
* to fix, so archive must abort exactly as it always did instead of retiring.
|
|
35
|
+
*
|
|
36
|
+
* Asking the validator - rather than counting requirement blocks a second time -
|
|
37
|
+
* is what makes "this spec could not have been written anyway" true by
|
|
38
|
+
* construction. The two counts genuinely disagree: `MarkdownParser` accepts any
|
|
39
|
+
* `###` heading under `## Requirements` as a requirement, while the delta block
|
|
40
|
+
* parser only indexes canonical `### Requirement:` headers and sweeps the rest
|
|
41
|
+
* into the preamble, which survives into the rebuilt spec.
|
|
42
|
+
*/
|
|
43
|
+
export async function isRetirableSpec(specName, rebuilt) {
|
|
44
|
+
const report = await new Validator().validateSpecContent(specName, rebuilt);
|
|
45
|
+
if (report.valid)
|
|
46
|
+
return false;
|
|
47
|
+
const errors = report.issues.filter((issue) => issue.level === 'ERROR');
|
|
48
|
+
return (errors.length > 0 &&
|
|
49
|
+
errors.every((issue) => issue.message === VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS));
|
|
50
|
+
}
|
|
51
|
+
async function isRetirementCandidate(update, built, skipValidation) {
|
|
52
|
+
return (!skipValidation &&
|
|
53
|
+
built.noRequirementBlocks &&
|
|
54
|
+
built.unaccountedContent.length === 0 &&
|
|
55
|
+
(await isRetirableSpec(update.id, built.rebuilt)));
|
|
56
|
+
}
|
|
57
|
+
async function decideSpecOutcome(update, built, skipValidation, retirementDeclared) {
|
|
58
|
+
// The author has to have asked. Without the marker this falls through to the
|
|
59
|
+
// ordinary write, which fails validation exactly as it always did - and the
|
|
60
|
+
// abort names the marker, so the dead end #1302 describes now comes with its
|
|
61
|
+
// own way out instead of just a rejected spec.
|
|
62
|
+
if (!retirementDeclared)
|
|
63
|
+
return 'write';
|
|
64
|
+
// Retirement is decided by the validator, never by a second opinion about
|
|
65
|
+
// what counts as a requirement: the block parser sweeps some shapes the
|
|
66
|
+
// validator accepts into the preamble, so "no blocks left" alone would retire
|
|
67
|
+
// specs that validate fine.
|
|
68
|
+
//
|
|
69
|
+
// Residual `###` headings veto it outright. The validator can be talked out of
|
|
70
|
+
// seeing them - a stray `### Requirements` under Purpose captures its section
|
|
71
|
+
// lookup - but a reader cannot, and deleting the file would take them with it.
|
|
72
|
+
//
|
|
73
|
+
// Under --no-validate there is no verdict to lean on, so nothing is retired:
|
|
74
|
+
// the author opted out of the check that makes this safe, and the old
|
|
75
|
+
// behavior (write the spec) loses nothing.
|
|
76
|
+
// Nothing in the file may sit outside the parts the merge understands. Asked
|
|
77
|
+
// as "did anything land outside the parts I understand" rather than "does
|
|
78
|
+
// anything look like a requirement" - the second question is the one six
|
|
79
|
+
// review rounds each found a new way to answer wrongly.
|
|
80
|
+
const retirable = await isRetirementCandidate(update, built, skipValidation);
|
|
81
|
+
if (!retirable)
|
|
82
|
+
return 'write';
|
|
83
|
+
// Nothing on disk to write or retire: the capability is already retired.
|
|
84
|
+
if (!update.exists)
|
|
85
|
+
return 'skip';
|
|
86
|
+
// A spec that was already requirement-less and lost nothing this run is still
|
|
87
|
+
// the author's to fix, so it takes the same abort it has always produced.
|
|
88
|
+
return built.counts.removed > 0 ? 'retire' : 'write';
|
|
89
|
+
}
|
|
10
90
|
async function listActiveChangeNames(changesDir) {
|
|
11
91
|
try {
|
|
12
92
|
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
@@ -15,14 +95,18 @@ async function listActiveChangeNames(changesDir) {
|
|
|
15
95
|
.map((entry) => entry.name)
|
|
16
96
|
.sort();
|
|
17
97
|
}
|
|
18
|
-
catch {
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (!isMissingPathError(error))
|
|
100
|
+
throw error;
|
|
19
101
|
return [];
|
|
20
102
|
}
|
|
21
103
|
}
|
|
22
104
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
105
|
+
* A decision point archive cannot get past on its own. Thrown wherever the
|
|
106
|
+
* flow needs an answer it has no way to obtain: in JSON mode, which never
|
|
107
|
+
* prompts at all, and in human mode when a prompt failed because nothing
|
|
108
|
+
* could answer it (#1479). Either way it carries a machine-readable
|
|
109
|
+
* diagnostic and exits non-zero.
|
|
26
110
|
*/
|
|
27
111
|
class ArchiveBlockedError extends Error {
|
|
28
112
|
diagnostic;
|
|
@@ -37,6 +121,100 @@ class ArchiveBlockedError extends Error {
|
|
|
37
121
|
};
|
|
38
122
|
}
|
|
39
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Quotes a change name for a `Fix:` line the reader is meant to paste.
|
|
126
|
+
* Archive resolves a change by stat-ing its directory, so the name is
|
|
127
|
+
* whatever the directory is called - including names with spaces or shell
|
|
128
|
+
* metacharacters, which pasted unquoted would run as a second command.
|
|
129
|
+
*
|
|
130
|
+
* Double quotes are the one form bash, zsh, PowerShell and cmd.exe all read
|
|
131
|
+
* the same way, so a POSIX-only `'...'` would be wrong on Windows. Characters
|
|
132
|
+
* that stay inert inside double quotes in every one of those shells are the
|
|
133
|
+
* limit of what can be quoted portably; a name containing anything else has
|
|
134
|
+
* no portable spelling, so the placeholder is named instead of emitting a
|
|
135
|
+
* command that might expand to something the reader did not intend.
|
|
136
|
+
*
|
|
137
|
+
* `%` and `!` are unquotable for the same reason even though POSIX shells
|
|
138
|
+
* leave them alone inside double quotes: cmd.exe expands `%NAME%` inside
|
|
139
|
+
* double quotes, and `!NAME!` expands there too under `setlocal
|
|
140
|
+
* enabledelayedexpansion` (as does `!` under bash's interactive history
|
|
141
|
+
* expansion). A change directory really can be named `%USERNAME%`, and a
|
|
142
|
+
* rerun that silently targets a different change is worse than one the reader
|
|
143
|
+
* has to fill in.
|
|
144
|
+
*/
|
|
145
|
+
function quoteChangeName(name) {
|
|
146
|
+
return quoteForShell(name) ?? '<change-name>';
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Quotes an argument for a line the reader is meant to paste, or returns
|
|
150
|
+
* undefined when no portable spelling exists.
|
|
151
|
+
*
|
|
152
|
+
* Double quotes are the one form bash, zsh, PowerShell and cmd.exe all read the
|
|
153
|
+
* same way. A value holding a character that stays special INSIDE double quotes
|
|
154
|
+
* in any of them has no portable spelling, so callers say something else rather
|
|
155
|
+
* than emit a command that expands to something the reader did not intend.
|
|
156
|
+
*/
|
|
157
|
+
function quoteForShell(value) {
|
|
158
|
+
if (/^[A-Za-z0-9._\/-]+$/.test(value))
|
|
159
|
+
return value;
|
|
160
|
+
if (!/["\\$`\r\n%!]/.test(value))
|
|
161
|
+
return `"${value}"`;
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Renders a change name inside a prose message. The name is a directory name,
|
|
166
|
+
* so it can hold control characters, and human mode prints the message
|
|
167
|
+
* verbatim: a raw CR or LF would let a change directory forge its own `Fix:`
|
|
168
|
+
* line, which is worse here than anywhere else because `quoteChangeName`
|
|
169
|
+
* degrades the real fix to the `<change-name>` placeholder for exactly those
|
|
170
|
+
* names - leaving the forged line as the only pasteable command on screen.
|
|
171
|
+
* An ESC could redraw the terminal. Neither survives.
|
|
172
|
+
*/
|
|
173
|
+
function describeChangeName(name) {
|
|
174
|
+
return name.replace(/[\u0000-\u001f\u007f]/g, '?');
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Builds the flags a blocked archive's suggested rerun has to reproduce. The
|
|
178
|
+
* caller's own flags are carried, because suggesting a bare `--yes` rerun for
|
|
179
|
+
* `archive x --skip-specs` would merge deltas into the main specs - the exact
|
|
180
|
+
* thing `--skip-specs` was passed to prevent.
|
|
181
|
+
*/
|
|
182
|
+
function rerunFlags(options) {
|
|
183
|
+
return [
|
|
184
|
+
...(options.skipSpecs ? ['--skip-specs'] : []),
|
|
185
|
+
...(options.validate === false || options.noValidate === true ? ['--no-validate'] : []),
|
|
186
|
+
'--yes',
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
function rerunCommand(root, changeName, options) {
|
|
190
|
+
const flags = rerunFlags(options).join(' ');
|
|
191
|
+
// A name starting with a dash is read as an option wherever it sits, so it
|
|
192
|
+
// goes last, behind the `--` that ends option parsing. The store flag has
|
|
193
|
+
// to stay in front of that `--` to still be read as an option.
|
|
194
|
+
if (changeName.startsWith('-')) {
|
|
195
|
+
return `${withStoreFlag(root, `openspec archive ${flags}`)} -- ${quoteChangeName(changeName)}`;
|
|
196
|
+
}
|
|
197
|
+
return withStoreFlag(root, `openspec archive ${quoteChangeName(changeName)} ${flags}`);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Asks a yes/no question in human mode. When no answer can be read — the
|
|
201
|
+
* usual case for an AI agent or a script that runs the command with stdin
|
|
202
|
+
* closed — the raw @inquirer failure is replaced with guidance for this
|
|
203
|
+
* decision point, so the caller learns which flag to pass instead of reading
|
|
204
|
+
* `User force closed the prompt` (#1479).
|
|
205
|
+
*/
|
|
206
|
+
async function confirmOrBlock(prompt, blocked) {
|
|
207
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
208
|
+
try {
|
|
209
|
+
return await confirm(prompt);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (isNonInteractivePromptError(error)) {
|
|
213
|
+
throw blocked();
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
40
218
|
function toArchiveDiagnostic(error) {
|
|
41
219
|
if (error instanceof ArchiveBlockedError) {
|
|
42
220
|
return error.diagnostic;
|
|
@@ -53,41 +231,603 @@ function toArchiveDiagnostic(error) {
|
|
|
53
231
|
/**
|
|
54
232
|
* Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows).
|
|
55
233
|
*/
|
|
56
|
-
async function
|
|
57
|
-
await fs.
|
|
234
|
+
async function copySymbolicLink(src, dest) {
|
|
235
|
+
const target = await fs.readlink(src);
|
|
236
|
+
const isWindowsDirectoryLink = process.platform === 'win32' && (await fs.stat(src)).isDirectory();
|
|
237
|
+
const destinationTarget = isWindowsDirectoryLink && !path.isAbsolute(target)
|
|
238
|
+
? path.resolve(path.dirname(src), target)
|
|
239
|
+
: target;
|
|
240
|
+
await fs.symlink(destinationTarget, dest, isWindowsDirectoryLink ? 'junction' : undefined);
|
|
241
|
+
}
|
|
242
|
+
async function copyDirContents(src, dest) {
|
|
243
|
+
const sourceStat = await fs.lstat(src);
|
|
244
|
+
// Keep group/other access no broader than the source while ensuring this
|
|
245
|
+
// process can populate even a read-only source directory.
|
|
246
|
+
await fs.chmod(dest, (sourceStat.mode & 0o7777) | 0o700);
|
|
58
247
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
59
248
|
for (const entry of entries) {
|
|
60
249
|
const srcPath = path.join(src, entry.name);
|
|
61
250
|
const destPath = path.join(dest, entry.name);
|
|
62
251
|
if (entry.isDirectory()) {
|
|
63
|
-
await
|
|
252
|
+
await fs.mkdir(destPath, { mode: 0o700 });
|
|
253
|
+
await copyDirContents(srcPath, destPath);
|
|
254
|
+
}
|
|
255
|
+
else if (entry.isSymbolicLink()) {
|
|
256
|
+
await copySymbolicLink(srcPath, destPath);
|
|
257
|
+
}
|
|
258
|
+
else if (entry.isFile()) {
|
|
259
|
+
await fs.copyFile(srcPath, destPath, constants.COPYFILE_EXCL);
|
|
64
260
|
}
|
|
65
261
|
else {
|
|
66
|
-
|
|
262
|
+
throw new Error(`Cannot archive unsupported filesystem entry: ${srcPath}`);
|
|
67
263
|
}
|
|
68
264
|
}
|
|
265
|
+
await fs.chmod(dest, sourceStat.mode & 0o7777);
|
|
266
|
+
}
|
|
267
|
+
async function fingerprintDirectoryContents(root) {
|
|
268
|
+
const hash = createHash('sha256');
|
|
269
|
+
const updateHashField = (label, value) => {
|
|
270
|
+
const labelBuffer = Buffer.from(label);
|
|
271
|
+
const valueBuffer = typeof value === 'string' ? Buffer.from(value) : value;
|
|
272
|
+
const lengths = Buffer.allocUnsafe(16);
|
|
273
|
+
lengths.writeBigUInt64BE(BigInt(labelBuffer.length), 0);
|
|
274
|
+
lengths.writeBigUInt64BE(BigInt(valueBuffer.length), 8);
|
|
275
|
+
hash.update(lengths);
|
|
276
|
+
hash.update(labelBuffer);
|
|
277
|
+
hash.update(valueBuffer);
|
|
278
|
+
};
|
|
279
|
+
const fingerprintFile = async (filePath) => {
|
|
280
|
+
const fileHash = createHash('sha256');
|
|
281
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
282
|
+
fileHash.update(chunk);
|
|
283
|
+
}
|
|
284
|
+
return fileHash.digest();
|
|
285
|
+
};
|
|
286
|
+
const visit = async (dir, relativeDir) => {
|
|
287
|
+
const before = await fs.lstat(dir, { bigint: true });
|
|
288
|
+
if (!before.isDirectory()) {
|
|
289
|
+
throw new Error(`Expected a directory while verifying ${dir}.`);
|
|
290
|
+
}
|
|
291
|
+
updateHashField('directory-mode', (before.mode & 4095n).toString());
|
|
292
|
+
const entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const entryPath = path.join(dir, entry.name);
|
|
295
|
+
const relativePath = path.join(relativeDir, entry.name);
|
|
296
|
+
const stat = await fs.lstat(entryPath, { bigint: true });
|
|
297
|
+
updateHashField('path', relativePath);
|
|
298
|
+
if (stat.isDirectory()) {
|
|
299
|
+
updateHashField('type', 'directory');
|
|
300
|
+
await visit(entryPath, relativePath);
|
|
301
|
+
}
|
|
302
|
+
else if (stat.isSymbolicLink()) {
|
|
303
|
+
const target = await fs.readlink(entryPath);
|
|
304
|
+
const after = await fs.lstat(entryPath, { bigint: true });
|
|
305
|
+
if (statIdentity(stat) !== statIdentity(after)) {
|
|
306
|
+
throw new Error(`Path changed while archive was reading ${entryPath}.`);
|
|
307
|
+
}
|
|
308
|
+
updateHashField('type', 'symlink');
|
|
309
|
+
updateHashField('target', target);
|
|
310
|
+
}
|
|
311
|
+
else if (stat.isFile()) {
|
|
312
|
+
const contentFingerprint = await fingerprintFile(entryPath);
|
|
313
|
+
const after = await fs.lstat(entryPath, { bigint: true });
|
|
314
|
+
if (statIdentity(stat) !== statIdentity(after)) {
|
|
315
|
+
throw new Error(`Path changed while archive was reading ${entryPath}.`);
|
|
316
|
+
}
|
|
317
|
+
updateHashField('type', 'file');
|
|
318
|
+
updateHashField('mode', (stat.mode & 4095n).toString());
|
|
319
|
+
updateHashField('content-sha256', contentFingerprint);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
updateHashField('type', 'other');
|
|
323
|
+
updateHashField('mode', stat.mode.toString());
|
|
324
|
+
updateHashField('size', stat.size.toString());
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const after = await fs.lstat(dir, { bigint: true });
|
|
328
|
+
if (statIdentity(before) !== statIdentity(after)) {
|
|
329
|
+
throw new Error(`Directory changed while archive was reading ${dir}.`);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
await visit(root, '');
|
|
333
|
+
return hash.digest('hex');
|
|
334
|
+
}
|
|
335
|
+
async function assertCopiedDirectoryUnchanged(stagedSource, destination, expectedFingerprint) {
|
|
336
|
+
const sourceFingerprint = await fingerprintDirectoryContents(stagedSource);
|
|
337
|
+
const destinationFingerprint = await fingerprintDirectoryContents(destination);
|
|
338
|
+
if (sourceFingerprint !== expectedFingerprint ||
|
|
339
|
+
destinationFingerprint !== expectedFingerprint) {
|
|
340
|
+
throw new Error(`Change directory contents changed during the fallback copy from ${stagedSource} to ${destination}.`);
|
|
341
|
+
}
|
|
69
342
|
}
|
|
70
343
|
/**
|
|
71
|
-
* Move a directory from src to dest. On Windows, fs.rename()
|
|
72
|
-
* EPERM
|
|
73
|
-
*
|
|
74
|
-
*
|
|
344
|
+
* Move a directory from src to dest. On Windows, fs.rename() can fail with
|
|
345
|
+
* EPERM, and cross-device moves fail with EXDEV. When the source can first be
|
|
346
|
+
* renamed to a private sibling, fall back to a verified copy-then-remove. A
|
|
347
|
+
* source that cannot be staged is left untouched rather than copied and deleted
|
|
348
|
+
* through a path another process may still be editing.
|
|
75
349
|
*/
|
|
76
|
-
|
|
350
|
+
class MoveDestinationRetainedError extends Error {
|
|
351
|
+
}
|
|
352
|
+
class RetirementBackupsRetainedError extends Error {
|
|
353
|
+
}
|
|
354
|
+
async function moveDirectory(src, dest, options = {}) {
|
|
77
355
|
try {
|
|
78
356
|
await fs.rename(src, dest);
|
|
79
357
|
}
|
|
80
358
|
catch (err) {
|
|
81
359
|
const code = err?.code;
|
|
360
|
+
// rename onto a non-empty directory: the destination was taken while the
|
|
361
|
+
// archive was running. Same condition the pre-flight check reports.
|
|
362
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
|
363
|
+
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
364
|
+
}
|
|
82
365
|
if (code === 'EPERM' || code === 'EXDEV') {
|
|
83
|
-
|
|
84
|
-
|
|
366
|
+
const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`);
|
|
367
|
+
try {
|
|
368
|
+
await fs.rename(src, stagedSource);
|
|
369
|
+
}
|
|
370
|
+
catch (stageError) {
|
|
371
|
+
throw new Error(`Could not safely stage ${src} before the fallback archive copy ` +
|
|
372
|
+
`(${stageError instanceof Error ? stageError.message : String(stageError)}). ` +
|
|
373
|
+
'No fallback copy was attempted.');
|
|
374
|
+
}
|
|
375
|
+
let destIsOurs = false;
|
|
376
|
+
let stagedFingerprint;
|
|
377
|
+
try {
|
|
378
|
+
stagedFingerprint = await fingerprintDirectoryContents(stagedSource);
|
|
379
|
+
await fs.mkdir(dest, { mode: 0o700 });
|
|
380
|
+
destIsOurs = true;
|
|
381
|
+
await copyDirContents(stagedSource, dest);
|
|
382
|
+
await options.verifyCopiedDestination?.(stagedSource);
|
|
383
|
+
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
|
384
|
+
}
|
|
385
|
+
catch (copyError) {
|
|
386
|
+
if (destIsOurs) {
|
|
387
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await fs.rename(stagedSource, src);
|
|
391
|
+
}
|
|
392
|
+
catch (restoreError) {
|
|
393
|
+
throw new Error(`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
|
394
|
+
`Could not restore the staged source at ${stagedSource} ` +
|
|
395
|
+
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
396
|
+
}
|
|
397
|
+
if (copyError.code === 'EEXIST') {
|
|
398
|
+
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
399
|
+
}
|
|
400
|
+
throw copyError;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
await options.verifyCopiedDestination?.(stagedSource);
|
|
404
|
+
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
|
405
|
+
}
|
|
406
|
+
catch (verificationError) {
|
|
407
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
408
|
+
try {
|
|
409
|
+
await fs.rename(stagedSource, src);
|
|
410
|
+
}
|
|
411
|
+
catch (restoreError) {
|
|
412
|
+
throw new Error(`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
|
413
|
+
`Could not restore the staged source at ${stagedSource} ` +
|
|
414
|
+
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
415
|
+
}
|
|
416
|
+
throw verificationError;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
await fs.rm(stagedSource, { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
catch (cleanupError) {
|
|
422
|
+
// Recursive removal may already have deleted part of the source. The
|
|
423
|
+
// destination is now the only complete copy, so never erase it while
|
|
424
|
+
// trying to make this failed move look atomic.
|
|
425
|
+
throw new MoveDestinationRetainedError(`Copied ${src} to ${dest}, but could not remove the staged source at ` +
|
|
426
|
+
`${stagedSource} completely ` +
|
|
427
|
+
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
|
428
|
+
'The complete destination was retained for recovery.');
|
|
429
|
+
}
|
|
85
430
|
}
|
|
86
431
|
else {
|
|
87
432
|
throw err;
|
|
88
433
|
}
|
|
89
434
|
}
|
|
90
435
|
}
|
|
436
|
+
async function assertArchiveDestinationAvailable(archivePath, archiveName) {
|
|
437
|
+
try {
|
|
438
|
+
await fs.lstat(archivePath);
|
|
439
|
+
throw new ArchiveBlockedError('archive_target_exists', `Archive '${archiveName}' already exists.`);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (error instanceof ArchiveBlockedError)
|
|
443
|
+
throw error;
|
|
444
|
+
if (error.code !== 'ENOENT')
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function archiveClaimPath(archivePath, _archiveName) {
|
|
449
|
+
return path.join(path.dirname(archivePath), '.openspec-archive.lock');
|
|
450
|
+
}
|
|
451
|
+
async function releaseArchiveClaim(claim, claimPath) {
|
|
452
|
+
const owned = await claim.handle.stat({ bigint: true }).catch(() => undefined);
|
|
453
|
+
await claim.handle.close().catch(() => undefined);
|
|
454
|
+
if (owned === undefined)
|
|
455
|
+
return;
|
|
456
|
+
try {
|
|
457
|
+
// Read between two lstats by design: the identity + content match below
|
|
458
|
+
// proves we still own this claim before unlinking it. This is a concurrent-
|
|
459
|
+
// change detector, not an fd-less race to "fix" (CodeQL js/file-system-race).
|
|
460
|
+
const current = await fs.lstat(claimPath, { bigint: true });
|
|
461
|
+
const contents = await fs.readFile(claimPath, 'utf8');
|
|
462
|
+
const currentAfterRead = await fs.lstat(claimPath, { bigint: true });
|
|
463
|
+
if (current.dev === owned.dev &&
|
|
464
|
+
current.ino === owned.ino &&
|
|
465
|
+
current.dev === currentAfterRead.dev &&
|
|
466
|
+
current.ino === currentAfterRead.ino &&
|
|
467
|
+
contents === claim.contents) {
|
|
468
|
+
await fs.unlink(claimPath);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (error.code !== 'ENOENT')
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function claimArchiveDestination(archivePath, archiveName) {
|
|
477
|
+
const claimPath = archiveClaimPath(archivePath, archiveName);
|
|
478
|
+
try {
|
|
479
|
+
const handle = await fs.open(claimPath, 'wx');
|
|
480
|
+
const claim = {
|
|
481
|
+
handle,
|
|
482
|
+
contents: JSON.stringify({ pid: process.pid, nonce: randomUUID() }),
|
|
483
|
+
};
|
|
484
|
+
try {
|
|
485
|
+
await handle.writeFile(claim.contents);
|
|
486
|
+
await handle.sync();
|
|
487
|
+
return claim;
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
await releaseArchiveClaim(claim, claimPath).catch(() => undefined);
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
if (error.code === 'EEXIST') {
|
|
496
|
+
throw new ArchiveBlockedError('archive_target_exists', `Archive '${archiveName}' is already being created. If no archive process is running, ` +
|
|
497
|
+
`remove the stale claim at ${claimPath} and rerun.`);
|
|
498
|
+
}
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function statIdentity(value) {
|
|
503
|
+
return `${value.dev}:${value.ino}:${value.mode}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
|
|
504
|
+
}
|
|
505
|
+
function movableStatIdentity(value) {
|
|
506
|
+
return `${value.dev}:${value.ino}:${value.mode}:${value.size}`;
|
|
507
|
+
}
|
|
508
|
+
async function fingerprintPath(filePath) {
|
|
509
|
+
try {
|
|
510
|
+
const stat = await fs.lstat(filePath, { bigint: true });
|
|
511
|
+
const digest = async () => createHash('sha256').update(await fs.readFile(filePath)).digest('hex');
|
|
512
|
+
if (stat.isSymbolicLink()) {
|
|
513
|
+
const link = await fs.readlink(filePath);
|
|
514
|
+
try {
|
|
515
|
+
const referentBefore = await fs.stat(filePath, { bigint: true });
|
|
516
|
+
const hash = await digest();
|
|
517
|
+
const referentAfter = await fs.stat(filePath, { bigint: true });
|
|
518
|
+
const entryAfter = await fs.lstat(filePath, { bigint: true });
|
|
519
|
+
if (statIdentity(stat) !== statIdentity(entryAfter) ||
|
|
520
|
+
statIdentity(referentBefore) !== statIdentity(referentAfter) ||
|
|
521
|
+
link !== (await fs.readlink(filePath))) {
|
|
522
|
+
throw new Error(`Path changed while archive was reading ${filePath}.`);
|
|
523
|
+
}
|
|
524
|
+
return `symlink:${statIdentity(stat)}:${link}:${statIdentity(referentAfter)}:${hash}`;
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
if (error.code === 'ENOENT') {
|
|
528
|
+
return `symlink:${statIdentity(stat)}:${link}:missing`;
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (stat.isFile()) {
|
|
534
|
+
const hash = await digest();
|
|
535
|
+
const after = await fs.lstat(filePath, { bigint: true });
|
|
536
|
+
if (statIdentity(stat) !== statIdentity(after)) {
|
|
537
|
+
throw new Error(`Path changed while archive was reading ${filePath}.`);
|
|
538
|
+
}
|
|
539
|
+
return `file:${statIdentity(after)}:${hash}`;
|
|
540
|
+
}
|
|
541
|
+
return `other:${statIdentity(stat)}`;
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
if (error.code === 'ENOENT')
|
|
545
|
+
return 'missing';
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async function fingerprintMovablePath(filePath) {
|
|
550
|
+
try {
|
|
551
|
+
const entry = await fs.lstat(filePath, { bigint: true });
|
|
552
|
+
// Deliberate stat -> read -> re-stat: a concurrent change is DETECTED by the
|
|
553
|
+
// statIdentity comparison below and throws. Do not collapse to fd I/O, which
|
|
554
|
+
// would pin one inode and blind the detector (CodeQL js/file-system-race).
|
|
555
|
+
const hash = createHash('sha256')
|
|
556
|
+
.update(await fs.readFile(filePath))
|
|
557
|
+
.digest('hex');
|
|
558
|
+
if (entry.isSymbolicLink()) {
|
|
559
|
+
const link = await fs.readlink(filePath);
|
|
560
|
+
const referent = await fs.stat(filePath, { bigint: true });
|
|
561
|
+
const entryAfter = await fs.lstat(filePath, { bigint: true });
|
|
562
|
+
const referentAfter = await fs.stat(filePath, { bigint: true });
|
|
563
|
+
const linkAfter = await fs.readlink(filePath);
|
|
564
|
+
if (statIdentity(entry) !== statIdentity(entryAfter) ||
|
|
565
|
+
statIdentity(referent) !== statIdentity(referentAfter) ||
|
|
566
|
+
link !== linkAfter) {
|
|
567
|
+
throw new Error(`Path changed while archive was reading ${filePath}.`);
|
|
568
|
+
}
|
|
569
|
+
return (`symlink:${movableStatIdentity(entry)}:${link}:` +
|
|
570
|
+
`${movableStatIdentity(referentAfter)}:${hash}`);
|
|
571
|
+
}
|
|
572
|
+
const entryAfter = await fs.lstat(filePath, { bigint: true });
|
|
573
|
+
if (statIdentity(entry) !== statIdentity(entryAfter)) {
|
|
574
|
+
throw new Error(`Path changed while archive was reading ${filePath}.`);
|
|
575
|
+
}
|
|
576
|
+
return `file:${movableStatIdentity(entry)}:${hash}`;
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
if (error.code === 'ENOENT')
|
|
580
|
+
return 'missing';
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async function fingerprintPortableContent(filePath) {
|
|
585
|
+
try {
|
|
586
|
+
const entry = await fs.lstat(filePath);
|
|
587
|
+
// Point-in-time content hash by design (no re-stat): callers compare it
|
|
588
|
+
// against a prior fingerprint of the same bytes, so any concurrent change
|
|
589
|
+
// surfaces as a hash mismatch (CodeQL js/file-system-race is a false positive here).
|
|
590
|
+
const hash = createHash('sha256')
|
|
591
|
+
.update(await fs.readFile(filePath))
|
|
592
|
+
.digest('hex');
|
|
593
|
+
return entry.isSymbolicLink()
|
|
594
|
+
? `symlink:${await fs.readlink(filePath)}:${hash}`
|
|
595
|
+
: `file:${hash}`;
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (error.code === 'ENOENT')
|
|
599
|
+
return 'missing';
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/** Fail closed if the metadata authorizing a retirement leaves its snapshot. */
|
|
604
|
+
async function assertRetirementAuthorization(changeDir, expectedFingerprint, options = {}) {
|
|
605
|
+
const metadataPath = path.join(changeDir, METADATA_FILENAME);
|
|
606
|
+
const before = await fingerprintPortableContent(metadataPath);
|
|
607
|
+
const markerStillDeclared = options.verifyMarker === false || readRetireCapabilitiesMarker(changeDir).declared;
|
|
608
|
+
const after = await fingerprintPortableContent(metadataPath);
|
|
609
|
+
if (before !== expectedFingerprint ||
|
|
610
|
+
after !== expectedFingerprint ||
|
|
611
|
+
!markerStillDeclared) {
|
|
612
|
+
throw new Error(`The ${METADATA_FILENAME} retirement authorization changed before archive could complete.`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async function fingerprintSpecInputs(update) {
|
|
616
|
+
return `${await fingerprintPath(update.source)}\n${await fingerprintPath(update.target)}`;
|
|
617
|
+
}
|
|
618
|
+
async function mutationTargetIdentity(mutation) {
|
|
619
|
+
try {
|
|
620
|
+
const stat = await fs.stat(mutation.update.target, { bigint: true });
|
|
621
|
+
return `${stat.dev}:${stat.ino}`;
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
if (error.code === 'ENOENT') {
|
|
625
|
+
const parent = path.dirname(mutation.update.target);
|
|
626
|
+
const realParent = await fs.realpath(parent).catch(() => path.resolve(parent));
|
|
627
|
+
return `missing:${path.join(realParent, path.basename(mutation.update.target))}`;
|
|
628
|
+
}
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function assertDistinctMutationTargets(mutations) {
|
|
633
|
+
const owners = new Map();
|
|
634
|
+
for (const mutation of mutations) {
|
|
635
|
+
const identity = await mutationTargetIdentity(mutation);
|
|
636
|
+
const existing = owners.get(identity);
|
|
637
|
+
if (existing !== undefined) {
|
|
638
|
+
throw new Error(`Spec updates for '${existing}' and '${mutation.update.id}' resolve to the same target ` +
|
|
639
|
+
`${identity}. Replace the capability alias or combine the deltas before archiving.`);
|
|
640
|
+
}
|
|
641
|
+
owners.set(identity, mutation.update.id);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function captureSpecSnapshots(mutations) {
|
|
645
|
+
return Promise.all(mutations.map(async ({ update, outcome, rebuilt }) => {
|
|
646
|
+
try {
|
|
647
|
+
const stat = await fs.lstat(update.target);
|
|
648
|
+
if (stat.isSymbolicLink()) {
|
|
649
|
+
let content;
|
|
650
|
+
let contentExisted = false;
|
|
651
|
+
if (outcome === 'write') {
|
|
652
|
+
try {
|
|
653
|
+
// Best-effort rollback snapshot; a concurrent edit is caught later
|
|
654
|
+
// by restoreSpecSnapshots refusing to overwrite non-matching content,
|
|
655
|
+
// not here (CodeQL js/file-system-race).
|
|
656
|
+
content = await fs.readFile(update.target);
|
|
657
|
+
contentExisted = true;
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (error.code !== 'ENOENT')
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
target: update.target,
|
|
666
|
+
existed: true,
|
|
667
|
+
outcome,
|
|
668
|
+
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
|
669
|
+
content,
|
|
670
|
+
contentExisted,
|
|
671
|
+
symlink: await fs.readlink(update.target),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
target: update.target,
|
|
676
|
+
existed: true,
|
|
677
|
+
outcome,
|
|
678
|
+
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
|
679
|
+
// Snapshot read for rollback; restoreSpecSnapshots re-checks this
|
|
680
|
+
// content before restoring, so a mid-run change aborts instead of
|
|
681
|
+
// clobbering (CodeQL js/file-system-race).
|
|
682
|
+
...(stat.isFile() ? { content: await fs.readFile(update.target) } : {}),
|
|
683
|
+
...(stat.isFile() ? { mode: stat.mode } : {}),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
if (error.code === 'ENOENT') {
|
|
688
|
+
return {
|
|
689
|
+
target: update.target,
|
|
690
|
+
existed: false,
|
|
691
|
+
outcome,
|
|
692
|
+
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
async function restoreSpecSnapshots(snapshots) {
|
|
700
|
+
const errors = [];
|
|
701
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
702
|
+
try {
|
|
703
|
+
if (snapshot.outcome === 'retire') {
|
|
704
|
+
if (snapshot.displacedPath !== undefined) {
|
|
705
|
+
try {
|
|
706
|
+
await fs.lstat(snapshot.target);
|
|
707
|
+
throw new Error(`Archive rollback would overwrite a concurrent change at ${snapshot.target}. ` +
|
|
708
|
+
`The displaced spec was retained at ${snapshot.displacedPath}.`);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
if (error.code !== 'ENOENT')
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
await fs.rename(snapshot.displacedPath, snapshot.target);
|
|
715
|
+
snapshot.displacedPath = undefined;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
const current = await fs.lstat(snapshot.target);
|
|
720
|
+
const unchangedSymlink = snapshot.symlink !== undefined &&
|
|
721
|
+
current.isSymbolicLink() &&
|
|
722
|
+
(await fs.readlink(snapshot.target)) === snapshot.symlink;
|
|
723
|
+
// Re-read to confirm the target still holds the snapshot content; a
|
|
724
|
+
// mismatch means a concurrent edit, and rollback throws below rather
|
|
725
|
+
// than overwrite it (CodeQL js/file-system-race is intentional here).
|
|
726
|
+
const unchangedFile = snapshot.symlink === undefined &&
|
|
727
|
+
snapshot.content !== undefined &&
|
|
728
|
+
current.isFile() &&
|
|
729
|
+
(await fs.readFile(snapshot.target)).equals(snapshot.content);
|
|
730
|
+
if (unchangedSymlink || unchangedFile)
|
|
731
|
+
continue;
|
|
732
|
+
throw new Error(`Archive rollback would overwrite a concurrent change at ${snapshot.target}.`);
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
if (error.code !== 'ENOENT')
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
let current;
|
|
741
|
+
try {
|
|
742
|
+
current = await fs.lstat(snapshot.target);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error.code === 'ENOENT' &&
|
|
746
|
+
!snapshot.existed) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
if ((snapshot.symlink !== undefined &&
|
|
752
|
+
(!current.isSymbolicLink() ||
|
|
753
|
+
(await fs.readlink(snapshot.target)) !== snapshot.symlink)) ||
|
|
754
|
+
(snapshot.symlink === undefined &&
|
|
755
|
+
(!current.isFile() ||
|
|
756
|
+
(snapshot.mode !== undefined && current.mode !== snapshot.mode)))) {
|
|
757
|
+
throw new Error(`Archive rollback would overwrite a concurrent change at ${snapshot.target}.`);
|
|
758
|
+
}
|
|
759
|
+
// Re-read at rollback: only restore when current content matches what
|
|
760
|
+
// archive wrote or snapshotted; otherwise abort to preserve a concurrent
|
|
761
|
+
// change (CodeQL js/file-system-race is intentional here).
|
|
762
|
+
const currentContent = await fs.readFile(snapshot.target);
|
|
763
|
+
const originalContent = snapshot.symlink !== undefined && !snapshot.contentExisted
|
|
764
|
+
? undefined
|
|
765
|
+
: snapshot.content;
|
|
766
|
+
if (originalContent !== undefined &&
|
|
767
|
+
currentContent.equals(originalContent)) {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (snapshot.expectedContent === undefined ||
|
|
771
|
+
!currentContent.equals(snapshot.expectedContent)) {
|
|
772
|
+
throw new Error(`Archive rollback would overwrite a concurrent change at ${snapshot.target}.`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (!snapshot.existed) {
|
|
776
|
+
await fs.rm(snapshot.target, { force: true });
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (snapshot.symlink !== undefined) {
|
|
780
|
+
if (snapshot.outcome === 'retire') {
|
|
781
|
+
await fs.mkdir(path.dirname(snapshot.target), { recursive: true });
|
|
782
|
+
await fs.symlink(snapshot.symlink, snapshot.target);
|
|
783
|
+
}
|
|
784
|
+
else if (snapshot.contentExisted) {
|
|
785
|
+
await fs.writeFile(snapshot.target, snapshot.content);
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
const referent = path.resolve(path.dirname(snapshot.target), snapshot.symlink);
|
|
789
|
+
await fs.rm(referent, { force: true });
|
|
790
|
+
}
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (snapshot.content !== undefined) {
|
|
794
|
+
await fs.mkdir(path.dirname(snapshot.target), { recursive: true });
|
|
795
|
+
await fs.writeFile(snapshot.target, snapshot.content);
|
|
796
|
+
if (snapshot.mode !== undefined)
|
|
797
|
+
await fs.chmod(snapshot.target, snapshot.mode);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (errors.length > 0) {
|
|
805
|
+
throw new Error(errors.map(({ message }) => message).join(' '));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async function finalizeRetirementBackups(snapshots, mainSpecsDir) {
|
|
809
|
+
const errors = [];
|
|
810
|
+
for (const snapshot of snapshots) {
|
|
811
|
+
if (snapshot.outcome !== 'retire' || snapshot.displacedPath === undefined)
|
|
812
|
+
continue;
|
|
813
|
+
const displacedPath = snapshot.displacedPath;
|
|
814
|
+
try {
|
|
815
|
+
if (snapshot.displacedFingerprint === undefined ||
|
|
816
|
+
(await fingerprintMovablePath(displacedPath)) !== snapshot.displacedFingerprint) {
|
|
817
|
+
throw new Error('the displaced spec changed after retirement verification');
|
|
818
|
+
}
|
|
819
|
+
await finalizeRetiredSpec(snapshot.target, displacedPath, mainSpecsDir);
|
|
820
|
+
snapshot.displacedPath = undefined;
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
errors.push(`Could not remove the committed retirement backup at ${displacedPath} ` +
|
|
824
|
+
`(${error instanceof Error ? error.message : String(error)}).`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (errors.length > 0) {
|
|
828
|
+
throw new RetirementBackupsRetainedError(`${errors.join(' ')} The change remains archived and each listed backup was retained for recovery.`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
91
831
|
export class ArchiveCommand {
|
|
92
832
|
async execute(changeName, options = {}) {
|
|
93
833
|
const json = !!options.json;
|
|
@@ -138,34 +878,48 @@ export class ArchiveCommand {
|
|
|
138
878
|
const changesDir = root.changesDir;
|
|
139
879
|
const archiveDir = root.archiveDir;
|
|
140
880
|
const mainSpecsDir = root.specsDir;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
881
|
+
for (const [allowedDirectory, managedDir] of [
|
|
882
|
+
[root.path, changesDir],
|
|
883
|
+
[changesDir, archiveDir],
|
|
884
|
+
[root.path, mainSpecsDir],
|
|
885
|
+
]) {
|
|
886
|
+
try {
|
|
887
|
+
FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
throw new ArchiveBlockedError('archive_path_outside_root', `Refusing to archive through a path outside the OpenSpec root: ${managedDir}`);
|
|
891
|
+
}
|
|
147
892
|
}
|
|
148
893
|
// Get change name interactively if not provided
|
|
149
894
|
if (!changeName) {
|
|
150
895
|
if (json) {
|
|
151
896
|
throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: archive --json is non-interactive.', withStoreFlag(root, 'openspec archive <change-name> --json'));
|
|
152
897
|
}
|
|
153
|
-
const selectedChange = await this.selectChange(changesDir);
|
|
898
|
+
const selectedChange = await this.selectChange(changesDir, root, options);
|
|
154
899
|
if (!selectedChange) {
|
|
155
900
|
console.log('No change selected. Aborting.');
|
|
156
901
|
return null;
|
|
157
902
|
}
|
|
158
903
|
changeName = selectedChange;
|
|
159
904
|
}
|
|
905
|
+
const changeNameProblem = folderStyleNameProblem(changeName, 'Change name');
|
|
906
|
+
if (changeNameProblem) {
|
|
907
|
+
throw new ArchiveBlockedError('archive_change_name_invalid', changeNameProblem);
|
|
908
|
+
}
|
|
160
909
|
const changeDir = path.join(changesDir, changeName);
|
|
161
910
|
// Verify change exists
|
|
162
911
|
try {
|
|
163
|
-
const stat = await fs.
|
|
912
|
+
const stat = await fs.lstat(changeDir);
|
|
913
|
+
if (stat.isSymbolicLink()) {
|
|
914
|
+
throw new ArchiveBlockedError('archive_change_symlink', `Change '${changeName}' is a symbolic link. Replace it with a real directory before archiving.`);
|
|
915
|
+
}
|
|
164
916
|
if (!stat.isDirectory()) {
|
|
165
917
|
throw new Error(`Change '${changeName}' not found.`);
|
|
166
918
|
}
|
|
167
919
|
}
|
|
168
|
-
catch {
|
|
920
|
+
catch (error) {
|
|
921
|
+
if (error instanceof ArchiveBlockedError)
|
|
922
|
+
throw error;
|
|
169
923
|
const available = await listActiveChangeNames(changesDir);
|
|
170
924
|
throw new ArchiveBlockedError('archive_change_not_found', available.length > 0
|
|
171
925
|
? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
|
|
@@ -182,10 +936,21 @@ export class ArchiveCommand {
|
|
|
182
936
|
try {
|
|
183
937
|
await fs.access(changeFile);
|
|
184
938
|
const changeReport = await validator.validateChange(changeFile);
|
|
185
|
-
// Proposal validation is informative only (do not block archive)
|
|
186
|
-
|
|
939
|
+
// Proposal validation is informative only (do not block archive).
|
|
940
|
+
// `validateChange` parses the change together with its delta specs,
|
|
941
|
+
// so it also raises requirement-level issues under
|
|
942
|
+
// `deltas.<n>.requirement(s)`. Those
|
|
943
|
+
// are not proposal problems, and reporting them here was noisy and
|
|
944
|
+
// sometimes wrong (#498): the change parser records every requirement
|
|
945
|
+
// under both `requirement` and `requirements`, so each defect was
|
|
946
|
+
// printed twice, and REMOVED requirements — names-only by design —
|
|
947
|
+
// produced a "missing scenario" warning for a correct removal.
|
|
948
|
+
// Genuine delta defects are still caught below, by the delta spec
|
|
949
|
+
// validation and by the rebuilt-spec check that runs before any write.
|
|
950
|
+
const proposalIssues = changeReport.issues.filter((issue) => !/^deltas\.\d+\.requirements?\./.test(issue.path));
|
|
951
|
+
if (!changeReport.valid && proposalIssues.length > 0) {
|
|
187
952
|
console.log(chalk.yellow(`\nProposal warnings in proposal.md (non-blocking):`));
|
|
188
|
-
for (const issue of
|
|
953
|
+
for (const issue of proposalIssues) {
|
|
189
954
|
const symbol = issue.level === 'ERROR' ? '⚠' : (issue.level === 'WARNING' ? '⚠' : 'ℹ');
|
|
190
955
|
console.log(chalk.yellow(` ${symbol} ${issue.message}`));
|
|
191
956
|
}
|
|
@@ -197,26 +962,56 @@ export class ArchiveCommand {
|
|
|
197
962
|
}
|
|
198
963
|
// Validate delta-formatted spec files under the change directory if present
|
|
199
964
|
const changeSpecsDir = path.join(changeDir, 'specs');
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
965
|
+
// A spec.md at the specs/ root is never merged, so archiving a change
|
|
966
|
+
// that has one drops its content whether or not it carries delta headers
|
|
967
|
+
// (#1385). Its existence alone must run validation, which reports it and
|
|
968
|
+
// blocks the archive. A directory named spec.md is a normal capability
|
|
969
|
+
// folder, so only a regular file counts.
|
|
970
|
+
const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
|
|
971
|
+
let hasDeltaSpecs = rootSpecStat?.isFile() === true;
|
|
972
|
+
// A change that declares skip_specs must not carry any file under
|
|
973
|
+
// specs/ — validate reports that as a conflict, so archive has to run
|
|
974
|
+
// the same check instead of skipping validation because the files
|
|
975
|
+
// happen to have no delta headers. A marker that cannot be honored
|
|
976
|
+
// (skip_specs mentioned but the metadata fails the shared shape, or
|
|
977
|
+
// names a schema that does not resolve) also
|
|
978
|
+
// forces validation, so archive and validate always agree about the
|
|
979
|
+
// marker. Unreadable specs/ fails closed into validation too. (An
|
|
980
|
+
// UNMARKED zero-delta change still archives with only non-blocking
|
|
981
|
+
// proposal warnings — a gap that predates the marker and is left
|
|
982
|
+
// unchanged here.)
|
|
983
|
+
if (!hasDeltaSpecs) {
|
|
984
|
+
const marker = readSkipSpecsMarker(changeDir);
|
|
985
|
+
if (marker.invalidReason) {
|
|
986
|
+
hasDeltaSpecs = true;
|
|
987
|
+
}
|
|
988
|
+
else if (marker.declared) {
|
|
989
|
+
let specsDirHasFiles = true;
|
|
990
|
+
try {
|
|
991
|
+
specsDirHasFiles = await hasAnyFileUnder(changeSpecsDir);
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
// fall through with true: let validation surface the conflict
|
|
995
|
+
}
|
|
996
|
+
hasDeltaSpecs = specsDirHasFiles;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
for (const { specFile } of hasDeltaSpecs ? [] : await discoverSpecFiles(changeSpecsDir)) {
|
|
1000
|
+
try {
|
|
1001
|
+
const content = await fs.readFile(specFile, 'utf-8');
|
|
1002
|
+
// Case-insensitive to match the delta parser, so a lowercase header
|
|
1003
|
+
// routes through the same delta validation that validate runs.
|
|
1004
|
+
if (/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements/im.test(content)) {
|
|
1005
|
+
hasDeltaSpecs = true;
|
|
1006
|
+
break;
|
|
215
1007
|
}
|
|
216
1008
|
}
|
|
1009
|
+
catch { }
|
|
217
1010
|
}
|
|
218
|
-
catch { }
|
|
219
1011
|
if (hasDeltaSpecs) {
|
|
1012
|
+
// No mainSpecsDir here on purpose: the scenario-loss check standalone
|
|
1013
|
+
// validate runs (#1477) is the same one buildUpdatedSpec enforces a few
|
|
1014
|
+
// steps later, and reporting it here would relabel that failure.
|
|
220
1015
|
const deltaReport = await validator.validateChangeDeltaSpecs(changeDir);
|
|
221
1016
|
if (!deltaReport.valid) {
|
|
222
1017
|
hasValidationErrors = true;
|
|
@@ -239,6 +1034,7 @@ export class ArchiveCommand {
|
|
|
239
1034
|
}
|
|
240
1035
|
console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.'));
|
|
241
1036
|
console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.'));
|
|
1037
|
+
process.exitCode = 1;
|
|
242
1038
|
return null;
|
|
243
1039
|
}
|
|
244
1040
|
}
|
|
@@ -251,11 +1047,10 @@ export class ArchiveCommand {
|
|
|
251
1047
|
// Log warning when validation is skipped
|
|
252
1048
|
const timestamp = new Date().toISOString();
|
|
253
1049
|
if (!options.yes) {
|
|
254
|
-
const
|
|
255
|
-
const proceed = await confirm({
|
|
1050
|
+
const proceed = await confirmOrBlock({
|
|
256
1051
|
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
|
|
257
1052
|
default: false
|
|
258
|
-
});
|
|
1053
|
+
}, () => new ArchiveBlockedError('archive_confirmation_required', 'Skipping validation requires confirmation, and no answer could be read from stdin.', rerunCommand(root, changeName, options)));
|
|
259
1054
|
if (!proceed) {
|
|
260
1055
|
console.log('Archive cancelled.');
|
|
261
1056
|
return null;
|
|
@@ -268,7 +1063,7 @@ export class ArchiveCommand {
|
|
|
268
1063
|
console.log(chalk.yellow(`Affected files: ${changeDir}`));
|
|
269
1064
|
}
|
|
270
1065
|
// Show progress and check for incomplete tasks
|
|
271
|
-
const progress = await getTaskProgressForChange(changesDir, changeName);
|
|
1066
|
+
const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..'));
|
|
272
1067
|
if (!json) {
|
|
273
1068
|
const status = formatTaskStatus(progress);
|
|
274
1069
|
console.log(`Task status: ${status}`);
|
|
@@ -281,11 +1076,10 @@ export class ArchiveCommand {
|
|
|
281
1076
|
}
|
|
282
1077
|
}
|
|
283
1078
|
else if (!options.yes) {
|
|
284
|
-
const
|
|
285
|
-
const proceed = await confirm({
|
|
1079
|
+
const proceed = await confirmOrBlock({
|
|
286
1080
|
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
|
|
287
1081
|
default: false
|
|
288
|
-
});
|
|
1082
|
+
}, () => new ArchiveBlockedError('archive_tasks_incomplete', `${incompleteTasks} incomplete task(s) found for change '${describeChangeName(changeName)}', and no answer could be read from stdin.`, `Complete the tasks or rerun with ${rerunCommand(root, changeName, options)}`));
|
|
289
1083
|
if (!proceed) {
|
|
290
1084
|
console.log('Archive cancelled.');
|
|
291
1085
|
return null;
|
|
@@ -295,159 +1089,520 @@ export class ArchiveCommand {
|
|
|
295
1089
|
console.log(`Warning: ${incompleteTasks} incomplete task(s) found. Continuing due to --yes flag.`);
|
|
296
1090
|
}
|
|
297
1091
|
}
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1092
|
+
// Settle the archive destination BEFORE touching any spec. The name depends
|
|
1093
|
+
// only on the change, and a collision is routine (archiving twice in a day,
|
|
1094
|
+
// a restored change), so discovering it after the merge would leave specs
|
|
1095
|
+
// rewritten - or a capability retired - for an archive that never happened.
|
|
1096
|
+
//
|
|
1097
|
+
// Names that already carry a date prefix keep it: re-prefixing would stutter
|
|
1098
|
+
// the name, and when the archive runs on a later day the folder would sort
|
|
1099
|
+
// under a day on which the change did not happen (#1309).
|
|
1100
|
+
const archiveName = ARCHIVE_DATE_PREFIX_PATTERN.test(changeName)
|
|
1101
|
+
? changeName
|
|
1102
|
+
: `${formatLocalDate()}-${changeName}`;
|
|
1103
|
+
const archivePath = path.join(archiveDir, archiveName);
|
|
1104
|
+
// Read once, before any spec is touched: whether this change is allowed to
|
|
1105
|
+
// retire a capability at all. An unhonorable marker counts as undeclared,
|
|
1106
|
+
// exactly as skip_specs treats one, so metadata the rest of the CLI rejects
|
|
1107
|
+
// can never authorise a deletion.
|
|
1108
|
+
const retirementMarker = readRetireCapabilitiesMarker(changeDir);
|
|
1109
|
+
const retirementDeclared = retirementMarker.declared;
|
|
1110
|
+
const retirementAuthorizationFingerprint = retirementDeclared
|
|
1111
|
+
? await fingerprintPortableContent(path.join(changeDir, METADATA_FILENAME))
|
|
1112
|
+
: undefined;
|
|
1113
|
+
await assertArchiveDestinationAvailable(archivePath, archiveName);
|
|
1114
|
+
await fs.mkdir(archiveDir, { recursive: true });
|
|
1115
|
+
const claimPath = archiveClaimPath(archivePath, archiveName);
|
|
1116
|
+
let archiveClaim;
|
|
1117
|
+
try {
|
|
1118
|
+
// Handle spec updates unless skipSpecs flag is set
|
|
1119
|
+
let specsUpdated = false;
|
|
1120
|
+
let totals;
|
|
1121
|
+
const specWarnings = [];
|
|
1122
|
+
let changeArchived = false;
|
|
1123
|
+
if (options.skipSpecs) {
|
|
311
1124
|
if (!json) {
|
|
312
|
-
console.log('
|
|
313
|
-
for (const update of specUpdates) {
|
|
314
|
-
const status = update.exists ? 'update' : 'create';
|
|
315
|
-
const capability = path.basename(path.dirname(update.target));
|
|
316
|
-
console.log(` ${capability}: ${status}`);
|
|
317
|
-
}
|
|
1125
|
+
console.log('Skipping spec updates (--skip-specs flag provided).');
|
|
318
1126
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
// Find specs to update
|
|
1130
|
+
const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
1131
|
+
if (specUpdates.length > 0) {
|
|
1132
|
+
if (!json) {
|
|
1133
|
+
console.log('\nSpecs to update:');
|
|
1134
|
+
for (const update of specUpdates) {
|
|
1135
|
+
const status = update.exists ? 'update' : 'create';
|
|
1136
|
+
const capability = update.id;
|
|
1137
|
+
console.log(` ${capability}: ${status}`);
|
|
1138
|
+
}
|
|
331
1139
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
1140
|
+
// Build the proposed updates before asking permission to apply them.
|
|
1141
|
+
// buildUpdatedSpec also reports content that the merge would drop, so
|
|
1142
|
+
// the confirmation must come after this preview.
|
|
335
1143
|
const prepared = [];
|
|
1144
|
+
let prepareError;
|
|
336
1145
|
try {
|
|
337
1146
|
for (const update of specUpdates) {
|
|
338
|
-
const
|
|
339
|
-
|
|
1147
|
+
const sourceBeforeBuild = await fingerprintPath(update.source);
|
|
1148
|
+
const targetBeforeBuild = await fingerprintPath(update.target);
|
|
1149
|
+
const built = await buildUpdatedSpec(update, changeName, { silent: true });
|
|
1150
|
+
const sourceAfterBuild = await fingerprintPath(update.source);
|
|
1151
|
+
const targetAfterBuild = await fingerprintPath(update.target);
|
|
1152
|
+
if (sourceBeforeBuild !== sourceAfterBuild ||
|
|
1153
|
+
targetBeforeBuild !== targetAfterBuild) {
|
|
1154
|
+
throw new Error(`Spec inputs for '${update.id}' changed while archive was preparing the preview.`);
|
|
1155
|
+
}
|
|
1156
|
+
prepared.push({
|
|
1157
|
+
update,
|
|
1158
|
+
rebuilt: built.rebuilt,
|
|
1159
|
+
counts: built.counts,
|
|
1160
|
+
outcome: await decideSpecOutcome(update, built, skipValidation, retirementDeclared),
|
|
1161
|
+
noRequirementBlocks: built.noRequirementBlocks,
|
|
1162
|
+
unaccountedContent: built.unaccountedContent,
|
|
1163
|
+
sourceFingerprint: sourceAfterBuild,
|
|
1164
|
+
sourceContentFingerprint: await fingerprintPortableContent(update.source),
|
|
1165
|
+
targetFingerprint: targetAfterBuild,
|
|
1166
|
+
targetMovableFingerprint: await fingerprintMovablePath(update.target),
|
|
1167
|
+
});
|
|
1168
|
+
specWarnings.push(...built.warnings);
|
|
340
1169
|
}
|
|
341
1170
|
}
|
|
342
1171
|
catch (err) {
|
|
1172
|
+
// A user may still decline spec updates and archive the change, as
|
|
1173
|
+
// before this preview existed. Defer the error until they accept.
|
|
1174
|
+
prepareError = err;
|
|
1175
|
+
}
|
|
1176
|
+
if (prepareError === undefined && !json) {
|
|
1177
|
+
for (const warning of specWarnings) {
|
|
1178
|
+
console.log(chalk.yellow(`⚠️ Warning: ${warning}`));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
let shouldUpdateSpecs = true;
|
|
1182
|
+
if (!options.yes) {
|
|
343
1183
|
if (json) {
|
|
344
|
-
throw new ArchiveBlockedError('
|
|
1184
|
+
throw new ArchiveBlockedError('archive_confirmation_required', `Updating ${specUpdates.length} spec(s) requires confirmation: rerun with --yes.`, withStoreFlag(root, 'openspec archive <change-name> --json --yes'));
|
|
1185
|
+
}
|
|
1186
|
+
shouldUpdateSpecs = await confirmOrBlock({
|
|
1187
|
+
message: 'Proceed with spec updates?',
|
|
1188
|
+
default: true
|
|
1189
|
+
}, () => new ArchiveBlockedError('archive_confirmation_required', `Updating ${specUpdates.length} spec(s) requires confirmation, and no answer could be read from stdin.`, rerunCommand(root, changeName, options)));
|
|
1190
|
+
if (!shouldUpdateSpecs) {
|
|
1191
|
+
console.log('Skipping spec updates. Proceeding with archive.');
|
|
345
1192
|
}
|
|
346
|
-
console.log(String(err.message || err));
|
|
347
|
-
console.log('Aborted. No files were changed.');
|
|
348
|
-
return null;
|
|
349
1193
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1194
|
+
if (shouldUpdateSpecs) {
|
|
1195
|
+
// The confirmation may stay open while another editor changes a main
|
|
1196
|
+
// spec. Never apply the proposal built before the prompt to a newer
|
|
1197
|
+
// baseline: in particular, a stale retirement decision must not
|
|
1198
|
+
// delete a requirement added while the prompt was waiting.
|
|
1199
|
+
if (prepareError === undefined) {
|
|
1200
|
+
try {
|
|
1201
|
+
const currentRetirementMarker = readRetireCapabilitiesMarker(changeDir);
|
|
1202
|
+
if (currentRetirementMarker.declared !== retirementMarker.declared ||
|
|
1203
|
+
currentRetirementMarker.invalidReason !== retirementMarker.invalidReason) {
|
|
1204
|
+
throw new Error(`The ${METADATA_FILENAME} retirement authorization changed while archive was awaiting confirmation.`);
|
|
1205
|
+
}
|
|
1206
|
+
const currentUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
1207
|
+
const currentById = new Map(currentUpdates.map((update) => [update.id, update]));
|
|
1208
|
+
if (currentUpdates.length !== prepared.length) {
|
|
1209
|
+
throw new Error('The change specs changed while archive was awaiting confirmation.');
|
|
359
1210
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
1211
|
+
for (const proposed of prepared) {
|
|
1212
|
+
const current = currentById.get(proposed.update.id);
|
|
1213
|
+
if (!current) {
|
|
1214
|
+
throw new Error(`The delta for '${proposed.update.id}' changed while archive was awaiting confirmation.`);
|
|
1215
|
+
}
|
|
1216
|
+
if ((await fingerprintPath(current.source)) !== proposed.sourceFingerprint ||
|
|
1217
|
+
(await fingerprintPath(current.target)) !== proposed.targetFingerprint) {
|
|
1218
|
+
throw new Error(`Spec inputs for '${proposed.update.id}' changed while archive was awaiting confirmation. ` +
|
|
1219
|
+
'No files were changed; review the new content and rerun.');
|
|
1220
|
+
}
|
|
1221
|
+
const rebuilt = await buildUpdatedSpec(current, changeName, { silent: true });
|
|
1222
|
+
const outcome = await decideSpecOutcome(current, rebuilt, skipValidation, retirementDeclared);
|
|
1223
|
+
if (current.exists !== proposed.update.exists ||
|
|
1224
|
+
rebuilt.rebuilt !== proposed.rebuilt ||
|
|
1225
|
+
JSON.stringify(rebuilt.counts) !== JSON.stringify(proposed.counts) ||
|
|
1226
|
+
outcome !== proposed.outcome) {
|
|
1227
|
+
throw new Error(`Main spec '${proposed.update.id}' changed while archive was awaiting confirmation. ` +
|
|
1228
|
+
'No files were changed; review the new content and rerun.');
|
|
1229
|
+
}
|
|
366
1230
|
}
|
|
367
|
-
|
|
368
|
-
|
|
1231
|
+
}
|
|
1232
|
+
catch (error) {
|
|
1233
|
+
prepareError = error;
|
|
369
1234
|
}
|
|
370
1235
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1236
|
+
if (prepareError !== undefined) {
|
|
1237
|
+
const message = prepareError instanceof Error ? prepareError.message : String(prepareError);
|
|
1238
|
+
if (json) {
|
|
1239
|
+
throw new ArchiveBlockedError('archive_spec_update_failed', message, 'Fix the change delta specs and rerun. No files were changed.');
|
|
1240
|
+
}
|
|
1241
|
+
console.log(message);
|
|
1242
|
+
console.log('Aborted. No files were changed.');
|
|
1243
|
+
process.exitCode = 1;
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
// Validate every rebuilt spec before writing any of them, so a
|
|
1247
|
+
// late validation failure really does leave all targets unchanged.
|
|
1248
|
+
if (!skipValidation) {
|
|
1249
|
+
for (const p of prepared) {
|
|
1250
|
+
// A retirement was already put to the validator, and failed on
|
|
1251
|
+
// nothing but "no requirements" - there is no spec left to write,
|
|
1252
|
+
// so re-reporting that one error would just abort the fix (#1302).
|
|
1253
|
+
if (p.outcome !== 'write')
|
|
1254
|
+
continue;
|
|
1255
|
+
const specName = p.update.id;
|
|
1256
|
+
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
|
1257
|
+
if (!report.valid) {
|
|
1258
|
+
// The dead end #1302 describes: the rebuilt spec is unwritable
|
|
1259
|
+
// for exactly one reason, and retiring the capability is the
|
|
1260
|
+
// fix - but only the author can authorise deleting the spec, so
|
|
1261
|
+
// the abort names the marker instead of just rejecting. Says so
|
|
1262
|
+
// only when the marker is the ONLY thing missing, so it never
|
|
1263
|
+
// sends someone after a marker that would not have helped.
|
|
1264
|
+
const retirementWouldFix = !retirementDeclared &&
|
|
1265
|
+
p.update.exists &&
|
|
1266
|
+
p.counts.removed > 0 &&
|
|
1267
|
+
(await isRetirementCandidate(p.update, p, false));
|
|
1268
|
+
const retirementHint = retirementWouldFix
|
|
1269
|
+
? `This change removes the last requirement '${specName}' has. To retire the` +
|
|
1270
|
+
` capability and delete its spec, add \`retire_capabilities: true\` to the` +
|
|
1271
|
+
` change's ${METADATA_FILENAME} (alongside its \`schema:\`, which that file` +
|
|
1272
|
+
` requires), then rerun.` +
|
|
1273
|
+
(retirementMarker.invalidReason
|
|
1274
|
+
? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
|
|
1275
|
+
: '')
|
|
1276
|
+
: undefined;
|
|
1277
|
+
// The marker was set and retirement was still refused. Saying
|
|
1278
|
+
// nothing left the author who did exactly what the docs asked
|
|
1279
|
+
// back in the original dead end with no signal that their
|
|
1280
|
+
// marker had been read at all.
|
|
1281
|
+
// The author asked for a retirement and got the bare
|
|
1282
|
+
// validation abort. Name the lines that stood in the way.
|
|
1283
|
+
const refusalReason = retirementDeclared &&
|
|
1284
|
+
p.unaccountedContent.length > 0 &&
|
|
1285
|
+
(await isRetirableSpec(specName, p.rebuilt))
|
|
1286
|
+
? `'${specName}' declares retire_capabilities, but the spec holds content the merge ` +
|
|
1287
|
+
`cannot safely account for and deleting the file would take with it: ` +
|
|
1288
|
+
`${p.unaccountedContent.slice(0, 3).map((line) => `"${line}"`).join(', ')}` +
|
|
1289
|
+
`${p.unaccountedContent.length > 3 ? `, and ${p.unaccountedContent.length - 3} more line(s)` : ''}. ` +
|
|
1290
|
+
'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand.'
|
|
1291
|
+
: undefined;
|
|
1292
|
+
if (json) {
|
|
1293
|
+
throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, refusalReason ??
|
|
1294
|
+
retirementHint ??
|
|
1295
|
+
`Run ${withStoreFlag(root, `openspec validate ${specName}`)} after fixing the change deltas.`);
|
|
1296
|
+
}
|
|
1297
|
+
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
|
|
1298
|
+
for (const issue of report.issues) {
|
|
1299
|
+
if (issue.level === 'ERROR')
|
|
1300
|
+
console.log(chalk.red(` ✗ ${issue.message}`));
|
|
1301
|
+
else if (issue.level === 'WARNING')
|
|
1302
|
+
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
|
1303
|
+
}
|
|
1304
|
+
if (retirementHint)
|
|
1305
|
+
console.log(chalk.yellow(` → ${retirementHint}`));
|
|
1306
|
+
if (refusalReason)
|
|
1307
|
+
console.log(chalk.yellow(` → ${refusalReason}`));
|
|
1308
|
+
console.log('Aborted. No files were changed.');
|
|
1309
|
+
process.exitCode = 1;
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
// A legitimate concurrent archive cannot pass the exclusive claim,
|
|
1315
|
+
// while this catches an external process that created the final
|
|
1316
|
+
// destination during a confirmation prompt. Check before the first
|
|
1317
|
+
// spec mutation so a collision never strands a write or retirement.
|
|
1318
|
+
await assertArchiveDestinationAvailable(archivePath, archiveName);
|
|
1319
|
+
archiveClaim = await claimArchiveDestination(archivePath, archiveName);
|
|
1320
|
+
await assertArchiveDestinationAvailable(archivePath, archiveName);
|
|
1321
|
+
const mutations = prepared
|
|
1322
|
+
.filter(({ outcome, counts }) => outcome === 'retire' ||
|
|
1323
|
+
(outcome === 'write' &&
|
|
1324
|
+
counts.added + counts.modified + counts.removed + counts.renamed > 0))
|
|
1325
|
+
.map(({ update, outcome, rebuilt }) => ({
|
|
1326
|
+
update,
|
|
1327
|
+
outcome: outcome,
|
|
1328
|
+
rebuilt,
|
|
1329
|
+
}));
|
|
1330
|
+
const hasRetirements = mutations.some(({ outcome }) => outcome === 'retire');
|
|
1331
|
+
await assertDistinctMutationTargets(mutations);
|
|
1332
|
+
for (const proposed of prepared) {
|
|
1333
|
+
if ((await fingerprintPath(proposed.update.source)) !== proposed.sourceFingerprint ||
|
|
1334
|
+
(await fingerprintPath(proposed.update.target)) !== proposed.targetFingerprint) {
|
|
1335
|
+
throw new Error(`Spec inputs for '${proposed.update.id}' changed before archive could apply them. ` +
|
|
1336
|
+
'No files were changed; review the new content and rerun.');
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const specSnapshots = await captureSpecSnapshots(mutations);
|
|
1340
|
+
const specSnapshotsByTarget = new Map(specSnapshots.map((snapshot) => [snapshot.target, snapshot]));
|
|
1341
|
+
const mutationAttempts = new Set();
|
|
1342
|
+
try {
|
|
1343
|
+
// All validations passed; write files and display counts
|
|
1344
|
+
const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0 };
|
|
1345
|
+
let wroteAny = false;
|
|
1346
|
+
for (const p of prepared) {
|
|
1347
|
+
// Deletions are deferred to the loop below.
|
|
1348
|
+
if (p.outcome !== 'write')
|
|
1349
|
+
continue;
|
|
1350
|
+
const { added, modified, removed, renamed } = p.counts;
|
|
1351
|
+
if (added + modified + removed + renamed === 0) {
|
|
1352
|
+
// Every operation was already synced: rewriting the file would
|
|
1353
|
+
// only churn normalization differences into it.
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
|
|
1357
|
+
silent: json,
|
|
1358
|
+
beforeMutate: async () => {
|
|
1359
|
+
if ((await fingerprintSpecInputs(p.update)) !==
|
|
1360
|
+
`${p.sourceFingerprint}\n${p.targetFingerprint}`) {
|
|
1361
|
+
throw new Error(`Spec inputs for '${p.update.id}' changed before archive could write them.`);
|
|
1362
|
+
}
|
|
1363
|
+
mutationAttempts.add(p.update.target);
|
|
1364
|
+
},
|
|
1365
|
+
// Cross-root paths must be absolute when a store is selected.
|
|
1366
|
+
...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}),
|
|
1367
|
+
});
|
|
1368
|
+
wroteAny = true;
|
|
1369
|
+
writeTotals.added += added;
|
|
1370
|
+
writeTotals.modified += modified;
|
|
1371
|
+
writeTotals.removed += removed;
|
|
1372
|
+
writeTotals.renamed += renamed;
|
|
1373
|
+
}
|
|
1374
|
+
// Retirements run only after every write has succeeded. If any
|
|
1375
|
+
// later mutation fails, the snapshots below restore every target.
|
|
1376
|
+
for (const p of prepared) {
|
|
1377
|
+
if (p.outcome !== 'retire')
|
|
1378
|
+
continue;
|
|
1379
|
+
const { retired, resolvedPath, displacedPath } = await retireSpec(p.update, mainSpecsDir, {
|
|
1380
|
+
silent: json,
|
|
1381
|
+
deferDelete: true,
|
|
1382
|
+
beforeMutate: async () => {
|
|
1383
|
+
if (retirementAuthorizationFingerprint === undefined) {
|
|
1384
|
+
throw new Error(`The ${METADATA_FILENAME} retirement authorization is unavailable.`);
|
|
1385
|
+
}
|
|
1386
|
+
await assertRetirementAuthorization(changeDir, retirementAuthorizationFingerprint);
|
|
1387
|
+
if ((await fingerprintSpecInputs(p.update)) !==
|
|
1388
|
+
`${p.sourceFingerprint}\n${p.targetFingerprint}`) {
|
|
1389
|
+
throw new Error(`Spec inputs for '${p.update.id}' changed before archive could retire them.`);
|
|
1390
|
+
}
|
|
1391
|
+
mutationAttempts.add(p.update.target);
|
|
1392
|
+
},
|
|
1393
|
+
verifyDisplaced: async (displacedPath) => {
|
|
1394
|
+
await assertRetirementAuthorization(changeDir, retirementAuthorizationFingerprint);
|
|
1395
|
+
if ((await fingerprintMovablePath(displacedPath)) !==
|
|
1396
|
+
p.targetMovableFingerprint) {
|
|
1397
|
+
throw new Error(`Main spec '${p.update.id}' changed while archive was securing it for retirement.`);
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
...(isStoreSelectedRoot(root) ? { displayPath: p.update.target } : {}),
|
|
1401
|
+
});
|
|
1402
|
+
if (!retired)
|
|
1403
|
+
continue;
|
|
1404
|
+
const retirementSnapshot = specSnapshotsByTarget.get(p.update.target);
|
|
1405
|
+
if (retirementSnapshot === undefined || displacedPath === undefined) {
|
|
1406
|
+
throw new Error(`Could not track the displaced main spec for '${p.update.id}' during retirement.`);
|
|
1407
|
+
}
|
|
1408
|
+
retirementSnapshot.displacedPath = displacedPath;
|
|
1409
|
+
retirementSnapshot.displacedFingerprint = p.targetMovableFingerprint;
|
|
1410
|
+
wroteAny = true;
|
|
1411
|
+
// A rename applied on the way to the retirement still happened;
|
|
1412
|
+
// folding every count in keeps the totals honest about the whole
|
|
1413
|
+
// delta.
|
|
1414
|
+
writeTotals.added += p.counts.added;
|
|
1415
|
+
writeTotals.modified += p.counts.modified;
|
|
1416
|
+
writeTotals.removed += p.counts.removed;
|
|
1417
|
+
writeTotals.renamed += p.counts.renamed;
|
|
1418
|
+
// Deleting a file is the one archive outcome a JSON consumer cannot
|
|
1419
|
+
// infer from the totals, so it is recorded the way every other
|
|
1420
|
+
// spec-merge divergence is. Purpose always goes with the file, so it
|
|
1421
|
+
// is named too rather than left to the reader to work out, and the
|
|
1422
|
+
// note carries the command that brings the file back.
|
|
1423
|
+
const lost = ['Purpose'];
|
|
1424
|
+
// Derived from the path that was unlinked, never rebuilt from the
|
|
1425
|
+
// capability id: on a case-insensitive filesystem the id and the
|
|
1426
|
+
// real directory can differ in case, and git is case-sensitive, so
|
|
1427
|
+
// an id-derived path is one git rejects.
|
|
1428
|
+
// `update.target` is built from the capability id, so on a
|
|
1429
|
+
// case-insensitive filesystem it can differ in case from the file
|
|
1430
|
+
// that was actually unlinked - and git is case-sensitive, so the
|
|
1431
|
+
// printed command is one git rejects. A capability directory
|
|
1432
|
+
// symlinked to a sibling has the same problem without leaving the
|
|
1433
|
+
// tree. `retiredPath` carries the resolved path, so it wins
|
|
1434
|
+
// whenever it disagrees, not only when it escapes.
|
|
1435
|
+
const unlinkedPath = resolvedPath ?? p.update.target;
|
|
1436
|
+
// Measured against the REAL root, so the platform's own
|
|
1437
|
+
// `/var` -> `/private/var` link does not read as an escape. A path
|
|
1438
|
+
// that genuinely sits outside stays absolute, which is what routes
|
|
1439
|
+
// it to prose guidance instead of a command git would reject.
|
|
1440
|
+
const realRoot = await fs.realpath(root.path).catch(() => root.path);
|
|
1441
|
+
const relativeToRoot = path.relative(realRoot, unlinkedPath);
|
|
1442
|
+
const insideRoot = relativeToRoot !== '' &&
|
|
1443
|
+
!relativeToRoot.startsWith('..') &&
|
|
1444
|
+
!path.isAbsolute(relativeToRoot);
|
|
1445
|
+
const deletedPath = isStoreSelectedRoot(root) || !insideRoot
|
|
1446
|
+
? unlinkedPath
|
|
1447
|
+
: relativeToRoot.split(path.sep).join('/');
|
|
1448
|
+
// A command is offered only when pasting it where archive was run
|
|
1449
|
+
// would actually work. An absolute path here means the file did not
|
|
1450
|
+
// live under that directory - a selected store, or a symlinked
|
|
1451
|
+
// capability directory - and `git checkout HEAD -- <abs>` is rejected
|
|
1452
|
+
// from a different worktree however it is quoted, so that case gets
|
|
1453
|
+
// guidance instead of a command that cannot run. A path with no
|
|
1454
|
+
// portable shell spelling is handled the same way.
|
|
1455
|
+
//
|
|
1456
|
+
// Conditional on purpose, too: whether the file is in `HEAD` is not
|
|
1457
|
+
// something archive knows - a spec an earlier archive CREATED and
|
|
1458
|
+
// nobody has committed yet is not - and promising recovery is the one
|
|
1459
|
+
// claim this feature must not get wrong.
|
|
1460
|
+
const pasteablePath = path.isAbsolute(deletedPath)
|
|
1461
|
+
? undefined
|
|
1462
|
+
: quoteForShell(`:(top)${deletedPath}`);
|
|
1463
|
+
const recovery = pasteablePath
|
|
1464
|
+
? `If it was committed, restore it with: git checkout HEAD -- ${pasteablePath}`
|
|
1465
|
+
: `It was deleted from ${deletedPath}; if it was committed, restore it from that checkout's history.`;
|
|
1466
|
+
const retirementNote = `${p.update.id} - capability retired; deleted the main spec (all requirements removed` +
|
|
1467
|
+
`, declared by retire_capabilities) at ${deletedPath}` +
|
|
1468
|
+
`. Its section(s) went with it: ${lost.join(', ')}. ` +
|
|
1469
|
+
recovery;
|
|
1470
|
+
specWarnings.push(retirementNote);
|
|
1471
|
+
// The "Retiring ..." line already told a human the file is gone; the
|
|
1472
|
+
// sections it took along, and how to get them back, are the parts
|
|
1473
|
+
// they cannot see from the path.
|
|
1474
|
+
if (!json) {
|
|
1475
|
+
console.log(` ${recovery}`);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
specsUpdated = wroteAny;
|
|
1479
|
+
totals = writeTotals;
|
|
1480
|
+
if (!json) {
|
|
1481
|
+
console.log(`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}`);
|
|
1482
|
+
console.log(wroteAny
|
|
1483
|
+
? 'Specs updated successfully.'
|
|
1484
|
+
: 'Specs already in sync; no files changed.');
|
|
1485
|
+
}
|
|
1486
|
+
for (const proposed of prepared) {
|
|
1487
|
+
if ((await fingerprintPath(proposed.update.source)) !==
|
|
1488
|
+
proposed.sourceFingerprint) {
|
|
1489
|
+
throw new Error(`The delta for '${proposed.update.id}' changed before the change could be archived.`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (hasRetirements) {
|
|
1493
|
+
await assertRetirementAuthorization(changeDir, retirementAuthorizationFingerprint);
|
|
1494
|
+
}
|
|
1495
|
+
const verifyArchivedDeltas = async (stagedSource) => {
|
|
1496
|
+
if (hasRetirements) {
|
|
1497
|
+
await assertRetirementAuthorization(archivePath, retirementAuthorizationFingerprint,
|
|
1498
|
+
// Archived changes are nested one level deeper than active
|
|
1499
|
+
// changes, so the marker reader cannot resolve their schema.
|
|
1500
|
+
// Exact content equality proves this is the authorization
|
|
1501
|
+
// already validated at the active path.
|
|
1502
|
+
{ verifyMarker: false });
|
|
1503
|
+
if (stagedSource) {
|
|
1504
|
+
await assertRetirementAuthorization(stagedSource, retirementAuthorizationFingerprint);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
for (const proposed of prepared) {
|
|
1508
|
+
const archivedSource = path.join(archivePath, path.relative(changeDir, proposed.update.source));
|
|
1509
|
+
if ((await fingerprintPortableContent(archivedSource)) !==
|
|
1510
|
+
proposed.sourceContentFingerprint) {
|
|
1511
|
+
throw new Error(`The archived delta for '${proposed.update.id}' changed during the final move.`);
|
|
1512
|
+
}
|
|
1513
|
+
if (stagedSource) {
|
|
1514
|
+
const stagedDelta = path.join(stagedSource, path.relative(changeDir, proposed.update.source));
|
|
1515
|
+
if ((await fingerprintPortableContent(stagedDelta)) !==
|
|
1516
|
+
proposed.sourceContentFingerprint) {
|
|
1517
|
+
throw new Error(`The active delta for '${proposed.update.id}' changed during the fallback copy.`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
await moveDirectory(changeDir, archivePath, {
|
|
1523
|
+
verifyCopiedDestination: verifyArchivedDeltas,
|
|
1524
|
+
});
|
|
1525
|
+
changeArchived = true;
|
|
1526
|
+
await verifyArchivedDeltas();
|
|
1527
|
+
await finalizeRetirementBackups(specSnapshots, mainSpecsDir);
|
|
1528
|
+
}
|
|
1529
|
+
catch (error) {
|
|
1530
|
+
if (error instanceof MoveDestinationRetainedError) {
|
|
1531
|
+
changeArchived = true;
|
|
1532
|
+
try {
|
|
1533
|
+
await finalizeRetirementBackups(specSnapshots, mainSpecsDir);
|
|
1534
|
+
}
|
|
1535
|
+
catch (cleanupError) {
|
|
1536
|
+
throw new RetirementBackupsRetainedError(`${error.message} ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
1537
|
+
}
|
|
1538
|
+
throw error;
|
|
1539
|
+
}
|
|
1540
|
+
if (error instanceof RetirementBackupsRetainedError)
|
|
1541
|
+
throw error;
|
|
1542
|
+
const rollbackErrors = [];
|
|
1543
|
+
try {
|
|
1544
|
+
await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)));
|
|
1545
|
+
}
|
|
1546
|
+
catch (rollbackError) {
|
|
1547
|
+
rollbackErrors.push(rollbackError instanceof Error
|
|
1548
|
+
? rollbackError
|
|
1549
|
+
: new Error(String(rollbackError)));
|
|
1550
|
+
}
|
|
1551
|
+
if (changeArchived) {
|
|
1552
|
+
try {
|
|
1553
|
+
await moveDirectory(archivePath, changeDir);
|
|
1554
|
+
changeArchived = false;
|
|
1555
|
+
}
|
|
1556
|
+
catch (rollbackError) {
|
|
1557
|
+
rollbackErrors.push(rollbackError instanceof Error
|
|
1558
|
+
? rollbackError
|
|
1559
|
+
: new Error(String(rollbackError)));
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (rollbackErrors.length > 0) {
|
|
1563
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
1564
|
+
throw new Error(`${original} Rollback also failed: ${rollbackErrors.map(({ message }) => message).join(' ')}`);
|
|
1565
|
+
}
|
|
1566
|
+
throw error;
|
|
1567
|
+
}
|
|
394
1568
|
}
|
|
395
1569
|
}
|
|
396
1570
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
throw error;
|
|
1571
|
+
// The destination was checked before the merge, so anything claiming it now
|
|
1572
|
+
// appeared while we were working. Report that as the collision it is: a raw
|
|
1573
|
+
// ENOTEMPTY from rename would otherwise degrade to a bare `archive_error`.
|
|
1574
|
+
if (!changeArchived) {
|
|
1575
|
+
await assertArchiveDestinationAvailable(archivePath, archiveName);
|
|
1576
|
+
archiveClaim = await claimArchiveDestination(archivePath, archiveName);
|
|
1577
|
+
await assertArchiveDestinationAvailable(archivePath, archiveName);
|
|
1578
|
+
// Create archive directory if needed
|
|
1579
|
+
await fs.mkdir(archiveDir, { recursive: true });
|
|
1580
|
+
// Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)
|
|
1581
|
+
await moveDirectory(changeDir, archivePath);
|
|
1582
|
+
changeArchived = true;
|
|
410
1583
|
}
|
|
1584
|
+
if (!json) {
|
|
1585
|
+
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
|
|
1586
|
+
}
|
|
1587
|
+
// Track change archived event
|
|
1588
|
+
await trackChangeArchived(changeName);
|
|
1589
|
+
return {
|
|
1590
|
+
change: changeName,
|
|
1591
|
+
archivedAs: archiveName,
|
|
1592
|
+
path: archivePath,
|
|
1593
|
+
specsUpdated,
|
|
1594
|
+
...(totals ? { totals } : {}),
|
|
1595
|
+
...(specWarnings.length > 0 ? { warnings: specWarnings } : {}),
|
|
1596
|
+
};
|
|
411
1597
|
}
|
|
412
|
-
|
|
413
|
-
|
|
1598
|
+
finally {
|
|
1599
|
+
if (archiveClaim)
|
|
1600
|
+
await releaseArchiveClaim(archiveClaim, claimPath).catch(() => undefined);
|
|
414
1601
|
}
|
|
415
|
-
// Create archive directory if needed
|
|
416
|
-
await fs.mkdir(archiveDir, { recursive: true });
|
|
417
|
-
// Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)
|
|
418
|
-
await moveDirectory(changeDir, archivePath);
|
|
419
|
-
const metadata = readChangeMetadata(archivePath, root.path);
|
|
420
|
-
const schema = metadata?.schema ?? root.defaultSchema;
|
|
421
|
-
const specDeltas = await buildSpecDeltasFromUpdates(archivedSpecDeltas);
|
|
422
|
-
await trackChangeArchived({
|
|
423
|
-
changeDir: archivePath,
|
|
424
|
-
changeName: changeName,
|
|
425
|
-
schema,
|
|
426
|
-
specsUpdated,
|
|
427
|
-
totals,
|
|
428
|
-
tasksComplete: incompleteTasks === 0,
|
|
429
|
-
specDeltas,
|
|
430
|
-
projectRoot: root.path,
|
|
431
|
-
});
|
|
432
|
-
if (!json) {
|
|
433
|
-
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
|
|
434
|
-
}
|
|
435
|
-
return {
|
|
436
|
-
change: changeName,
|
|
437
|
-
archivedAs: archiveName,
|
|
438
|
-
path: archivePath,
|
|
439
|
-
specsUpdated,
|
|
440
|
-
...(totals ? { totals } : {}),
|
|
441
|
-
};
|
|
442
1602
|
}
|
|
443
|
-
async selectChange(changesDir) {
|
|
1603
|
+
async selectChange(changesDir, root, options) {
|
|
444
1604
|
const { select } = await import('@inquirer/prompts');
|
|
445
|
-
|
|
446
|
-
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
447
|
-
const changeDirs = entries
|
|
448
|
-
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
|
|
449
|
-
.map(entry => entry.name)
|
|
450
|
-
.sort();
|
|
1605
|
+
const changeDirs = await listActiveChangeNames(changesDir);
|
|
451
1606
|
if (changeDirs.length === 0) {
|
|
452
1607
|
console.log('No active changes found.');
|
|
453
1608
|
return null;
|
|
@@ -457,7 +1612,7 @@ export class ArchiveCommand {
|
|
|
457
1612
|
try {
|
|
458
1613
|
const progressList = [];
|
|
459
1614
|
for (const id of changeDirs) {
|
|
460
|
-
const progress = await getTaskProgressForChange(changesDir, id);
|
|
1615
|
+
const progress = await getTaskProgressForChange(changesDir, id, path.resolve(changesDir, '..', '..'));
|
|
461
1616
|
const status = formatTaskStatus(progress);
|
|
462
1617
|
progressList.push({ id, status });
|
|
463
1618
|
}
|
|
@@ -479,13 +1634,18 @@ export class ArchiveCommand {
|
|
|
479
1634
|
return answer;
|
|
480
1635
|
}
|
|
481
1636
|
catch (error) {
|
|
1637
|
+
// Nobody to pick from the list: reporting "No change selected" and
|
|
1638
|
+
// exiting 0 told an agent the archive had succeeded when nothing
|
|
1639
|
+
// happened (#1479). The suggested rerun carries --yes because the same
|
|
1640
|
+
// caller cannot answer the confirmations further down either, and the
|
|
1641
|
+
// caller's own flags because dropping --skip-specs here would suggest a
|
|
1642
|
+
// rerun that merges the specs it was passed to leave alone.
|
|
1643
|
+
if (isNonInteractivePromptError(error)) {
|
|
1644
|
+
throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: no answer could be read from stdin.', withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`));
|
|
1645
|
+
}
|
|
482
1646
|
// User cancelled (Ctrl+C)
|
|
483
1647
|
return null;
|
|
484
1648
|
}
|
|
485
1649
|
}
|
|
486
|
-
getArchiveDate() {
|
|
487
|
-
// Returns date in YYYY-MM-DD format
|
|
488
|
-
return new Date().toISOString().split('T')[0];
|
|
489
|
-
}
|
|
490
1650
|
}
|
|
491
1651
|
//# sourceMappingURL=archive.js.map
|