@fission-ai/openspec 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/dist/cli/index.js +79 -8
- package/dist/commands/change.d.ts +0 -1
- package/dist/commands/change.js +105 -51
- package/dist/commands/config.js +14 -8
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/feedback.js +74 -23
- package/dist/commands/schema.js +110 -31
- package/dist/commands/show.js +5 -2
- package/dist/commands/spec.js +49 -29
- package/dist/commands/validate.js +20 -5
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.js +121 -33
- package/dist/commands/workflow/shared.d.ts +13 -2
- package/dist/commands/workflow/shared.js +34 -4
- package/dist/commands/workflow/status.js +9 -4
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -1
- package/dist/core/archive.js +1332 -155
- package/dist/core/artifact-graph/graph.d.ts +16 -0
- package/dist/core/artifact-graph/graph.js +30 -6
- 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 +1 -1
- package/dist/core/change-status-policy.js +2 -2
- package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
- package/dist/core/command-generation/adapters/amazon-q.js +7 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -1
- package/dist/core/command-generation/adapters/auggie.js +2 -1
- package/dist/core/command-generation/adapters/bob.d.ts +5 -1
- package/dist/core/command-generation/adapters/bob.js +6 -5
- package/dist/core/command-generation/adapters/claude.js +1 -8
- package/dist/core/command-generation/adapters/codebuddy.js +3 -2
- package/dist/core/command-generation/adapters/continue.js +3 -2
- package/dist/core/command-generation/adapters/costrict.js +2 -1
- package/dist/core/command-generation/adapters/crush.js +5 -5
- package/dist/core/command-generation/adapters/cursor.js +2 -2
- 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 +2 -1
- package/dist/core/command-generation/adapters/gemini.js +37 -2
- package/dist/core/command-generation/adapters/github-copilot.js +2 -1
- package/dist/core/command-generation/adapters/iflow.js +5 -4
- package/dist/core/command-generation/adapters/index.d.ts +2 -2
- package/dist/core/command-generation/adapters/index.js +2 -2
- package/dist/core/command-generation/adapters/junie.js +2 -1
- package/dist/core/command-generation/adapters/kiro.js +2 -1
- package/dist/core/command-generation/adapters/lingma.js +5 -5
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
- package/dist/core/command-generation/adapters/opencode.js +3 -5
- package/dist/core/command-generation/adapters/pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/pi.js +3 -6
- package/dist/core/command-generation/adapters/qoder.js +5 -5
- package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
- package/dist/core/command-generation/adapters/qwen.js +12 -7
- package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
- package/dist/core/command-generation/adapters/roocode.js +4 -4
- package/dist/core/command-generation/adapters/trae.js +1 -21
- 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 +4 -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 -2
- package/dist/core/completions/generators/powershell-generator.js +15 -8
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
- package/dist/core/completions/installers/zsh-installer.js +20 -4
- 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 +45 -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 +450 -69
- package/dist/core/legacy-cleanup.d.ts +59 -0
- package/dist/core/legacy-cleanup.js +253 -24
- package/dist/core/list.js +5 -7
- package/dist/core/migration.d.ts +73 -1
- package/dist/core/migration.js +360 -11
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/parsers/change-parser.d.ts +17 -2
- package/dist/core/parsers/change-parser.js +33 -20
- 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.js +2 -1
- package/dist/core/parsers/requirement-blocks.d.ts +18 -0
- package/dist/core/parsers/requirement-blocks.js +120 -35
- package/dist/core/parsers/requirement-text.d.ts +1 -8
- package/dist/core/parsers/requirement-text.js +4 -42
- 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 +48 -49
- package/dist/core/project-config.d.ts +28 -6
- package/dist/core/project-config.js +108 -8
- 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/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.d.ts +25 -5
- package/dist/core/shared/tool-detection.js +166 -36
- 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 +659 -171
- package/dist/core/store/git.d.ts +13 -0
- package/dist/core/store/git.js +24 -0
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +48 -158
- package/dist/core/templates/workflows/archive-change.js +170 -42
- package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
- package/dist/core/templates/workflows/continue-change.js +38 -46
- package/dist/core/templates/workflows/explore.js +54 -18
- package/dist/core/templates/workflows/ff-change.js +46 -30
- package/dist/core/templates/workflows/new-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +13 -7
- package/dist/core/templates/workflows/propose.js +116 -46
- 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 +260 -32
- package/dist/core/templates/workflows/update-change.js +26 -16
- package/dist/core/templates/workflows/verify-change.js +16 -10
- package/dist/core/update.d.ts +40 -5
- package/dist/core/update.js +389 -93
- package/dist/core/validation/constants.d.ts +4 -1
- package/dist/core/validation/constants.js +4 -1
- package/dist/core/validation/validator.d.ts +22 -8
- package/dist/core/validation/validator.js +222 -50
- package/dist/core/version-check.d.ts +152 -0
- package/dist/core/version-check.js +731 -0
- package/dist/core/view.js +12 -17
- package/dist/prompts/searchable-multi-select.js +1 -1
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +10 -5
- package/dist/telemetry/index.js +75 -41
- 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 +17 -0
- package/dist/utils/task-progress.js +41 -13
- package/package.json +14 -12
- package/schemas/spec-driven/schema.yaml +67 -10
- package/schemas/spec-driven/templates/design.md +2 -2
- package/schemas/spec-driven/templates/proposal.md +10 -4
- package/schemas/spec-driven/templates/spec.md +3 -0
- 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/README.md
CHANGED
|
@@ -76,6 +76,29 @@ AI: Archived to openspec/changes/archive/2025-01-23-add-dark-mode/
|
|
|
76
76
|
Specs updated. Ready for the next feature.
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
<details>
|
|
80
|
+
<summary><strong>What do the specs actually look like?</strong></summary>
|
|
81
|
+
|
|
82
|
+
Plain Markdown — requirements with concrete scenarios, no special syntax to learn. Here's what goes in the `specs/` folder created above:
|
|
83
|
+
|
|
84
|
+
```markdown
|
|
85
|
+
## ADDED Requirements
|
|
86
|
+
|
|
87
|
+
### Requirement: Theme selection
|
|
88
|
+
The app SHALL let users switch between light and dark themes,
|
|
89
|
+
defaulting to the system preference.
|
|
90
|
+
|
|
91
|
+
#### Scenario: User toggles dark mode
|
|
92
|
+
- **WHEN** the user clicks the theme toggle
|
|
93
|
+
- **THEN** the app switches to dark mode and persists the choice
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Your AI writes these; you review the plan before any code is written.
|
|
97
|
+
|
|
98
|
+
OpenSpec is built with OpenSpec — browse this repo's live [specs](openspec/specs) and in-flight [changes](openspec/changes) for real examples at scale.
|
|
99
|
+
|
|
100
|
+
</details>
|
|
101
|
+
|
|
79
102
|
<details>
|
|
80
103
|
<summary><strong>OpenSpec Dashboard</strong></summary>
|
|
81
104
|
|
|
@@ -114,6 +137,8 @@ cd your-project
|
|
|
114
137
|
openspec init
|
|
115
138
|
```
|
|
116
139
|
|
|
140
|
+
> **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
|
|
141
|
+
|
|
117
142
|
Now talk to your AI:
|
|
118
143
|
|
|
119
144
|
- **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before anything is written. ([Explore guide](docs/explore.md))
|
|
@@ -121,8 +146,10 @@ Now talk to your AI:
|
|
|
121
146
|
|
|
122
147
|
Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
|
|
123
148
|
|
|
149
|
+
`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke).
|
|
150
|
+
|
|
124
151
|
> [!NOTE]
|
|
125
|
-
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support
|
|
152
|
+
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
|
|
126
153
|
>
|
|
127
154
|
> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
|
|
128
155
|
|
|
@@ -221,7 +248,9 @@ OpenSpec collects anonymous usage stats.
|
|
|
221
248
|
|
|
222
249
|
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
|
|
223
250
|
|
|
224
|
-
**Opt-out
|
|
251
|
+
**Opt-out (any one is enough):**
|
|
252
|
+
- `openspec config set telemetry.enabled false` (global config; unset means on)
|
|
253
|
+
- `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config)
|
|
225
254
|
|
|
226
255
|
</details>
|
|
227
256
|
|
package/dist/cli/index.js
CHANGED
|
@@ -5,8 +5,9 @@ import ora from 'ora';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { promises as fs } from 'fs';
|
|
8
|
-
import { AI_TOOLS } from '../core/config.js';
|
|
8
|
+
import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
|
|
9
9
|
import { UpdateCommand } from '../core/update.js';
|
|
10
|
+
import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, } from '../core/version-check.js';
|
|
10
11
|
import { ListCommand } from '../core/list.js';
|
|
11
12
|
import { ArchiveCommand } from '../core/archive.js';
|
|
12
13
|
import { ViewCommand } from '../core/view.js';
|
|
@@ -23,9 +24,10 @@ import { registerStoreCommand } from '../commands/store.js';
|
|
|
23
24
|
import { registerDoctorCommand } from '../commands/doctor.js';
|
|
24
25
|
import { registerContextCommand } from '../commands/context.js';
|
|
25
26
|
import { registerWorksetCommand } from '../commands/workset.js';
|
|
26
|
-
import { statusCommand, instructionsCommand, applyInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
|
|
27
|
+
import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
|
|
27
28
|
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
|
28
29
|
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
|
30
|
+
import { isInteractive } from '../utils/interactive.js';
|
|
29
31
|
const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
|
|
30
32
|
// Deliberate rejection path: --store-path stays registered (hidden) so the
|
|
31
33
|
// resolver can explain that registering the path is the supported route,
|
|
@@ -95,14 +97,22 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
95
97
|
program.hook('postAction', async () => {
|
|
96
98
|
await shutdown();
|
|
97
99
|
});
|
|
98
|
-
const availableToolIds = AI_TOOLS
|
|
99
|
-
|
|
100
|
+
const availableToolIds = AI_TOOLS
|
|
101
|
+
.filter((tool) => tool.skillsDir || tool.globalSkillsDir)
|
|
102
|
+
.map((tool) => tool.value);
|
|
103
|
+
const toolAliasNote = Object.entries(TOOL_ID_ALIASES)
|
|
104
|
+
.map(([retired, current]) => `${retired} (now ${current})`)
|
|
105
|
+
.join(', ');
|
|
106
|
+
const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}. Also accepted: ${toolAliasNote}`;
|
|
100
107
|
program
|
|
101
108
|
.command('init [path]')
|
|
102
109
|
.description('Initialize OpenSpec in your project')
|
|
103
110
|
.option('--tools <tools>', toolsOptionDescription)
|
|
104
111
|
.option('--force', 'Auto-cleanup legacy files without prompting')
|
|
105
112
|
.option('--profile <profile>', 'Override global config profile (core or custom)')
|
|
113
|
+
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
|
|
114
|
+
.option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting')
|
|
115
|
+
.option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting')
|
|
106
116
|
.action(async (targetPath = '.', options) => {
|
|
107
117
|
try {
|
|
108
118
|
// Validate that the path is a valid directory
|
|
@@ -130,6 +140,8 @@ program
|
|
|
130
140
|
tools: options?.tools,
|
|
131
141
|
force: options?.force,
|
|
132
142
|
profile: options?.profile,
|
|
143
|
+
animation: options?.animation,
|
|
144
|
+
copilotCloud: options?.copilotCloud,
|
|
133
145
|
});
|
|
134
146
|
await initCommand.execute(targetPath);
|
|
135
147
|
}
|
|
@@ -165,8 +177,55 @@ program
|
|
|
165
177
|
.option('--force', 'Force update even when tools are up to date')
|
|
166
178
|
.action(async (targetPath = '.', options) => {
|
|
167
179
|
try {
|
|
180
|
+
const installDir = getInstallDir();
|
|
181
|
+
// Running from a clone: the version is whatever the branch says, so any
|
|
182
|
+
// upgrade advice would be noise. Decided before the request, so a
|
|
183
|
+
// contributor never waits on an answer that gets thrown away.
|
|
184
|
+
const latestVersion = isSourceCheckout(installDir) ? null : await getAvailableCliUpdate();
|
|
185
|
+
const announce = latestVersion !== null;
|
|
186
|
+
// Offer to upgrade first: this process generates files from its own
|
|
187
|
+
// templates, so upgrading afterwards would leave the old ones on disk.
|
|
188
|
+
// Both streams must be a terminal — with stdout redirected the question
|
|
189
|
+
// lands in the file and the user waits at a blank screen forever.
|
|
190
|
+
const canOffer = announce &&
|
|
191
|
+
shouldOfferUpgrade({
|
|
192
|
+
installDir,
|
|
193
|
+
projectPath: targetPath,
|
|
194
|
+
interactive: isInteractive(),
|
|
195
|
+
stdoutIsTty: Boolean(process.stdout.isTTY),
|
|
196
|
+
});
|
|
197
|
+
let declined = false;
|
|
198
|
+
if (latestVersion && canOffer) {
|
|
199
|
+
displayCliUpdateNote(latestVersion, targetPath, { withCommand: false });
|
|
200
|
+
const outcome = await offerCliUpgrade(latestVersion);
|
|
201
|
+
// Set the code and return rather than process.exit: exiting here would
|
|
202
|
+
// skip commander's postAction hook, killing the telemetry flush
|
|
203
|
+
// mid-request.
|
|
204
|
+
if (outcome === 'cancelled') {
|
|
205
|
+
// Ctrl-C means stop the command, not fall through to more prompts.
|
|
206
|
+
process.exitCode = 130;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (outcome === 'upgraded') {
|
|
210
|
+
process.exitCode = await rerunUpdateWithUpgradedCli(targetPath, {
|
|
211
|
+
force: options?.force,
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Declined, failed, or upgraded-but-unreachable: fall through to the
|
|
216
|
+
// update, then leave the command on screen underneath it.
|
|
217
|
+
declined = true;
|
|
218
|
+
}
|
|
168
219
|
const updateCommand = new UpdateCommand({ force: options?.force });
|
|
169
220
|
await updateCommand.execute(targetPath);
|
|
221
|
+
if (declined) {
|
|
222
|
+
// The headline was printed before the prompt; only the manual route is
|
|
223
|
+
// still owed, and it belongs where the user is looking now.
|
|
224
|
+
displayUpgradeCommand(targetPath);
|
|
225
|
+
}
|
|
226
|
+
else if (latestVersion) {
|
|
227
|
+
displayCliUpdateNote(latestVersion, targetPath);
|
|
228
|
+
}
|
|
170
229
|
}
|
|
171
230
|
catch (error) {
|
|
172
231
|
failWithError(error);
|
|
@@ -212,10 +271,19 @@ program
|
|
|
212
271
|
program
|
|
213
272
|
.command('view')
|
|
214
273
|
.description('Display an interactive dashboard of specs and changes')
|
|
215
|
-
.
|
|
274
|
+
.option('--store <id>', STORE_OPTION_DESCRIPTION)
|
|
275
|
+
.addOption(hiddenStorePathOption())
|
|
276
|
+
.action(async (options) => {
|
|
216
277
|
try {
|
|
278
|
+
// Implicit cwd fallback stays enabled so `view` keeps accepting the same
|
|
279
|
+
// directories as `list`/`status` — notably pre-config.yaml `openspec/`
|
|
280
|
+
// dirs. ViewCommand still reports a missing openspec/ directory itself.
|
|
281
|
+
const root = await resolveRootForCommand(options ?? {});
|
|
282
|
+
if (!root) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
217
285
|
const viewCommand = new ViewCommand();
|
|
218
|
-
await viewCommand.execute(
|
|
286
|
+
await viewCommand.execute(root.path);
|
|
219
287
|
}
|
|
220
288
|
catch (error) {
|
|
221
289
|
failWithError(error);
|
|
@@ -460,7 +528,7 @@ program
|
|
|
460
528
|
// Instructions command
|
|
461
529
|
program
|
|
462
530
|
.command('instructions [artifact]')
|
|
463
|
-
.description('Output enriched instructions for
|
|
531
|
+
.description('Output enriched instructions for artifacts, apply, or archive')
|
|
464
532
|
.option('--change <id>', 'Change name')
|
|
465
533
|
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
|
466
534
|
.option('--json', 'Output as JSON')
|
|
@@ -468,10 +536,13 @@ program
|
|
|
468
536
|
.addOption(hiddenStorePathOption())
|
|
469
537
|
.action(async (artifactId, options) => {
|
|
470
538
|
try {
|
|
471
|
-
//
|
|
539
|
+
// Workflow instruction surfaces are reserved command branches, not artifacts.
|
|
472
540
|
if (artifactId === 'apply') {
|
|
473
541
|
await applyInstructionsCommand(options);
|
|
474
542
|
}
|
|
543
|
+
else if (artifactId === 'archive') {
|
|
544
|
+
await archiveInstructionsCommand(options);
|
|
545
|
+
}
|
|
475
546
|
else {
|
|
476
547
|
await instructionsCommand(artifactId, options);
|
|
477
548
|
}
|
package/dist/commands/change.js
CHANGED
|
@@ -2,12 +2,31 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { JsonConverter } from '../core/converters/json-converter.js';
|
|
4
4
|
import { Validator } from '../core/validation/validator.js';
|
|
5
|
+
import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
|
|
5
6
|
import { ChangeParser } from '../core/parsers/change-parser.js';
|
|
6
7
|
import { isInteractive } from '../utils/interactive.js';
|
|
7
8
|
import { getActiveChangeIds } from '../utils/item-discovery.js';
|
|
8
9
|
import { getTaskProgressForChange } from '../utils/task-progress.js';
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
11
|
+
/**
|
|
12
|
+
* True only when `target` is definitively absent. An EACCES or I/O failure
|
|
13
|
+
* means existence cannot be determined, so callers fall through to their
|
|
14
|
+
* read-error path rather than claim the file was never written.
|
|
15
|
+
*/
|
|
16
|
+
async function isDefinitelyMissing(target) {
|
|
17
|
+
return fs
|
|
18
|
+
.access(target)
|
|
19
|
+
.then(() => false)
|
|
20
|
+
.catch((error) => error?.code === 'ENOENT');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A change is a directory directly under changes/. Rejecting anything else up
|
|
24
|
+
* front keeps a traversing name (`../..`) from reading a proposal outside the
|
|
25
|
+
* changes directory, and keeps the missing-proposal message honest.
|
|
26
|
+
*/
|
|
27
|
+
function isChangeDirectoryName(changesPath, changeDir) {
|
|
28
|
+
return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath);
|
|
29
|
+
}
|
|
11
30
|
export class ChangeCommand {
|
|
12
31
|
converter;
|
|
13
32
|
rootPath;
|
|
@@ -30,7 +49,8 @@ export class ChangeCommand {
|
|
|
30
49
|
const changesPath = this.getChangesPath();
|
|
31
50
|
if (!changeName) {
|
|
32
51
|
const canPrompt = isInteractive(options);
|
|
33
|
-
|
|
52
|
+
// Offer exactly the changes `show <name>` can resolve.
|
|
53
|
+
const changes = await getActiveChangeIds(this.rootPath ?? process.cwd());
|
|
34
54
|
if (canPrompt && changes.length > 0) {
|
|
35
55
|
const { select } = await import('@inquirer/prompts');
|
|
36
56
|
const selected = await select({
|
|
@@ -51,19 +71,40 @@ export class ChangeCommand {
|
|
|
51
71
|
return;
|
|
52
72
|
}
|
|
53
73
|
}
|
|
54
|
-
const
|
|
74
|
+
const changeDir = path.join(changesPath, changeName);
|
|
75
|
+
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
76
|
+
if (!isChangeDirectoryName(changesPath, changeDir)) {
|
|
77
|
+
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
|
|
78
|
+
}
|
|
55
79
|
try {
|
|
56
80
|
await fs.access(proposalPath);
|
|
57
81
|
}
|
|
58
82
|
catch {
|
|
83
|
+
// A change can exist without a proposal: `openspec new change` scaffolds
|
|
84
|
+
// only .openspec.yaml, and a custom schema need not define a proposal
|
|
85
|
+
// artifact. Say which of the two cases this is instead of reporting a
|
|
86
|
+
// change that does exist as missing. A stray file under changes/ is not a
|
|
87
|
+
// change, and naming it one would point the user at a `status --change`
|
|
88
|
+
// call that cannot work.
|
|
89
|
+
const isChangeDirectory = await fs
|
|
90
|
+
.stat(changeDir)
|
|
91
|
+
.then((stats) => stats.isDirectory())
|
|
92
|
+
.catch(() => false);
|
|
93
|
+
if (isChangeDirectory) {
|
|
94
|
+
throw new Error(`Change "${changeName}" has no proposal.md yet. ` +
|
|
95
|
+
`Run "openspec status --change ${changeName}" to see which artifact comes next.`);
|
|
96
|
+
}
|
|
59
97
|
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
|
|
60
98
|
}
|
|
99
|
+
FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath);
|
|
61
100
|
if (options?.json) {
|
|
101
|
+
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
|
|
62
102
|
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
|
|
63
103
|
if (options.requirementsOnly) {
|
|
64
104
|
console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
|
|
65
105
|
}
|
|
66
106
|
const parsed = JSON.parse(jsonOutput);
|
|
107
|
+
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
|
|
67
108
|
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
|
|
68
109
|
const title = this.extractTitle(contentForTitle, changeName);
|
|
69
110
|
const id = parsed.name;
|
|
@@ -78,6 +119,7 @@ export class ChangeCommand {
|
|
|
78
119
|
console.log(JSON.stringify(output, null, 2));
|
|
79
120
|
}
|
|
80
121
|
else {
|
|
122
|
+
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
|
|
81
123
|
const content = await fs.readFile(proposalPath, 'utf-8');
|
|
82
124
|
console.log(content);
|
|
83
125
|
}
|
|
@@ -89,18 +131,31 @@ export class ChangeCommand {
|
|
|
89
131
|
*/
|
|
90
132
|
async list(options) {
|
|
91
133
|
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
|
|
92
|
-
|
|
134
|
+
// Same directory-based resolution as `openspec list`, the command this
|
|
135
|
+
// deprecated alias points users at. Every output path below already
|
|
136
|
+
// tolerates a change whose proposal.md is missing or unreadable.
|
|
137
|
+
const changes = await getActiveChangeIds();
|
|
93
138
|
if (options?.json) {
|
|
94
139
|
const changeDetails = await Promise.all(changes.map(async (changeName) => {
|
|
95
|
-
const
|
|
140
|
+
const changeDir = path.join(changesPath, changeName);
|
|
141
|
+
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
142
|
+
// Resolve task progress through the shared tracked-tasks helper so
|
|
143
|
+
// this deprecated noun-form list cannot re-fork the resolution
|
|
144
|
+
// (#1202). Tasks are independent of the proposal: a change can carry
|
|
145
|
+
// tasks before, or without, a proposal.md.
|
|
146
|
+
const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
|
|
147
|
+
// No proposal yet is an ordinary state (scaffolded change, or a
|
|
148
|
+
// schema with no proposal artifact), so name the change rather than
|
|
149
|
+
// labelling it Unknown. Unknown stays for a proposal that exists but
|
|
150
|
+
// cannot be read or parsed.
|
|
151
|
+
if (await isDefinitelyMissing(proposalPath)) {
|
|
152
|
+
return { id: changeName, title: changeName, deltaCount: 0, taskStatus };
|
|
153
|
+
}
|
|
96
154
|
try {
|
|
155
|
+
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
|
|
97
156
|
const content = await fs.readFile(proposalPath, 'utf-8');
|
|
98
|
-
const changeDir = path.join(changesPath, changeName);
|
|
99
157
|
const parser = new ChangeParser(content, changeDir);
|
|
100
158
|
const change = await parser.parseChangeWithDeltas(changeName);
|
|
101
|
-
// Resolve task progress through the shared tracked-tasks helper so
|
|
102
|
-
// this deprecated noun-form list cannot re-fork the resolution (#1202).
|
|
103
|
-
const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
|
|
104
159
|
return {
|
|
105
160
|
id: changeName,
|
|
106
161
|
title: this.extractTitle(content, changeName),
|
|
@@ -108,13 +163,8 @@ export class ChangeCommand {
|
|
|
108
163
|
taskStatus,
|
|
109
164
|
};
|
|
110
165
|
}
|
|
111
|
-
catch
|
|
112
|
-
return {
|
|
113
|
-
id: changeName,
|
|
114
|
-
title: 'Unknown',
|
|
115
|
-
deltaCount: 0,
|
|
116
|
-
taskStatus: { total: 0, completed: 0 },
|
|
117
|
-
};
|
|
166
|
+
catch {
|
|
167
|
+
return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus };
|
|
118
168
|
}
|
|
119
169
|
}));
|
|
120
170
|
const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -133,20 +183,25 @@ export class ChangeCommand {
|
|
|
133
183
|
}
|
|
134
184
|
// Long format: id: title and minimal counts
|
|
135
185
|
for (const changeName of sorted) {
|
|
136
|
-
const
|
|
186
|
+
const changeDir = path.join(changesPath, changeName);
|
|
187
|
+
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
188
|
+
const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
|
|
189
|
+
const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
|
|
190
|
+
if (await isDefinitelyMissing(proposalPath)) {
|
|
191
|
+
console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
137
194
|
try {
|
|
195
|
+
FileSystemUtils.assertPathWithin(changeDir, proposalPath);
|
|
138
196
|
const content = await fs.readFile(proposalPath, 'utf-8');
|
|
139
197
|
const title = this.extractTitle(content, changeName);
|
|
140
|
-
const
|
|
141
|
-
const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
|
|
142
|
-
const changeDir = path.join(changesPath, changeName);
|
|
143
|
-
const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir);
|
|
198
|
+
const parser = new ChangeParser(content, changeDir);
|
|
144
199
|
const change = await parser.parseChangeWithDeltas(changeName);
|
|
145
200
|
const deltaCountText = ` [deltas ${change.deltas.length}]`;
|
|
146
201
|
console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`);
|
|
147
202
|
}
|
|
148
203
|
catch {
|
|
149
|
-
console.log(`${changeName}: (unable to read)`);
|
|
204
|
+
console.log(`${changeName}: (unable to read)${taskStatusText}`);
|
|
150
205
|
}
|
|
151
206
|
}
|
|
152
207
|
}
|
|
@@ -177,6 +232,9 @@ export class ChangeCommand {
|
|
|
177
232
|
}
|
|
178
233
|
}
|
|
179
234
|
const changeDir = path.join(changesPath, changeName);
|
|
235
|
+
if (!isChangeDirectoryName(changesPath, changeDir)) {
|
|
236
|
+
throw new Error(`Change "${changeName}" not found at ${changeDir}`);
|
|
237
|
+
}
|
|
180
238
|
try {
|
|
181
239
|
await fs.access(changeDir);
|
|
182
240
|
}
|
|
@@ -184,7 +242,11 @@ export class ChangeCommand {
|
|
|
184
242
|
throw new Error(`Change "${changeName}" not found at ${changeDir}`);
|
|
185
243
|
}
|
|
186
244
|
const validator = new Validator(options?.strict || false);
|
|
187
|
-
const report = await validator.validateChangeDeltaSpecs(changeDir
|
|
245
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
246
|
+
// Derived from changesPath so the main specs come from the same root the
|
|
247
|
+
// change itself was resolved against.
|
|
248
|
+
mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
|
|
249
|
+
});
|
|
188
250
|
if (options?.json) {
|
|
189
251
|
console.log(JSON.stringify(report, null, 2));
|
|
190
252
|
}
|
|
@@ -200,44 +262,36 @@ export class ChangeCommand {
|
|
|
200
262
|
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
|
201
263
|
});
|
|
202
264
|
// Next steps footer to guide fixing issues
|
|
203
|
-
this.printNextSteps();
|
|
265
|
+
this.printNextSteps(report.issues);
|
|
204
266
|
if (!options?.json) {
|
|
205
267
|
process.exitCode = 1;
|
|
206
268
|
}
|
|
207
269
|
}
|
|
208
270
|
}
|
|
209
271
|
}
|
|
210
|
-
async getActiveChanges(changesPath) {
|
|
211
|
-
try {
|
|
212
|
-
const entries = await fs.readdir(changesPath, { withFileTypes: true });
|
|
213
|
-
const result = [];
|
|
214
|
-
for (const entry of entries) {
|
|
215
|
-
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === ARCHIVE_DIR)
|
|
216
|
-
continue;
|
|
217
|
-
const proposalPath = path.join(changesPath, entry.name, 'proposal.md');
|
|
218
|
-
try {
|
|
219
|
-
await fs.access(proposalPath);
|
|
220
|
-
result.push(entry.name);
|
|
221
|
-
}
|
|
222
|
-
catch {
|
|
223
|
-
// skip directories without proposal.md
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return result.sort();
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
return [];
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
272
|
extractTitle(content, changeName) {
|
|
233
273
|
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
|
|
234
274
|
return match ? match[1].trim() : changeName;
|
|
235
275
|
}
|
|
236
|
-
printNextSteps() {
|
|
276
|
+
printNextSteps(issues = []) {
|
|
237
277
|
const bullets = [];
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
278
|
+
// Branch on the exact marker messages: the generic no-deltas guidance
|
|
279
|
+
// also mentions skip_specs and must not trigger the marker bullets.
|
|
280
|
+
const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
|
|
281
|
+
const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
|
|
282
|
+
if (conflictIssue) {
|
|
283
|
+
bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change');
|
|
284
|
+
bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> is required)');
|
|
285
|
+
}
|
|
286
|
+
else if (invalidMarkerIssue) {
|
|
287
|
+
bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> is required)');
|
|
288
|
+
bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead');
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
|
|
292
|
+
bullets.push('- Each requirement MUST include at least one #### Scenario: block');
|
|
293
|
+
bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
|
|
294
|
+
}
|
|
241
295
|
console.error('Next steps:');
|
|
242
296
|
bullets.forEach(b => console.error(` ${b}`));
|
|
243
297
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { spawn
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, } from '../core/global-config.js';
|
|
5
|
-
import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js';
|
|
5
|
+
import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, hasUnsafeKeySegment, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js';
|
|
6
6
|
import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';
|
|
7
7
|
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
|
8
8
|
import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
|
9
|
-
import {
|
|
9
|
+
import { UpdateCommand } from '../core/update.js';
|
|
10
|
+
import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
|
|
10
11
|
const WORKFLOW_PROMPT_META = {
|
|
11
12
|
propose: {
|
|
12
13
|
name: 'Propose change',
|
|
@@ -233,11 +234,15 @@ export function registerConfigCommand(program) {
|
|
|
233
234
|
.action((key, value, options) => {
|
|
234
235
|
const allowUnknown = Boolean(options.allowUnknown);
|
|
235
236
|
const keyValidation = validateConfigKeyPath(key);
|
|
236
|
-
|
|
237
|
+
// --allow-unknown relaxes the known-key check, but never the prototype-safety check.
|
|
238
|
+
const unsafeKey = hasUnsafeKeySegment(key);
|
|
239
|
+
if (!keyValidation.valid && (!allowUnknown || unsafeKey)) {
|
|
237
240
|
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
|
|
238
241
|
console.error(`Error: Invalid configuration key "${key}".${reason}`);
|
|
239
242
|
console.error('Use "openspec config list" to see available keys.');
|
|
240
|
-
|
|
243
|
+
if (!allowUnknown && !unsafeKey) {
|
|
244
|
+
console.error('Pass --allow-unknown to bypass this check.');
|
|
245
|
+
}
|
|
241
246
|
process.exitCode = 1;
|
|
242
247
|
return;
|
|
243
248
|
}
|
|
@@ -527,11 +532,12 @@ export function registerConfigCommand(program) {
|
|
|
527
532
|
});
|
|
528
533
|
if (applyNow) {
|
|
529
534
|
try {
|
|
530
|
-
|
|
535
|
+
await new UpdateCommand().execute(projectDir);
|
|
531
536
|
console.log('Run `openspec update` in your other projects to apply.');
|
|
532
537
|
}
|
|
533
|
-
catch {
|
|
534
|
-
console.error(
|
|
538
|
+
catch (error) {
|
|
539
|
+
console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`);
|
|
540
|
+
console.error('Please run it manually to apply the profile changes.');
|
|
535
541
|
process.exitCode = 1;
|
|
536
542
|
}
|
|
537
543
|
return;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Option } from 'commander';
|
|
7
7
|
import { resolveRootForCommand, } from '../core/root-selection.js';
|
|
8
8
|
import { readOptionalStoreMetadataState } from '../core/store/foundation.js';
|
|
9
|
-
import { gitOriginUrl, isGitRepositoryAtRoot } from '../core/store/git.js';
|
|
9
|
+
import { gitOriginUrl, gitTrackingDrift, isGitRepositoryAtRoot } from '../core/store/git.js';
|
|
10
10
|
import { classifyOpenSpecDir, readProjectConfig, resolveConfigFilePath, } from '../core/project-config.js';
|
|
11
11
|
import { findRepoPlanningRootSync } from '../core/planning-home.js';
|
|
12
12
|
import { gatherRelationshipData } from './shared-gather.js';
|
|
@@ -27,7 +27,8 @@ async function gatherHealth(root) {
|
|
|
27
27
|
referenceEntries,
|
|
28
28
|
registryUnreadable,
|
|
29
29
|
};
|
|
30
|
-
// Store facts for store-backed roots (explicit --store
|
|
30
|
+
// Store facts for store-backed roots (explicit --store, a declared
|
|
31
|
+
// pointer, or the global default).
|
|
31
32
|
// Missing/invalid metadata never reaches here: store resolution
|
|
32
33
|
// verifies identity first and fails with the existing taxonomy
|
|
33
34
|
// (recorded amendment - corrupt store.yaml is an exit-1 resolution
|
|
@@ -35,14 +36,18 @@ async function gatherHealth(root) {
|
|
|
35
36
|
if (root.storeId) {
|
|
36
37
|
const metadata = await readOptionalStoreMetadataState(root.path).catch(() => null);
|
|
37
38
|
// git -C walks UP the tree: probing a non-repo store nested inside
|
|
38
|
-
// another repo would record the ENCLOSING repo's origin.
|
|
39
|
-
const
|
|
39
|
+
// another repo would record the ENCLOSING repo's origin (and drift).
|
|
40
|
+
const isRepo = await isGitRepositoryAtRoot(root.path);
|
|
41
|
+
const [originUrl, drift] = isRepo
|
|
42
|
+
? await Promise.all([gitOriginUrl(root.path), gitTrackingDrift(root.path)])
|
|
43
|
+
: [null, null];
|
|
40
44
|
input.storeFacts = {
|
|
41
45
|
id: root.storeId,
|
|
42
46
|
metadataPresent: metadata !== null,
|
|
43
47
|
metadataValid: metadata !== null,
|
|
44
48
|
...(metadata?.remote ? { canonicalRemote: metadata.remote } : {}),
|
|
45
49
|
...(originUrl ? { originUrl } : {}),
|
|
50
|
+
...(drift ? { drift } : {}),
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
// The 3.2 both-shapes wrong turn, structured — including a malformed
|