mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7257bead0 | ||
|
|
2500d6da97 | ||
|
|
bfa670eda9 | ||
|
|
760584ba9a | ||
|
|
ba0f508763 | ||
|
|
7056a58056 | ||
|
|
3a34ea309d |
@@ -1,5 +1,15 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 1.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#2025](https://github.com/Fission-AI/OpenSpec/pull/2025) [`bfa670e`](https://github.com/Fission-AI/OpenSpec/commit/bfa670eda91c6cd998d42248ceab2b565db932ff) Thanks [@TabishB](https://github.com/TabishB)! - The CLI starts faster: each command now loads its implementation only when it runs. `openspec --version` and `--help` load 24 modules instead of 485, and commands such as `config list`, `store list` and `doctor` load only what they use, which matters most where Node loads modules slowly, such as Windows. Output, help text, shell completions, exit codes and telemetry are unchanged.
|
||||
|
||||
- [#2020](https://github.com/Fission-AI/OpenSpec/pull/2020) [`760584b`](https://github.com/Fission-AI/OpenSpec/commit/760584ba9a6e2aa5906edf082ecd465a53cf6618) Thanks [@clay-good](https://github.com/clay-good)! - A requirement description over 500 characters is now a warning instead of an informational hint, so `openspec validate --strict` fails on it and CI can enforce the limit. The check also covers ADDED requirements in a change, so `openspec validate <change> --strict` catches a new overlong requirement before archive. Normal validation and archive are unchanged: they still pass when this is the only finding. The specs instruction now explains how to split an existing long requirement without losing its scenarios.
|
||||
|
||||
- [#2031](https://github.com/Fission-AI/OpenSpec/pull/2031) [`2500d6d`](https://github.com/Fission-AI/OpenSpec/commit/2500d6da971336167548b53731a35b2127df35ac) Thanks [@TabishB](https://github.com/TabishB)! - `openspec view` no longer lists or counts archived changes. In projects with many archived changes, the list pushed active work off the screen. The dashboard shows current work again, and `openspec list --archived` still shows archived changes on request.
|
||||
|
||||
## 1.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -37,6 +37,15 @@ Those four commands are what CI runs, so a green local run means a green CI run.
|
||||
|
||||
Run `pnpm changeset` if your change affects users, and commit the file it generates.
|
||||
|
||||
### Keep the CLI's startup fast
|
||||
|
||||
Editors, agents and OpenSpec Desktop run the CLI many times, and each call pays for every module it loads before the command runs. Before this rule, `openspec --version` loaded 485 modules: about 0.5 s per call on a Windows machine. So a command loads only the command definitions and its own code:
|
||||
|
||||
- **Definitions** (name, options, help text) go in `src/cli/index.ts` or `src/cli/commands/<name>.ts`. Import nothing heavy there: no zod, yaml, fast-glob, ora, and no other command's code.
|
||||
- **The command's code** goes in `src/commands/<name>.ts` or `src/core/`, loaded inside the action with `await import()`.
|
||||
|
||||
`test/cli-e2e/startup-modules.test.ts` checks which modules each command loads, and fails if a definition starts pulling in an implementation. When you add a command, add it to that test's list.
|
||||
|
||||
## 4. Open the PR
|
||||
|
||||
- Branch off `main` in your fork.
|
||||
|
||||
@@ -643,9 +643,7 @@ Prints a one-screen dashboard of specs and changes.
|
||||
openspec view # project summary in one screen
|
||||
```
|
||||
|
||||
view prints the dashboard once and exits. It reads no keystrokes. Changes group by task progress: Draft (no tasks yet), Active (tasks underway, with a progress bar and percent), Completed (every task checked), and Archived. Specs list with requirement counts, largest first.
|
||||
|
||||
Archived changes appear by directory name in alphabetical order. They do not contribute to the Draft, Active, Completed, or Task Progress totals.
|
||||
view prints the dashboard once and exits. It reads no keystrokes. Changes group by task progress: Draft (no tasks yet), Active (tasks underway, with a progress bar and percent), Completed (every task checked). Specs list with requirement counts, largest first.
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -664,16 +662,11 @@ Summary:
|
||||
● Draft Changes: 1
|
||||
● Active Changes: 0 in progress
|
||||
● Completed Changes: 0
|
||||
● Archived Changes: 1
|
||||
|
||||
Draft Changes
|
||||
────────────────────────────────────────────────────────────
|
||||
○ add-rate-limit
|
||||
|
||||
Archived Changes
|
||||
────────────────────────────────────────────────────────────
|
||||
◦ 2026-08-10-add-login
|
||||
|
||||
Specifications
|
||||
────────────────────────────────────────────────────────────
|
||||
▪ api 1 requirement
|
||||
|
||||
@@ -208,7 +208,7 @@ Format requirements:
|
||||
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions once they reach the main spec. This is an informational hint, not an error. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
|
||||
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
@@ -361,17 +361,22 @@ Before writing tasks, check design.md for Open Questions. If any of them
|
||||
would change what gets built, resolve them with the user first - do not
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
**IMPORTANT: Follow the template below for tracked tasks.** The apply phase parses
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Group related tracked tasks under ## numbered headings
|
||||
- Each tracked task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Tasks should be small enough to complete in one session
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
- Track implementation and verification work that can be completed before
|
||||
archive. If the requested workflow includes archive or work that requires
|
||||
this change to be archived, preserve those steps as plain bullets in an
|
||||
optional `## Workflow follow-up` section at the end of tasks.md. These
|
||||
bullets are reference information outside tracked task progress.
|
||||
- Each task MUST state how to verify completion (a test, command,
|
||||
observable behavior, or delivered artifact). Put the verification in
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
@@ -401,6 +406,14 @@ Example:
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
When applicable, append workflow follow-up as plain bullets, for example:
|
||||
```
|
||||
## Workflow follow-up
|
||||
|
||||
- Archive the change after the project's review requirements are satisfied.
|
||||
- Verify the archived result.
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
````
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Fai
|
||||
### 4.9 `archive <name> --json`
|
||||
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written or retired (a capability whose last requirement the change removed has its spec deleted, which requires `retire_capabilities: true` in the change's `.openspec.yaml`; every retirement is named in `warnings`, with a pasteable Git recovery command only when the spec lived in the caller's checkout); an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.
|
||||
|
||||
- **`archive: null`**: the command failed. This is not a guarantee that files are unchanged.
|
||||
- **`archive_retirement_cleanup_failed`**: the change was archived, but retirement backup verification or cleanup failed. A listed backup path may have changed or disappeared. The message can also report a staged source left by a failed fallback-copy cleanup. Inspect the current archive and all reported recovery paths before cleanup. Preserve any needed content. Retrying archive does not clean up these paths.
|
||||
- **`archive_error`**: the fallback diagnostic does not identify whether files changed. Inspect the change, archive, and affected specs before retrying.
|
||||
|
||||
### 4.10 `doctor --json`
|
||||
`{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "drift"?: {ahead,behind}, "status": [] } | null, "references": [...], "status": [] }`. `drift` (present only for a git-backed store checkout that has an upstream tracking ref) is ahead/behind counts against the last-fetched upstream, not the live remote. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1.
|
||||
|
||||
@@ -124,7 +128,7 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis
|
||||
`relationship_registry_unreadable`, `root_pointer_ignored`, `root_pointer_invalid`, `pointer_declarations_inert`.
|
||||
|
||||
### Archive (JSON mode)
|
||||
`archive_change_name_required`, `archive_change_not_found`, `archive_change_symlink`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_error`.
|
||||
`archive_change_name_required`, `archive_change_not_found`, `archive_change_symlink`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_retirement_cleanup_failed`, `archive_error`.
|
||||
|
||||
### Context writes
|
||||
`context_file_exists`, `context_output_dir_missing`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
@@ -94,7 +94,7 @@ artifacts:
|
||||
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
|
||||
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
|
||||
- Every requirement MUST have at least one scenario.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions once they reach the main spec. This is an informational hint, not an error. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length.
|
||||
- Keep each requirement's description (the text between `### Requirement:` and its first scenario) to 500 characters or fewer. `openspec validate` flags longer descriptions in ADDED requirements and in the main spec. This is a warning: normal validation still passes, but `openspec validate --strict` fails on it. When writing a new requirement, state one behavior per requirement: move examples and edge cases into scenarios, and split a requirement that covers several behaviors into separate `### Requirement:` blocks, each with its own scenarios. Under MODIFIED, keep the existing requirement block whole; never split, trim or rewrite existing text just to meet the length. Split an existing long requirement only when the user asks for it, in a change made for that purpose: under MODIFIED, keep its header and every scenario and cut its description down to one behavior without changing its meaning, then add each behavior you removed as its own ADDED requirement with its own scenarios.
|
||||
|
||||
New capabilities only: the delta spec's first section is `## Purpose` -
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
@@ -195,17 +195,22 @@ artifacts:
|
||||
would change what gets built, resolve them with the user first - do not
|
||||
bake an unstated assumption into the task list.
|
||||
|
||||
**IMPORTANT: Follow the template below exactly.** The apply phase parses
|
||||
**IMPORTANT: Follow the template below for tracked tasks.** The apply phase parses
|
||||
checkbox format to track progress. A box holding only `x` counts as done,
|
||||
upper or lower case and with any spacing, so `- [ x]` is done too. Every
|
||||
other marker, including `- [~]`, `- [-]` and an empty `- []`, reads as
|
||||
unfinished. A line with no checkbox is not tracked at all.
|
||||
|
||||
Guidelines:
|
||||
- Group related tasks under ## numbered headings
|
||||
- Each task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Group related tracked tasks under ## numbered headings
|
||||
- Each tracked task MUST be a checkbox: `- [ ] X.Y Task description`
|
||||
- Tasks should be small enough to complete in one session
|
||||
- Order tasks by dependency (what must be done first?)
|
||||
- Track implementation and verification work that can be completed before
|
||||
archive. If the requested workflow includes archive or work that requires
|
||||
this change to be archived, preserve those steps as plain bullets in an
|
||||
optional `## Workflow follow-up` section at the end of tasks.md. These
|
||||
bullets are reference information outside tracked task progress.
|
||||
- Each task MUST state how to verify completion (a test, command,
|
||||
observable behavior, or delivered artifact). Put the verification in
|
||||
that task's checkbox description. Use a separate verification task only
|
||||
@@ -235,6 +240,14 @@ artifacts:
|
||||
- [ ] 2.3 Document the export API in docs/export.md and verify the documented command runs as written
|
||||
```
|
||||
|
||||
When applicable, append workflow follow-up as plain bullets, for example:
|
||||
```
|
||||
## Workflow follow-up
|
||||
|
||||
- Archive the change after the project's review requirements are satisfied.
|
||||
- Verify the archived result.
|
||||
```
|
||||
|
||||
Reference specs for what needs to be built, design for how to build it.
|
||||
requires:
|
||||
- specs
|
||||
|
||||
@@ -65,7 +65,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
- If `state: "blocked"`: show the message and pause implementation.
|
||||
- If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them.
|
||||
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
Treat `context` as a required prompt-level input. Read and consider it, and
|
||||
@@ -124,7 +124,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
@@ -155,7 +155,8 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/openspec-archive-change`.
|
||||
All tracked tasks are complete. Review or verify the change as appropriate
|
||||
before archiving. You can archive this change with `/openspec-archive-change`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
/**
|
||||
* Register the config command and all its subcommands.
|
||||
*
|
||||
* @param program - The Commander program instance
|
||||
*/
|
||||
export function registerConfigCommand(program: Command): void {
|
||||
const configCmd = program
|
||||
.command('config')
|
||||
.description('View and modify global OpenSpec configuration')
|
||||
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.scope && opts.scope !== 'global') {
|
||||
console.error('Error: Project-local config is not yet implemented');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// config path
|
||||
configCmd
|
||||
.command('path')
|
||||
.description('Show config file location')
|
||||
.action(async () => {
|
||||
const { configPathCommand } = await import('../../commands/config.js');
|
||||
configPathCommand();
|
||||
});
|
||||
|
||||
// config list
|
||||
configCmd
|
||||
.command('list')
|
||||
.description('Show all current settings')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const { configListCommand } = await import('../../commands/config.js');
|
||||
configListCommand(options);
|
||||
});
|
||||
|
||||
// config get
|
||||
configCmd
|
||||
.command('get <key>')
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action(async (key: string) => {
|
||||
const { configGetCommand } = await import('../../commands/config.js');
|
||||
configGetCommand(key);
|
||||
});
|
||||
|
||||
// config set
|
||||
configCmd
|
||||
.command('set <key> <value>')
|
||||
.description('Set a value (auto-coerce types)')
|
||||
.option('--string', 'Force value to be stored as string')
|
||||
.option('--allow-unknown', 'Allow setting unknown keys')
|
||||
.action(async (key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {
|
||||
const { configSetCommand } = await import('../../commands/config.js');
|
||||
configSetCommand(key, value, options);
|
||||
});
|
||||
|
||||
// config unset
|
||||
configCmd
|
||||
.command('unset <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action(async (key: string) => {
|
||||
const { configUnsetCommand } = await import('../../commands/config.js');
|
||||
configUnsetCommand(key);
|
||||
});
|
||||
|
||||
// config reset
|
||||
configCmd
|
||||
.command('reset')
|
||||
.description('Reset configuration to defaults')
|
||||
.option('--all', 'Reset all configuration (required)')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (options: { all?: boolean; yes?: boolean }) => {
|
||||
const { configResetCommand } = await import('../../commands/config.js');
|
||||
await configResetCommand(options);
|
||||
});
|
||||
|
||||
// config edit
|
||||
configCmd
|
||||
.command('edit')
|
||||
.description('Open config in $EDITOR')
|
||||
.action(async () => {
|
||||
const { configEditCommand } = await import('../../commands/config.js');
|
||||
await configEditCommand();
|
||||
});
|
||||
|
||||
// config profile [preset]
|
||||
configCmd
|
||||
.command('profile [preset]')
|
||||
.description('Configure workflow profile (interactive picker or preset shortcut)')
|
||||
.action(async (preset?: string) => {
|
||||
const { configProfileCommand } = await import('../../commands/config.js');
|
||||
await configProfileCommand(preset);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../../core/completions/shared-flags.js';
|
||||
import type { ContextOptions } from '../../commands/context.js';
|
||||
|
||||
export function registerContextCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'context')?.description ??
|
||||
'Print the working context for the resolved OpenSpec root';
|
||||
|
||||
program
|
||||
.command('context')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output the agent brief as JSON')
|
||||
.option('--code-workspace <path>', 'Also write a VS Code workspace file for the set')
|
||||
.option('--force', 'Overwrite an existing --code-workspace file')
|
||||
.action(async (options: ContextOptions) => {
|
||||
const { contextCommand } = await import('../../commands/context.js');
|
||||
await contextCommand(options);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../../core/completions/shared-flags.js';
|
||||
import type { DoctorOptions } from '../../commands/doctor.js';
|
||||
|
||||
export function registerDoctorCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'doctor')?.description ??
|
||||
'Report relationship health for the resolved OpenSpec root';
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: DoctorOptions) => {
|
||||
const { doctorCommand } = await import('../../commands/doctor.js');
|
||||
await doctorCommand(options);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
/**
|
||||
* Register the schema command and all its subcommands.
|
||||
*/
|
||||
export function registerSchemaCommand(program: Command): void {
|
||||
const schemaCmd = program
|
||||
.command('schema')
|
||||
.description('Manage workflow schemas [experimental]');
|
||||
|
||||
// Experimental warning
|
||||
schemaCmd.hook('preAction', () => {
|
||||
console.error('Note: Schema commands are experimental and may change.');
|
||||
});
|
||||
|
||||
// schema which
|
||||
schemaCmd
|
||||
.command('which [name]')
|
||||
.description('Show where a schema resolves from')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--all', 'List all schemas with their resolution sources')
|
||||
.action(async (name?: string, options?: { json?: boolean; all?: boolean }) => {
|
||||
const { schemaWhichCommand } = await import('../../commands/schema.js');
|
||||
await schemaWhichCommand(name, options);
|
||||
});
|
||||
|
||||
// schema validate
|
||||
schemaCmd
|
||||
.command('validate [name]')
|
||||
.description('Validate a schema structure and templates')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--verbose', 'Show detailed validation steps')
|
||||
.action(async (name?: string, options?: { json?: boolean; verbose?: boolean }) => {
|
||||
const { schemaValidateCommand } = await import('../../commands/schema.js');
|
||||
await schemaValidateCommand(name, options);
|
||||
});
|
||||
|
||||
// schema fork
|
||||
schemaCmd
|
||||
.command('fork <source> [name]')
|
||||
.description('Copy an existing schema to project for customization')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--force', 'Overwrite existing destination')
|
||||
.action(async (source: string, name?: string, options?: { json?: boolean; force?: boolean }) => {
|
||||
const { schemaForkCommand } = await import('../../commands/schema.js');
|
||||
await schemaForkCommand(source, name, options);
|
||||
});
|
||||
|
||||
// schema init
|
||||
schemaCmd
|
||||
.command('init <name>')
|
||||
.description('Create a new project-local schema')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--description <text>', 'Schema description')
|
||||
.option('--artifacts <list>', 'Comma-separated artifact IDs (proposal,specs,design,tasks)')
|
||||
.option('--default', 'Set as project default schema')
|
||||
.option('--no-default', 'Do not prompt to set as default')
|
||||
.option('--force', 'Overwrite existing schema')
|
||||
.action(async (
|
||||
name: string,
|
||||
options?: {
|
||||
json?: boolean;
|
||||
description?: string;
|
||||
artifacts?: string;
|
||||
default?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
) => {
|
||||
const { schemaInitCommand } = await import('../../commands/schema.js');
|
||||
await schemaInitCommand(name, options);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ShowOptions } from '../../commands/spec.js';
|
||||
|
||||
export function registerSpecCommand(rootProgram: Command) {
|
||||
const specCommand = rootProgram
|
||||
.command('spec')
|
||||
.description('Manage and view OpenSpec specifications');
|
||||
|
||||
// Deprecation notice for noun-based commands
|
||||
specCommand.hook('preAction', () => {
|
||||
console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").');
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('show [spec-id]')
|
||||
.description('Display a specific specification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
|
||||
.option('--no-scenarios', 'JSON only: Exclude scenario content')
|
||||
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => {
|
||||
const { specShowCommand } = await import('../../commands/spec.js');
|
||||
await specShowCommand(specId, options);
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('list')
|
||||
.description('List all available specifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--long', 'Show id and title with counts')
|
||||
.action(async (options: { json?: boolean; long?: boolean }) => {
|
||||
const { specListCommand } = await import('../../commands/spec.js');
|
||||
await specListCommand(options);
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('validate [spec-id]')
|
||||
.description('Validate a specification structure')
|
||||
.option('--strict', 'Enable strict validation mode')
|
||||
.option('--json', 'Output validation report as JSON')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
const { specValidateCommand } = await import('../../commands/spec.js');
|
||||
await specValidateCommand(specId, options);
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Command } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import { printJson } from '../../commands/shared-output.js';
|
||||
import type {
|
||||
StoreCommand,
|
||||
StoreJsonOptions,
|
||||
StoreRegisterOptions,
|
||||
StoreRemoveOptions,
|
||||
StoreSetupOptions,
|
||||
} from '../../commands/store.js';
|
||||
|
||||
async function loadStoreCommand(): Promise<StoreCommand> {
|
||||
const { StoreCommand } = await import('../../commands/store.js');
|
||||
return new StoreCommand();
|
||||
}
|
||||
|
||||
export function registerStoreCommand(program: Command): void {
|
||||
// One source for the locked group one-liner: the completions registry
|
||||
// entry, which shell completion scripts also consume.
|
||||
const storeGroupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'store')?.description ??
|
||||
'Create and manage stores - standalone OpenSpec repos you register on this machine';
|
||||
const store = program.command('store').description(storeGroupDescription);
|
||||
|
||||
store
|
||||
.command('setup [id]')
|
||||
.description('Create and register a local store')
|
||||
.option('--path <path>', 'Folder where the store should live (for example ~/openspec/<id>)')
|
||||
.option('--init-git', 'Initialize a Git repository with an initial commit (default)')
|
||||
.option('--no-init-git', 'Skip every Git action: no init, no initial commit')
|
||||
.option('--remote <url>', 'Canonical clone source recorded in store.yaml')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreSetupOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.setup(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('register [path]')
|
||||
.description('Register an existing local store')
|
||||
.option('--id <id>', 'Store id; defaults to metadata or folder name')
|
||||
.option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (inputPath: string | undefined, options: StoreRegisterOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.register(inputPath, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('unregister <id>')
|
||||
.description('Forget a local store registration without deleting files')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreJsonOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.unregister(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('remove <id>')
|
||||
.description('Forget a local store registration and delete its local folder')
|
||||
.option('--yes', 'Confirm local store folder deletion')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreRemoveOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.remove(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List locally registered stores')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StoreJsonOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.list(options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('doctor [id]')
|
||||
.description('Check local store registration and metadata')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreJsonOptions) => {
|
||||
const storeCommand = await loadStoreCommand();
|
||||
await storeCommand.doctor(id, options);
|
||||
});
|
||||
|
||||
const lifecycleRedirects = new Set(
|
||||
COMMAND_REGISTRY.filter(
|
||||
(entry) =>
|
||||
entry.flags.some((flag) => flag.name === 'store') ||
|
||||
(entry.subcommands ?? []).some((subcommand) =>
|
||||
subcommand.flags.some((flag) => flag.name === 'store')
|
||||
)
|
||||
).map((entry) => entry.name)
|
||||
);
|
||||
const storeSubcommandsLine = store.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0 ? `${subcommand.name()} (${aliases.join(', ')})` : subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
// One group action owns missing AND unknown subcommands. Known
|
||||
// subcommands dispatch above; everything else — including a bare
|
||||
// `store --json` with no operand — lands here, so the handler owns the
|
||||
// entire message and exit path (same text for human and --json). The
|
||||
// permissive flags route unknown operands/options here instead of
|
||||
// letting Commander emit a raw error before the action runs. We detect
|
||||
// `--json` in the residual args rather than declaring a group option,
|
||||
// which would otherwise shadow each subcommand's own `--json` flag.
|
||||
store.allowExcessArguments(true);
|
||||
store.allowUnknownOption(true);
|
||||
store.action(() => {
|
||||
const operands = store.args;
|
||||
// Flag values are indistinguishable from operands without a full
|
||||
// parse, so the verbatim echo only applies to plain-operand input.
|
||||
const attempted = operands.filter((operand) => !operand.startsWith('-'));
|
||||
const hasFlagLikeToken = operands.some((operand) => operand.startsWith('-'));
|
||||
// The agent contract: --json failures emit one JSON document.
|
||||
if (operands.includes('--json')) {
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`;
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_store_subcommand',
|
||||
message,
|
||||
fix: 'Run a store subcommand, or use the lifecycle command with --store <id>.',
|
||||
},
|
||||
],
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let example = 'openspec new change <change-id> --store <id>';
|
||||
if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) {
|
||||
if (attempted[0] === 'new') {
|
||||
const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : '<change-id>';
|
||||
example = `openspec new change ${changeId} --store <id>`;
|
||||
} else {
|
||||
example = `openspec ${attempted.join(' ')} --store <id>`;
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
attempted.length > 0
|
||||
? `Error: unknown command '${attempted[0]}' for 'openspec store'.`
|
||||
: "Error: missing subcommand for 'openspec store'."
|
||||
);
|
||||
console.error(
|
||||
`Store subcommands manage store registration: ${storeSubcommandsLine}.`
|
||||
);
|
||||
console.error(
|
||||
'To create or work on a change in a store, use the normal command with --store, for example:'
|
||||
);
|
||||
console.error(` ${example}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import { COMMAND_REGISTRY } from '../../core/completions/command-registry.js';
|
||||
import type { StoreDiagnostic } from '../../core/store/errors.js';
|
||||
import { printJson } from '../../commands/shared-output.js';
|
||||
import type {
|
||||
WorksetCommand,
|
||||
WorksetCreateOptions,
|
||||
WorksetOpenOptions,
|
||||
WorksetRemoveOptions,
|
||||
} from '../../commands/workset.js';
|
||||
|
||||
async function loadWorksetCommand(): Promise<WorksetCommand> {
|
||||
const { WorksetCommand } = await import('../../commands/workset.js');
|
||||
return new WorksetCommand();
|
||||
}
|
||||
|
||||
function collectMember(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function registerWorksetCommand(program: Command): void {
|
||||
const groupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'workset')?.description ??
|
||||
'Compose, keep, and open personal working views (purely local)';
|
||||
const workset = program.command('workset').description(groupDescription);
|
||||
// Parsed at the group level so `openspec workset --json` keeps the
|
||||
// one-JSON-document contract instead of a raw Commander error. The
|
||||
// parent option matches anywhere; actions read optsWithGlobals().
|
||||
workset.addOption(new Option('--json', 'Output as JSON').hideHelp());
|
||||
|
||||
workset
|
||||
.command('create [name]')
|
||||
.description('Compose and save a named working view of folders you choose')
|
||||
.option(
|
||||
'--member <member>',
|
||||
'Member folder as <path> or <name>=<path>; repeatable, first is the primary',
|
||||
collectMember,
|
||||
[] as string[]
|
||||
)
|
||||
.option('--tool <id>', 'Preferred tool to open this workset with')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string | undefined, _options: WorksetCreateOptions, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.create(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('Show saved worksets with their members')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (_options: { json?: boolean }, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.list(command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('open <name>')
|
||||
.description('Open a saved workset in your tool (editor window or agent session)')
|
||||
.option('--tool <id>', 'Open with this tool just this once')
|
||||
.addOption(
|
||||
// Parsed so Commander never owns the error; rejected in the
|
||||
// action with one JSON document. Hidden because help should not
|
||||
// advertise a mode that only rejects.
|
||||
new Option('--json', 'Not supported for open').hideHelp()
|
||||
)
|
||||
.action(async (name: string, _options: WorksetOpenOptions, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.open(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('remove <name>')
|
||||
.description('Delete a saved workset (member folders are never touched)')
|
||||
.option('--yes', 'Confirm removal non-interactively')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string, _options: WorksetRemoveOptions, command: Command) => {
|
||||
const worksetCommand = await loadWorksetCommand();
|
||||
await worksetCommand.remove(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
const subcommandsLine = workset.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0
|
||||
? `${subcommand.name()} (${aliases.join(', ')})`
|
||||
: subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// One handler owns missing AND unknown subcommands: known
|
||||
// subcommands dispatch above; everything else lands in this action
|
||||
// (allowExcessArguments routes the unknown operand here), keeping
|
||||
// the one-JSON-document contract for `--json` probes.
|
||||
workset.allowExcessArguments(true);
|
||||
workset.action(() => {
|
||||
const attempted = workset.args.filter(
|
||||
(operand) => !operand.startsWith('-')
|
||||
);
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`;
|
||||
if (workset.opts().json) {
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_workset_subcommand',
|
||||
message,
|
||||
fix: 'Run one of the workset subcommands.',
|
||||
} satisfies StoreDiagnostic,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+97
-72
@@ -1,63 +1,31 @@
|
||||
import { asStatus } from '../commands/shared-output.js';
|
||||
import { Command, Option } from 'commander';
|
||||
import { createRequire } from 'module';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import {
|
||||
getAvailableCliUpdate,
|
||||
displayCliUpdateNote,
|
||||
shouldOfferUpgrade,
|
||||
getInstallDir,
|
||||
offerCliUpgrade,
|
||||
rerunUpdateWithUpgradedCli,
|
||||
displayUpgradeCommand,
|
||||
isSourceCheckout,
|
||||
checkForCliUpdate,
|
||||
getCliInstallInfo,
|
||||
getCliUpdateCommand,
|
||||
canSelfUpgrade,
|
||||
buildVersionReportLines,
|
||||
} from '../core/version-check.js';
|
||||
import { ListCommand } from '../core/list.js';
|
||||
import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js';
|
||||
import { ViewCommand } from '../core/view.js';
|
||||
import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js';
|
||||
import { registerSpecCommand } from '../commands/spec.js';
|
||||
import { ChangeCommand } from '../commands/change.js';
|
||||
import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { FeedbackCommand } from '../commands/feedback.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerSchemaCommand } from '../commands/schema.js';
|
||||
import { registerStoreCommand } from '../commands/store.js';
|
||||
import { registerDoctorCommand } from '../commands/doctor.js';
|
||||
import { registerContextCommand } from '../commands/context.js';
|
||||
import { registerWorksetCommand } from '../commands/workset.js';
|
||||
import {
|
||||
statusCommand,
|
||||
BATCH_STATUS_FAILURE_PAYLOAD,
|
||||
instructionsCommand,
|
||||
applyInstructionsCommand,
|
||||
archiveInstructionsCommand,
|
||||
templatesCommand,
|
||||
schemasCommand,
|
||||
newChangeCommand,
|
||||
DEFAULT_SCHEMA,
|
||||
type StatusOptions,
|
||||
type InstructionsOptions,
|
||||
type TemplatesOptions,
|
||||
type SchemasOptions,
|
||||
type NewChangeOptions,
|
||||
import type { ArchiveOptions } from '../core/archive.js';
|
||||
import { registerSpecCommand } from './commands/spec.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerSchemaCommand } from './commands/schema.js';
|
||||
import { registerStoreCommand } from './commands/store.js';
|
||||
import { registerDoctorCommand } from './commands/doctor.js';
|
||||
import { registerContextCommand } from './commands/context.js';
|
||||
import { registerWorksetCommand } from './commands/workset.js';
|
||||
import { DEFAULT_SCHEMA } from '../commands/workflow/default-schema.js';
|
||||
import type {
|
||||
StatusOptions,
|
||||
InstructionsOptions,
|
||||
TemplatesOptions,
|
||||
SchemasOptions,
|
||||
NewChangeOptions,
|
||||
} from '../commands/workflow/index.js';
|
||||
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
||||
import { maybeShowCompletionTip } from '../core/completion-tip.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
|
||||
// Startup cost: every command's implementation, and the packages it uses,
|
||||
// loads with `await import()` inside its action. This module and
|
||||
// ./commands/ only define commands (names, options, help), so `--version`,
|
||||
// `--help`, and each command load no other command's implementation.
|
||||
|
||||
const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
|
||||
|
||||
@@ -72,13 +40,14 @@ function hiddenStorePathOption(): Option {
|
||||
).hideHelp();
|
||||
}
|
||||
|
||||
function failWithError(
|
||||
async function failWithError(
|
||||
error: unknown,
|
||||
json?: { enabled: boolean | undefined; payload?: Record<string, unknown>; fallbackCode?: string }
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// The agent contract: every --json failure leaves exactly one JSON
|
||||
// document on stdout (the command's null-shape plus a status array).
|
||||
if (json?.enabled) {
|
||||
const { asStatus } = await import('../commands/shared-output.js');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ ...(json.payload ?? {}), status: [asStatus(error, json.fallbackCode ?? 'command_error')] },
|
||||
@@ -89,6 +58,7 @@ function failWithError(
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { default: ora } = await import('ora');
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
// Resolution and store errors carry a pasteable fix - never drop it.
|
||||
const fix = (error as { diagnostic?: { fix?: string } }).diagnostic?.fix;
|
||||
@@ -180,6 +150,13 @@ program
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--check', 'Check the registry for a newer version')
|
||||
.action(async (options: { json?: boolean; check?: boolean }) => {
|
||||
const {
|
||||
getCliInstallInfo,
|
||||
checkForCliUpdate,
|
||||
getCliUpdateCommand,
|
||||
canSelfUpgrade,
|
||||
buildVersionReportLines,
|
||||
} = await import('../core/version-check.js');
|
||||
const install = getCliInstallInfo();
|
||||
const update = options.check ? await checkForCliUpdate() : undefined;
|
||||
const command = update?.status === 'available' ? getCliUpdateCommand(install) : null;
|
||||
@@ -222,6 +199,7 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
||||
// Show first-run telemetry notice (if not seen). It's written to stderr, so it
|
||||
// never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
|
||||
// very first invocation stays free of any incidental output on either stream.
|
||||
const { maybeShowTelemetryNotice, trackCommand } = await import('../telemetry/index.js');
|
||||
await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
|
||||
|
||||
// Track command execution (use actionCommand to get the actual subcommand)
|
||||
@@ -239,12 +217,14 @@ program.hook('postAction', async (_thisCommand, actionCommand) => {
|
||||
// `openspec completion ...`, and a stderr that is not a terminal (agents and
|
||||
// pipes would otherwise silently burn the user's one-shot tip).
|
||||
try {
|
||||
const { maybeShowCompletionTip } = await import('../core/completion-tip.js');
|
||||
await maybeShowCompletionTip({
|
||||
silent: shouldDeferCompletionTip(actionCommand, Boolean(process.stderr.isTTY)),
|
||||
});
|
||||
} finally {
|
||||
// The flush runs even if the hint throws: parse() is synchronous, so a
|
||||
// rejection here has no catch anywhere above it.
|
||||
const { shutdown } = await import('../telemetry/index.js');
|
||||
await shutdown();
|
||||
}
|
||||
});
|
||||
@@ -299,7 +279,7 @@ program
|
||||
});
|
||||
await initCommand.execute(targetPath);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -320,7 +300,7 @@ program
|
||||
});
|
||||
await initCommand.execute('.');
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -331,6 +311,24 @@ program
|
||||
.option('--force', 'Force update even when tools are up to date')
|
||||
.action(async (targetPath = '.', options?: { force?: boolean }) => {
|
||||
try {
|
||||
const [
|
||||
{
|
||||
getInstallDir,
|
||||
isSourceCheckout,
|
||||
getAvailableCliUpdate,
|
||||
shouldOfferUpgrade,
|
||||
displayCliUpdateNote,
|
||||
offerCliUpgrade,
|
||||
rerunUpdateWithUpgradedCli,
|
||||
displayUpgradeCommand,
|
||||
},
|
||||
{ isInteractive },
|
||||
{ UpdateCommand },
|
||||
] = await Promise.all([
|
||||
import('../core/version-check.js'),
|
||||
import('../utils/interactive.js'),
|
||||
import('../core/update.js'),
|
||||
]);
|
||||
const installDir = getInstallDir();
|
||||
// Running from a clone: the version is whatever the branch says, so any
|
||||
// upgrade advice would be noise. Decided before the request, so a
|
||||
@@ -385,7 +383,7 @@ program
|
||||
displayCliUpdateNote(latestVersion, targetPath);
|
||||
}
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -406,6 +404,10 @@ program
|
||||
if (options?.specs && (options.archived || options.all)) {
|
||||
throw new Error('--archived and --all can only be used when listing changes.');
|
||||
}
|
||||
const [{ resolveRootForCommand, toRootOutput }, { ListCommand }] = await Promise.all([
|
||||
import('../core/root-selection.js'),
|
||||
import('../core/list.js'),
|
||||
]);
|
||||
const root = await resolveRootForCommand(options ?? {}, {
|
||||
json: options?.json,
|
||||
failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
||||
@@ -427,7 +429,7 @@ program
|
||||
...(options?.json ? { root: toRootOutput(root) } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
failWithError(error, {
|
||||
await failWithError(error, {
|
||||
enabled: options?.json,
|
||||
payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
||||
fallbackCode: 'list_error',
|
||||
@@ -446,6 +448,10 @@ program
|
||||
// Implicit cwd fallback stays enabled so `view` keeps accepting the same
|
||||
// directories as `list`/`status` — notably pre-config.yaml `openspec/`
|
||||
// dirs. ViewCommand still reports a missing openspec/ directory itself.
|
||||
const [{ resolveRootForCommand }, { ViewCommand }] = await Promise.all([
|
||||
import('../core/root-selection.js'),
|
||||
import('../core/view.js'),
|
||||
]);
|
||||
const root = await resolveRootForCommand(options ?? {});
|
||||
if (!root) {
|
||||
return;
|
||||
@@ -453,7 +459,7 @@ program
|
||||
const viewCommand = new ViewCommand();
|
||||
await viewCommand.execute(root.path);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -478,6 +484,7 @@ changeCmd
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; diff?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
const { ChangeCommand } = await import('../commands/change.js');
|
||||
const changeCommand = new ChangeCommand();
|
||||
await changeCommand.show(changeName, options);
|
||||
} catch (error) {
|
||||
@@ -494,6 +501,7 @@ changeCmd
|
||||
.action(async (options?: { json?: boolean; long?: boolean }) => {
|
||||
try {
|
||||
console.error('Warning: "openspec change list" is deprecated. Use "openspec list".');
|
||||
const { ChangeCommand } = await import('../commands/change.js');
|
||||
const changeCommand = new ChangeCommand();
|
||||
await changeCommand.list(options);
|
||||
} catch (error) {
|
||||
@@ -510,6 +518,7 @@ changeCmd
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
const { ChangeCommand } = await import('../commands/change.js');
|
||||
const changeCommand = new ChangeCommand();
|
||||
// validate() already sets process.exitCode, and Node honours it at
|
||||
// natural exit. Calling process.exit() here would skip commander's
|
||||
@@ -534,10 +543,11 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (changeName?: string, options?: ArchiveOptions) => {
|
||||
try {
|
||||
const { ArchiveCommand } = await import('../core/archive.js');
|
||||
const archiveCommand = new ArchiveCommand();
|
||||
await archiveCommand.execute(changeName, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -568,10 +578,11 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; archived?: boolean; report?: string; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => {
|
||||
try {
|
||||
const { ValidateCommand } = await import('../commands/validate.js');
|
||||
const validateCommand = new ValidateCommand();
|
||||
await validateCommand.execute(itemName, options);
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' });
|
||||
await failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -599,10 +610,11 @@ program
|
||||
.allowUnknownOption(true)
|
||||
.action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => {
|
||||
try {
|
||||
const { ShowCommand } = await import('../commands/show.js');
|
||||
const showCommand = new ShowCommand();
|
||||
await showCommand.execute(itemName, options ?? {});
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' });
|
||||
await failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -614,10 +626,11 @@ program
|
||||
.option('--body <text>', 'Detailed description for the feedback')
|
||||
.action(async (message: string, options?: { body?: string }) => {
|
||||
try {
|
||||
const { FeedbackCommand } = await import('../commands/feedback.js');
|
||||
const feedbackCommand = new FeedbackCommand();
|
||||
await feedbackCommand.execute(message, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -632,10 +645,11 @@ completionCmd
|
||||
.description('Generate completion script for a shell (outputs to stdout)')
|
||||
.action(async (shell?: string) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.generate({ shell });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -646,10 +660,11 @@ completionCmd
|
||||
.option('--verbose', 'Show detailed installation output')
|
||||
.action(async (shell?: string, options?: { verbose?: boolean }) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.install({ shell, verbose: options?.verbose });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -660,10 +675,11 @@ completionCmd
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (shell?: string, options?: { yes?: boolean }) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.uninstall({ shell, yes: options?.yes });
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -674,6 +690,7 @@ program
|
||||
.description('Output completion data in machine-readable format (internal use)')
|
||||
.action(async (type: string) => {
|
||||
try {
|
||||
const { CompletionCommand } = await import('../commands/completion.js');
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.complete({ type });
|
||||
} catch (error) {
|
||||
@@ -698,13 +715,16 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
const { statusCommand } = await import('../commands/workflow/status.js');
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error, {
|
||||
await failWithError(error, {
|
||||
enabled: options.json,
|
||||
// The batch null-shape; the single-change failure shape is
|
||||
// pre-existing contract and stays payload-free.
|
||||
payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
||||
payload: options.all
|
||||
? (await import('../commands/workflow/status.js')).BATCH_STATUS_FAILURE_PAYLOAD
|
||||
: undefined,
|
||||
fallbackCode: 'change_error',
|
||||
});
|
||||
process.exit(1);
|
||||
@@ -722,6 +742,8 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
const { applyInstructionsCommand, archiveInstructionsCommand, instructionsCommand } =
|
||||
await import('../commands/workflow/instructions.js');
|
||||
// Workflow instruction surfaces are reserved command branches, not artifacts.
|
||||
if (artifactId === 'apply') {
|
||||
await applyInstructionsCommand(options);
|
||||
@@ -731,7 +753,7 @@ program
|
||||
await instructionsCommand(artifactId, options);
|
||||
}
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
|
||||
await failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -744,9 +766,10 @@ program
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
const { templatesCommand } = await import('../commands/workflow/templates.js');
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -760,9 +783,10 @@ program
|
||||
.addOption(hiddenStorePathOption())
|
||||
.action(async (options: SchemasOptions) => {
|
||||
try {
|
||||
const { schemasCommand } = await import('../commands/workflow/schemas.js');
|
||||
await schemasCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error, {
|
||||
await failWithError(error, {
|
||||
enabled: options.json,
|
||||
payload: { schemas: [], root: null },
|
||||
fallbackCode: 'schemas_error',
|
||||
@@ -789,9 +813,10 @@ newCmd
|
||||
.addOption(new Option('--areas <names>', 'No longer supported').hideHelp())
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
const { newChangeCommand } = await import('../commands/workflow/new-change.js');
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
failWithError(error);
|
||||
await failWithError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
+402
-457
@@ -1,4 +1,3 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ChildProcess, spawn as nodeSpawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
@@ -25,8 +24,6 @@ import {
|
||||
} from '../core/config-schema.js';
|
||||
import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js';
|
||||
import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
||||
import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
|
||||
|
||||
type EditorOutcome =
|
||||
@@ -326,15 +323,18 @@ export function diffProfileState(before: ProfileState, after: ProfileState): Pro
|
||||
};
|
||||
}
|
||||
|
||||
function maybeWarnProjectConfigDrift(
|
||||
async function maybeWarnProjectConfigDrift(
|
||||
projectDir: string,
|
||||
state: ProfileState,
|
||||
colorize: (message: string) => string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);
|
||||
if (!fs.existsSync(openspecDir)) {
|
||||
return;
|
||||
}
|
||||
// Loaded here, not at the top: it pulls in every tool's command adapter,
|
||||
// which `config path` and `config list` never need.
|
||||
const { hasProjectConfigDrift } = await import('../core/profile-sync-drift.js');
|
||||
if (!hasProjectConfigDrift(projectDir, state.workflows, state.delivery)) {
|
||||
return;
|
||||
}
|
||||
@@ -345,468 +345,413 @@ function printConfigProfileApplyGuidance(): void {
|
||||
console.log('Config updated. Run `openspec update` in your projects to apply.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the config command and all its subcommands.
|
||||
*
|
||||
* @param program - The Commander program instance
|
||||
*/
|
||||
export function registerConfigCommand(program: Command): void {
|
||||
const configCmd = program
|
||||
.command('config')
|
||||
.description('View and modify global OpenSpec configuration')
|
||||
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
|
||||
.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.scope && opts.scope !== 'global') {
|
||||
console.error('Error: Project-local config is not yet implemented');
|
||||
process.exit(1);
|
||||
export function configPathCommand(): void {
|
||||
console.log(getGlobalConfigPath());
|
||||
}
|
||||
|
||||
export function configListCommand(options: { json?: boolean }): void {
|
||||
const config = getGlobalConfig();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
// Read raw config to determine which values are explicit vs defaults
|
||||
const configPath = getGlobalConfigPath();
|
||||
let rawConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
// A non-object root holds no explicit settings, and reading a key
|
||||
// off `null` would crash this read-only command.
|
||||
if (isConfigRootObject(parsed)) {
|
||||
rawConfig = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If reading fails, treat all as defaults
|
||||
}
|
||||
|
||||
console.log(formatValueYaml(config));
|
||||
|
||||
// Annotate profile settings
|
||||
const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)';
|
||||
const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)';
|
||||
console.log(`\nProfile settings:`);
|
||||
console.log(` profile: ${config.profile} ${profileSource}`);
|
||||
console.log(` delivery: ${config.delivery} ${deliverySource}`);
|
||||
if (config.profile === 'core') {
|
||||
console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`);
|
||||
} else if (config.workflows && config.workflows.length > 0) {
|
||||
console.log(` workflows: ${config.workflows.join(', ')} (explicit)`);
|
||||
} else {
|
||||
console.log(` workflows: (none)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function configGetCommand(key: string): void {
|
||||
const config = getGlobalConfig();
|
||||
const value = getNestedValue(config as Record<string, unknown>, key);
|
||||
|
||||
if (value === undefined) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
console.log(JSON.stringify(value));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
export function configSetCommand(key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }): void {
|
||||
const allowUnknown = Boolean(options.allowUnknown);
|
||||
const keyValidation = validateConfigKeyPath(key);
|
||||
// --allow-unknown relaxes the known-key check, but never the prototype-safety check.
|
||||
const unsafeKey = hasUnsafeKeySegment(key);
|
||||
if (!keyValidation.valid && (!allowUnknown || unsafeKey)) {
|
||||
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
|
||||
console.error(`Error: Invalid configuration key "${key}".${reason}`);
|
||||
console.error('Use "openspec config list" to see available keys.');
|
||||
if (!allowUnknown && !unsafeKey) {
|
||||
console.error('Pass --allow-unknown to bypass this check.');
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const coercedValue = coerceValue(value, options.string || false);
|
||||
|
||||
// Create a copy to validate before saving
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
setNestedValue(newConfig, key, coercedValue);
|
||||
|
||||
// Validate the new config
|
||||
const validation = validateConfig(newConfig);
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes and save
|
||||
setNestedValue(config, key, coercedValue);
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
|
||||
const displayValue =
|
||||
typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue);
|
||||
console.log(`Set ${key} = ${displayValue}`);
|
||||
}
|
||||
|
||||
export function configUnsetCommand(key: string): void {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const existed = deleteNestedValue(config, key);
|
||||
|
||||
if (existed) {
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
console.log(`Unset ${key} (reverted to default)`);
|
||||
} else {
|
||||
console.log(`Key "${key}" was not set`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function configResetCommand(options: { all?: boolean; yes?: boolean }): Promise<void> {
|
||||
if (!options.all) {
|
||||
console.error('Error: --all flag is required for reset');
|
||||
console.error('Usage: openspec config reset --all [-y]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirm({
|
||||
message: 'Reset all configuration to defaults?',
|
||||
default: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Reset cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Reset cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A reset is the one write meant to replace a file that cannot be parsed.
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
|
||||
console.log('Configuration reset to defaults');
|
||||
}
|
||||
|
||||
export async function configEditCommand(): Promise<void> {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL;
|
||||
|
||||
if (!editor) {
|
||||
console.error('Error: No editor configured');
|
||||
console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');
|
||||
console.error('Example: export EDITOR=vim');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
// Ensure config file exists with defaults
|
||||
if (!fs.existsSync(configPath)) {
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
}
|
||||
|
||||
// Wait for the editor to close; a failure is reported, never thrown.
|
||||
const outcome = await runEditor(editor, configPath);
|
||||
if ('error' in outcome || outcome.code !== 0) {
|
||||
reportEditorFailure(editor, outcome);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(rawConfig);
|
||||
const validation = validateConfig(parsedConfig);
|
||||
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.error(`Error: Config file not found at ${configPath}`);
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.error(`Error: Invalid JSON in ${configPath}`);
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function configProfileCommand(preset?: string): Promise<void> {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preset shortcut: `openspec config profile core`
|
||||
if (preset === 'core') {
|
||||
const config = getGlobalConfig();
|
||||
config.profile = 'core';
|
||||
config.workflows = [...CORE_WORKFLOWS];
|
||||
// Preserve delivery setting
|
||||
saveGlobalConfig(config);
|
||||
printConfigProfileApplyGuidance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
console.error(`Error: Unknown profile preset "${preset}". Available presets: core`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-interactive check
|
||||
if (!process.stdout.isTTY) {
|
||||
console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive picker
|
||||
const { select, checkbox, confirm } = await import('@inquirer/prompts');
|
||||
const chalk = (await import('chalk')).default;
|
||||
|
||||
try {
|
||||
const config = getGlobalConfig();
|
||||
const currentState = resolveCurrentProfileState(config);
|
||||
|
||||
console.log(chalk.bold('\nCurrent profile settings'));
|
||||
console.log(` Delivery: ${currentState.delivery}`);
|
||||
console.log(` Workflows: ${formatWorkflowSummary(currentState.workflows, currentState.profile)}`);
|
||||
console.log(chalk.dim(' Delivery = where workflows are installed (skills, commands, or both)'));
|
||||
console.log(chalk.dim(' Workflows = which actions are available (propose, explore, apply, etc.)'));
|
||||
console.log();
|
||||
|
||||
const action = await select<ProfileAction>({
|
||||
message: 'What do you want to configure?',
|
||||
choices: [
|
||||
{
|
||||
value: 'both',
|
||||
name: 'Delivery and workflows',
|
||||
description: 'Update install mode and available actions together',
|
||||
},
|
||||
{
|
||||
value: 'delivery',
|
||||
name: 'Delivery only',
|
||||
description: 'Change where workflows are installed',
|
||||
},
|
||||
{
|
||||
value: 'workflows',
|
||||
name: 'Workflows only',
|
||||
description: 'Change which workflow actions are available',
|
||||
},
|
||||
{
|
||||
value: 'keep',
|
||||
name: 'Keep current settings (exit)',
|
||||
description: 'Leave configuration unchanged and exit',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// config path
|
||||
configCmd
|
||||
.command('path')
|
||||
.description('Show config file location')
|
||||
.action(() => {
|
||||
console.log(getGlobalConfigPath());
|
||||
});
|
||||
if (action === 'keep') {
|
||||
console.log('No config changes.');
|
||||
await maybeWarnProjectConfigDrift(process.cwd(), currentState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
// config list
|
||||
configCmd
|
||||
.command('list')
|
||||
.description('Show all current settings')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action((options: { json?: boolean }) => {
|
||||
const config = getGlobalConfig();
|
||||
const nextState: ProfileState = {
|
||||
profile: currentState.profile,
|
||||
delivery: currentState.delivery,
|
||||
workflows: [...currentState.workflows],
|
||||
};
|
||||
let workflowSelectionChanged = false;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
// Read raw config to determine which values are explicit vs defaults
|
||||
const configPath = getGlobalConfigPath();
|
||||
let rawConfig: Record<string, unknown> = {};
|
||||
if (action === 'both' || action === 'delivery') {
|
||||
const deliveryChoices: { value: Delivery; name: string; description: string }[] = [
|
||||
{
|
||||
value: 'both' as Delivery,
|
||||
name: 'Both (skills + commands)',
|
||||
description: 'Install workflows as both skills and slash commands',
|
||||
},
|
||||
{
|
||||
value: 'skills' as Delivery,
|
||||
name: 'Skills only',
|
||||
description: 'Install workflows only as skills',
|
||||
},
|
||||
{
|
||||
value: 'commands' as Delivery,
|
||||
name: 'Commands only',
|
||||
description: 'Install workflows only as slash commands',
|
||||
},
|
||||
];
|
||||
for (const choice of deliveryChoices) {
|
||||
if (choice.value === currentState.delivery) {
|
||||
choice.name += ' [current]';
|
||||
}
|
||||
}
|
||||
|
||||
nextState.delivery = await select<Delivery>({
|
||||
message: 'Delivery mode (how workflows are installed):',
|
||||
choices: deliveryChoices,
|
||||
default: currentState.delivery,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'both' || action === 'workflows') {
|
||||
const formatWorkflowChoice = (workflow: string) => {
|
||||
const metadata = WORKFLOW_PROMPT_META[workflow] ?? {
|
||||
name: workflow,
|
||||
description: `Workflow: ${workflow}`,
|
||||
};
|
||||
return {
|
||||
value: workflow,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
short: metadata.name,
|
||||
checked: currentState.workflows.includes(workflow),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedWorkflows = await checkbox<string>({
|
||||
// The `instructions` option was removed in @inquirer/checkbox v5.
|
||||
// Its replacement, the built-in keys help tip, renders
|
||||
// "↑↓ navigate • space select • ⏎ submit" by default — a superset of
|
||||
// the hint this used to pass — so no theme override is needed here.
|
||||
message: 'Select workflows to make available:',
|
||||
pageSize: ALL_WORKFLOWS.length,
|
||||
theme: {
|
||||
icon: {
|
||||
checked: '[x]',
|
||||
unchecked: '[ ]',
|
||||
},
|
||||
},
|
||||
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
|
||||
});
|
||||
nextState.workflows = selectedWorkflows;
|
||||
workflowSelectionChanged =
|
||||
selectedWorkflows.length !== currentState.workflows.length ||
|
||||
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
|
||||
nextState.profile = workflowSelectionChanged
|
||||
? deriveProfileFromWorkflowSelection(selectedWorkflows)
|
||||
: currentState.profile;
|
||||
}
|
||||
|
||||
const diff = diffProfileState(currentState, nextState);
|
||||
if (!diff.hasChanges) {
|
||||
console.log('No config changes.');
|
||||
await maybeWarnProjectConfigDrift(process.cwd(), nextState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nConfig changes:'));
|
||||
for (const line of diff.lines) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
config.profile = nextState.profile;
|
||||
config.delivery = nextState.delivery;
|
||||
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
|
||||
config.workflows = nextState.workflows;
|
||||
}
|
||||
saveGlobalConfig(config);
|
||||
|
||||
// Check if inside an OpenSpec project
|
||||
const projectDir = process.cwd();
|
||||
const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);
|
||||
if (fs.existsSync(openspecDir)) {
|
||||
const applyNow = await confirm({
|
||||
message: 'Apply changes to this project now?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (applyNow) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
// A non-object root holds no explicit settings, and reading a key
|
||||
// off `null` would crash this read-only command.
|
||||
if (isConfigRootObject(parsed)) {
|
||||
rawConfig = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If reading fails, treat all as defaults
|
||||
}
|
||||
|
||||
console.log(formatValueYaml(config));
|
||||
|
||||
// Annotate profile settings
|
||||
const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)';
|
||||
const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)';
|
||||
console.log(`\nProfile settings:`);
|
||||
console.log(` profile: ${config.profile} ${profileSource}`);
|
||||
console.log(` delivery: ${config.delivery} ${deliverySource}`);
|
||||
if (config.profile === 'core') {
|
||||
console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`);
|
||||
} else if (config.workflows && config.workflows.length > 0) {
|
||||
console.log(` workflows: ${config.workflows.join(', ')} (explicit)`);
|
||||
} else {
|
||||
console.log(` workflows: (none)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// config get
|
||||
configCmd
|
||||
.command('get <key>')
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig();
|
||||
const value = getNestedValue(config as Record<string, unknown>, key);
|
||||
|
||||
if (value === undefined) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
console.log(JSON.stringify(value));
|
||||
} else {
|
||||
console.log(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// config set
|
||||
configCmd
|
||||
.command('set <key> <value>')
|
||||
.description('Set a value (auto-coerce types)')
|
||||
.option('--string', 'Force value to be stored as string')
|
||||
.option('--allow-unknown', 'Allow setting unknown keys')
|
||||
.action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {
|
||||
const allowUnknown = Boolean(options.allowUnknown);
|
||||
const keyValidation = validateConfigKeyPath(key);
|
||||
// --allow-unknown relaxes the known-key check, but never the prototype-safety check.
|
||||
const unsafeKey = hasUnsafeKeySegment(key);
|
||||
if (!keyValidation.valid && (!allowUnknown || unsafeKey)) {
|
||||
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
|
||||
console.error(`Error: Invalid configuration key "${key}".${reason}`);
|
||||
console.error('Use "openspec config list" to see available keys.');
|
||||
if (!allowUnknown && !unsafeKey) {
|
||||
console.error('Pass --allow-unknown to bypass this check.');
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const coercedValue = coerceValue(value, options.string || false);
|
||||
|
||||
// Create a copy to validate before saving
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
setNestedValue(newConfig, key, coercedValue);
|
||||
|
||||
// Validate the new config
|
||||
const validation = validateConfig(newConfig);
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes and save
|
||||
setNestedValue(config, key, coercedValue);
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
|
||||
const displayValue =
|
||||
typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue);
|
||||
console.log(`Set ${key} = ${displayValue}`);
|
||||
});
|
||||
|
||||
// config unset
|
||||
configCmd
|
||||
.command('unset <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action((key: string) => {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
const existed = deleteNestedValue(config, key);
|
||||
|
||||
if (existed) {
|
||||
saveGlobalConfig(config as GlobalConfig);
|
||||
console.log(`Unset ${key} (reverted to default)`);
|
||||
} else {
|
||||
console.log(`Key "${key}" was not set`);
|
||||
}
|
||||
});
|
||||
|
||||
// config reset
|
||||
configCmd
|
||||
.command('reset')
|
||||
.description('Reset configuration to defaults')
|
||||
.option('--all', 'Reset all configuration (required)')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (options: { all?: boolean; yes?: boolean }) => {
|
||||
if (!options.all) {
|
||||
console.error('Error: --all flag is required for reset');
|
||||
console.error('Usage: openspec config reset --all [-y]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirm({
|
||||
message: 'Reset all configuration to defaults?',
|
||||
default: false,
|
||||
});
|
||||
const { UpdateCommand } = await import('../core/update.js');
|
||||
await new UpdateCommand().execute(projectDir);
|
||||
console.log('Run `openspec update` in your other projects to apply.');
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Reset cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Reset cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A reset is the one write meant to replace a file that cannot be parsed.
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG }, { replaceUnreadable: true });
|
||||
console.log('Configuration reset to defaults');
|
||||
});
|
||||
|
||||
// config edit
|
||||
configCmd
|
||||
.command('edit')
|
||||
.description('Open config in $EDITOR')
|
||||
.action(async () => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL;
|
||||
|
||||
if (!editor) {
|
||||
console.error('Error: No editor configured');
|
||||
console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');
|
||||
console.error('Example: export EDITOR=vim');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
// Ensure config file exists with defaults
|
||||
if (!fs.existsSync(configPath)) {
|
||||
saveGlobalConfig({ ...DEFAULT_CONFIG });
|
||||
}
|
||||
|
||||
// Wait for the editor to close; a failure is reported, never thrown.
|
||||
const outcome = await runEditor(editor, configPath);
|
||||
if ('error' in outcome || outcome.code !== 0) {
|
||||
reportEditorFailure(editor, outcome);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(rawConfig);
|
||||
const validation = validateConfig(parsedConfig);
|
||||
|
||||
if (!validation.success) {
|
||||
console.error(`Error: Invalid configuration - ${validation.error}`);
|
||||
console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`);
|
||||
console.error('Please run it manually to apply the profile changes.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.error(`Error: Config file not found at ${configPath}`);
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.error(`Error: Invalid JSON in ${configPath}`);
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// config profile [preset]
|
||||
configCmd
|
||||
.command('profile [preset]')
|
||||
.description('Configure workflow profile (interactive picker or preset shortcut)')
|
||||
.action(async (preset?: string) => {
|
||||
if (refuseUnreadableConfig()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preset shortcut: `openspec config profile core`
|
||||
if (preset === 'core') {
|
||||
const config = getGlobalConfig();
|
||||
config.profile = 'core';
|
||||
config.workflows = [...CORE_WORKFLOWS];
|
||||
// Preserve delivery setting
|
||||
saveGlobalConfig(config);
|
||||
printConfigProfileApplyGuidance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset) {
|
||||
console.error(`Error: Unknown profile preset "${preset}". Available presets: core`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-interactive check
|
||||
if (!process.stdout.isTTY) {
|
||||
console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive picker
|
||||
const { select, checkbox, confirm } = await import('@inquirer/prompts');
|
||||
const chalk = (await import('chalk')).default;
|
||||
|
||||
try {
|
||||
const config = getGlobalConfig();
|
||||
const currentState = resolveCurrentProfileState(config);
|
||||
|
||||
console.log(chalk.bold('\nCurrent profile settings'));
|
||||
console.log(` Delivery: ${currentState.delivery}`);
|
||||
console.log(` Workflows: ${formatWorkflowSummary(currentState.workflows, currentState.profile)}`);
|
||||
console.log(chalk.dim(' Delivery = where workflows are installed (skills, commands, or both)'));
|
||||
console.log(chalk.dim(' Workflows = which actions are available (propose, explore, apply, etc.)'));
|
||||
console.log();
|
||||
|
||||
const action = await select<ProfileAction>({
|
||||
message: 'What do you want to configure?',
|
||||
choices: [
|
||||
{
|
||||
value: 'both',
|
||||
name: 'Delivery and workflows',
|
||||
description: 'Update install mode and available actions together',
|
||||
},
|
||||
{
|
||||
value: 'delivery',
|
||||
name: 'Delivery only',
|
||||
description: 'Change where workflows are installed',
|
||||
},
|
||||
{
|
||||
value: 'workflows',
|
||||
name: 'Workflows only',
|
||||
description: 'Change which workflow actions are available',
|
||||
},
|
||||
{
|
||||
value: 'keep',
|
||||
name: 'Keep current settings (exit)',
|
||||
description: 'Leave configuration unchanged and exit',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (action === 'keep') {
|
||||
console.log('No config changes.');
|
||||
maybeWarnProjectConfigDrift(process.cwd(), currentState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState: ProfileState = {
|
||||
profile: currentState.profile,
|
||||
delivery: currentState.delivery,
|
||||
workflows: [...currentState.workflows],
|
||||
};
|
||||
let workflowSelectionChanged = false;
|
||||
|
||||
if (action === 'both' || action === 'delivery') {
|
||||
const deliveryChoices: { value: Delivery; name: string; description: string }[] = [
|
||||
{
|
||||
value: 'both' as Delivery,
|
||||
name: 'Both (skills + commands)',
|
||||
description: 'Install workflows as both skills and slash commands',
|
||||
},
|
||||
{
|
||||
value: 'skills' as Delivery,
|
||||
name: 'Skills only',
|
||||
description: 'Install workflows only as skills',
|
||||
},
|
||||
{
|
||||
value: 'commands' as Delivery,
|
||||
name: 'Commands only',
|
||||
description: 'Install workflows only as slash commands',
|
||||
},
|
||||
];
|
||||
for (const choice of deliveryChoices) {
|
||||
if (choice.value === currentState.delivery) {
|
||||
choice.name += ' [current]';
|
||||
}
|
||||
}
|
||||
|
||||
nextState.delivery = await select<Delivery>({
|
||||
message: 'Delivery mode (how workflows are installed):',
|
||||
choices: deliveryChoices,
|
||||
default: currentState.delivery,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'both' || action === 'workflows') {
|
||||
const formatWorkflowChoice = (workflow: string) => {
|
||||
const metadata = WORKFLOW_PROMPT_META[workflow] ?? {
|
||||
name: workflow,
|
||||
description: `Workflow: ${workflow}`,
|
||||
};
|
||||
return {
|
||||
value: workflow,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
short: metadata.name,
|
||||
checked: currentState.workflows.includes(workflow),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedWorkflows = await checkbox<string>({
|
||||
// The `instructions` option was removed in @inquirer/checkbox v5.
|
||||
// Its replacement, the built-in keys help tip, renders
|
||||
// "↑↓ navigate • space select • ⏎ submit" by default — a superset of
|
||||
// the hint this used to pass — so no theme override is needed here.
|
||||
message: 'Select workflows to make available:',
|
||||
pageSize: ALL_WORKFLOWS.length,
|
||||
theme: {
|
||||
icon: {
|
||||
checked: '[x]',
|
||||
unchecked: '[ ]',
|
||||
},
|
||||
},
|
||||
choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
|
||||
});
|
||||
nextState.workflows = selectedWorkflows;
|
||||
workflowSelectionChanged =
|
||||
selectedWorkflows.length !== currentState.workflows.length ||
|
||||
selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
|
||||
nextState.profile = workflowSelectionChanged
|
||||
? deriveProfileFromWorkflowSelection(selectedWorkflows)
|
||||
: currentState.profile;
|
||||
}
|
||||
|
||||
const diff = diffProfileState(currentState, nextState);
|
||||
if (!diff.hasChanges) {
|
||||
console.log('No config changes.');
|
||||
maybeWarnProjectConfigDrift(process.cwd(), nextState, chalk.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nConfig changes:'));
|
||||
for (const line of diff.lines) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
config.profile = nextState.profile;
|
||||
config.delivery = nextState.delivery;
|
||||
if (currentState.profile !== 'custom' || workflowSelectionChanged) {
|
||||
config.workflows = nextState.workflows;
|
||||
}
|
||||
saveGlobalConfig(config);
|
||||
|
||||
// Check if inside an OpenSpec project
|
||||
const projectDir = process.cwd();
|
||||
const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME);
|
||||
if (fs.existsSync(openspecDir)) {
|
||||
const applyNow = await confirm({
|
||||
message: 'Apply changes to this project now?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (applyNow) {
|
||||
try {
|
||||
await new UpdateCommand().execute(projectDir);
|
||||
console.log('Run `openspec update` in your other projects to apply.');
|
||||
} catch (error) {
|
||||
console.error(`\`openspec update\` failed: ${asErrorMessage(error)}`);
|
||||
console.error('Please run it manually to apply the profile changes.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
printConfigProfileApplyGuidance();
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Config profile cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
printConfigProfileApplyGuidance();
|
||||
} catch (error) {
|
||||
if (isPromptCancellationError(error)) {
|
||||
console.log('Config profile cancelled.');
|
||||
process.exitCode = 130;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+32
-51
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import {
|
||||
resolveRootForCommand,
|
||||
@@ -22,8 +21,6 @@ import {
|
||||
type WorkingSetMember,
|
||||
} from '../core/working-set.js';
|
||||
import { StoreError } from '../core/store/errors.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { emitFailure, printJson } from './shared-output.js';
|
||||
import { gatherRelationshipData } from './shared-gather.js';
|
||||
|
||||
@@ -157,56 +154,40 @@ function writeCodeWorkspace(
|
||||
console.error(summary);
|
||||
}
|
||||
|
||||
export function registerContextCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'context')?.description ??
|
||||
'Print the working context for the resolved OpenSpec root';
|
||||
export interface ContextOptions {
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
json?: boolean;
|
||||
codeWorkspace?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
program
|
||||
.command('context')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output the agent brief as JSON')
|
||||
.option('--code-workspace <path>', 'Also write a VS Code workspace file for the set')
|
||||
.option('--force', 'Overwrite an existing --code-workspace file')
|
||||
.action(
|
||||
async (options: {
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
json?: boolean;
|
||||
codeWorkspace?: string;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
export async function contextCommand(options: ContextOptions): Promise<void> {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { workingSet, declaredReferenceCount } = await gatherWorkingSet(root);
|
||||
const { workingSet, declaredReferenceCount } = await gatherWorkingSet(root);
|
||||
|
||||
if (options.json) {
|
||||
// The write runs FIRST: a write failure must leave stdout
|
||||
// holding exactly one JSON document (the failure payload).
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
printJson(workingSet);
|
||||
} else {
|
||||
printHumanWorkingSet(workingSet, declaredReferenceCount);
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'context_failed');
|
||||
}
|
||||
if (options.json) {
|
||||
// The write runs FIRST: a write failure must leave stdout
|
||||
// holding exactly one JSON document (the failure payload).
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
printJson(workingSet);
|
||||
} else {
|
||||
printHumanWorkingSet(workingSet, declaredReferenceCount);
|
||||
if (options.codeWorkspace) {
|
||||
writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true);
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'context_failed');
|
||||
}
|
||||
}
|
||||
|
||||
+23
-35
@@ -3,8 +3,6 @@
|
||||
* report. Read-only — it answers "are the roots this work relates to
|
||||
* available on this machine?" and never clones, syncs, or repairs.
|
||||
*/
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import {
|
||||
resolveRootForCommand,
|
||||
type ResolvedOpenSpecRoot,
|
||||
@@ -23,8 +21,6 @@ import {
|
||||
type InspectRelationshipsInput,
|
||||
type RelationshipHealth,
|
||||
} from '../core/relationship-health.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
|
||||
import { emitFailure, printJson } from './shared-output.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
@@ -182,38 +178,30 @@ function printHumanHealth(health: RelationshipHealth, declaredReferenceCount: nu
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDoctorCommand(program: Command): void {
|
||||
const description =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'doctor')?.description ??
|
||||
'Report relationship health for the resolved OpenSpec root';
|
||||
export interface DoctorOptions {
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description(description)
|
||||
.option('--store <id>', COMMON_FLAGS.store.description)
|
||||
.addOption(
|
||||
new Option('--store-path <path>', 'Removed; register the store and use --store').hideHelp()
|
||||
)
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: { store?: string; storePath?: string; json?: boolean }) => {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
export async function doctorCommand(options: DoctorOptions): Promise<void> {
|
||||
try {
|
||||
const root = await resolveRootForCommand(
|
||||
{ store: options.store, storePath: options.storePath },
|
||||
{ json: options.json, failurePayload: FAILURE_PAYLOAD, allowImplicitRoot: false }
|
||||
);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { health, declaredReferenceCount } = await gatherHealth(root);
|
||||
const { health, declaredReferenceCount } = await gatherHealth(root);
|
||||
|
||||
if (options.json) {
|
||||
printJson(health);
|
||||
return;
|
||||
}
|
||||
printHumanHealth(health, declaredReferenceCount);
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'doctor_failed');
|
||||
}
|
||||
});
|
||||
if (options.json) {
|
||||
printJson(health);
|
||||
return;
|
||||
}
|
||||
printHumanHealth(health, declaredReferenceCount);
|
||||
} catch (error) {
|
||||
emitFailure(options.json, FAILURE_PAYLOAD, error, 'doctor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
+814
-857
File diff suppressed because it is too large
Load Diff
+102
-129
@@ -1,4 +1,3 @@
|
||||
import { program } from 'commander';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
||||
@@ -32,7 +31,7 @@ function assertSpecPath(specsDir: string, specPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface ShowOptions {
|
||||
export interface ShowOptions {
|
||||
json?: boolean;
|
||||
// JSON-only filters (raw-first text has no filters)
|
||||
requirements?: boolean;
|
||||
@@ -148,142 +147,116 @@ export class SpecCommand {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSpecCommand(rootProgram: typeof program) {
|
||||
const specCommand = rootProgram
|
||||
.command('spec')
|
||||
.description('Manage and view OpenSpec specifications');
|
||||
export async function specShowCommand(
|
||||
specId: string | undefined,
|
||||
options: ShowOptions & { noInteractive?: boolean }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cmd = new SpecCommand();
|
||||
await cmd.show(specId, options as any);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecation notice for noun-based commands
|
||||
specCommand.hook('preAction', () => {
|
||||
console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").');
|
||||
});
|
||||
export async function specListCommand(options: { json?: boolean; long?: boolean }): Promise<void> {
|
||||
try {
|
||||
if (!existsSync(SPECS_DIR)) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
|
||||
specCommand
|
||||
.command('show [spec-id]')
|
||||
.description('Display a specific specification')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
|
||||
.option('--no-scenarios', 'JSON only: Exclude scenario content')
|
||||
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => {
|
||||
try {
|
||||
const cmd = new SpecCommand();
|
||||
await cmd.show(specId, options as any);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
const discovered = await discoverSpecFiles(SPECS_DIR);
|
||||
const specs = discovered
|
||||
.map(({ id, specFile }) => {
|
||||
try {
|
||||
assertSpecPath(SPECS_DIR, specFile);
|
||||
const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
|
||||
|
||||
specCommand
|
||||
.command('list')
|
||||
.description('List all available specifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--long', 'Show id and title with counts')
|
||||
.action(async (options: { json?: boolean; long?: boolean }) => {
|
||||
try {
|
||||
if (!existsSync(SPECS_DIR)) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
return {
|
||||
id,
|
||||
title: spec.name,
|
||||
requirementCount: spec.requirements.length
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
requirementCount: 0
|
||||
};
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const discovered = await discoverSpecFiles(SPECS_DIR);
|
||||
const specs = discovered
|
||||
.map(({ id, specFile }) => {
|
||||
try {
|
||||
assertSpecPath(SPECS_DIR, specFile);
|
||||
const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(specs, null, 2));
|
||||
} else {
|
||||
if (specs.length === 0) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
if (!options.long) {
|
||||
specs.forEach(spec => console.log(spec.id));
|
||||
return;
|
||||
}
|
||||
specs.forEach(spec => {
|
||||
console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: spec.name,
|
||||
requirementCount: spec.requirements.length
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
requirementCount: 0
|
||||
};
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(specs, null, 2));
|
||||
} else {
|
||||
if (specs.length === 0) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
if (!options.long) {
|
||||
specs.forEach(spec => console.log(spec.id));
|
||||
return;
|
||||
}
|
||||
specs.forEach(spec => {
|
||||
console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
export async function specValidateCommand(
|
||||
specId: string | undefined,
|
||||
options: { strict?: boolean; json?: boolean; noInteractive?: boolean }
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
});
|
||||
} else {
|
||||
throw new Error('Missing required argument <spec-id>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
specCommand
|
||||
.command('validate [spec-id]')
|
||||
.description('Validate a specification structure')
|
||||
.option('--strict', 'Enable strict validation mode')
|
||||
.option('--json', 'Output validation report as JSON')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
});
|
||||
} else {
|
||||
throw new Error('Missing required argument <spec-id>');
|
||||
}
|
||||
}
|
||||
|
||||
const specPath = join(SPECS_DIR, specId, 'spec.md');
|
||||
assertSpecPath(SPECS_DIR, specPath);
|
||||
|
||||
if (!existsSync(specPath)) {
|
||||
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
|
||||
}
|
||||
const specPath = join(SPECS_DIR, specId, 'spec.md');
|
||||
assertSpecPath(SPECS_DIR, specPath);
|
||||
|
||||
if (!existsSync(specPath)) {
|
||||
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
|
||||
}
|
||||
|
||||
const validator = new Validator(options.strict);
|
||||
assertSpecPath(SPECS_DIR, specPath);
|
||||
const report = await validator.validateSpec(specPath);
|
||||
const validator = new Validator(options.strict);
|
||||
assertSpecPath(SPECS_DIR, specPath);
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
if (report.valid) {
|
||||
console.log(`Specification '${specId}' is valid`);
|
||||
} else {
|
||||
console.error(`Specification '${specId}' has issues`);
|
||||
report.issues.forEach(issue => {
|
||||
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
if (report.valid) {
|
||||
console.log(`Specification '${specId}' is valid`);
|
||||
} else {
|
||||
console.error(`Specification '${specId}' has issues`);
|
||||
report.issues.forEach(issue => {
|
||||
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
|
||||
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
||||
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
process.exitCode = report.valid ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-148
@@ -1,9 +1,6 @@
|
||||
import * as os from 'node:os';
|
||||
import { asErrorMessage, emitFailure, printJson } from './shared-output.js';
|
||||
import * as path from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
|
||||
import {
|
||||
StoreError,
|
||||
@@ -28,25 +25,25 @@ import {
|
||||
} from '../core/store/index.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
|
||||
interface StoreSetupOptions {
|
||||
export interface StoreSetupOptions {
|
||||
path?: string;
|
||||
initGit?: boolean;
|
||||
json?: boolean;
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
interface StoreRegisterOptions {
|
||||
export interface StoreRegisterOptions {
|
||||
id?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface StoreRemoveOptions {
|
||||
export interface StoreRemoveOptions {
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface StoreJsonOptions {
|
||||
export interface StoreJsonOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
@@ -516,7 +513,7 @@ function printDoctorHuman(payload: StoreDoctorOutput): void {
|
||||
}
|
||||
}
|
||||
|
||||
class StoreCommand {
|
||||
export class StoreCommand {
|
||||
async setup(id: string | undefined, options: StoreSetupOptions = {}): Promise<void> {
|
||||
try {
|
||||
const setupInput = await resolveSetupInput(id, options);
|
||||
@@ -660,143 +657,3 @@ class StoreCommand {
|
||||
emitFailure(json, payload, error, 'store_error');
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStoreCommand(program: Command): void {
|
||||
const storeCommand = new StoreCommand();
|
||||
// One source for the locked group one-liner: the completions registry
|
||||
// entry, which shell completion scripts also consume.
|
||||
const storeGroupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'store')?.description ??
|
||||
'Create and manage stores - standalone OpenSpec repos you register on this machine';
|
||||
const store = program.command('store').description(storeGroupDescription);
|
||||
|
||||
store
|
||||
.command('setup [id]')
|
||||
.description('Create and register a local store')
|
||||
.option('--path <path>', 'Folder where the store should live (for example ~/openspec/<id>)')
|
||||
.option('--init-git', 'Initialize a Git repository with an initial commit (default)')
|
||||
.option('--no-init-git', 'Skip every Git action: no init, no initial commit')
|
||||
.option('--remote <url>', 'Canonical clone source recorded in store.yaml')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreSetupOptions) => {
|
||||
await storeCommand.setup(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('register [path]')
|
||||
.description('Register an existing local store')
|
||||
.option('--id <id>', 'Store id; defaults to metadata or folder name')
|
||||
.option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (inputPath: string | undefined, options: StoreRegisterOptions) => {
|
||||
await storeCommand.register(inputPath, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('unregister <id>')
|
||||
.description('Forget a local store registration without deleting files')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreJsonOptions) => {
|
||||
await storeCommand.unregister(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('remove <id>')
|
||||
.description('Forget a local store registration and delete its local folder')
|
||||
.option('--yes', 'Confirm local store folder deletion')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string, options: StoreRemoveOptions) => {
|
||||
await storeCommand.remove(id, options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List locally registered stores')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StoreJsonOptions) => {
|
||||
await storeCommand.list(options);
|
||||
});
|
||||
|
||||
store
|
||||
.command('doctor [id]')
|
||||
.description('Check local store registration and metadata')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id: string | undefined, options: StoreJsonOptions) => {
|
||||
await storeCommand.doctor(id, options);
|
||||
});
|
||||
|
||||
const lifecycleRedirects = new Set(
|
||||
COMMAND_REGISTRY.filter(
|
||||
(entry) =>
|
||||
entry.flags.some((flag) => flag.name === 'store') ||
|
||||
(entry.subcommands ?? []).some((subcommand) =>
|
||||
subcommand.flags.some((flag) => flag.name === 'store')
|
||||
)
|
||||
).map((entry) => entry.name)
|
||||
);
|
||||
const storeSubcommandsLine = store.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0 ? `${subcommand.name()} (${aliases.join(', ')})` : subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
// One group action owns missing AND unknown subcommands. Known
|
||||
// subcommands dispatch above; everything else — including a bare
|
||||
// `store --json` with no operand — lands here, so the handler owns the
|
||||
// entire message and exit path (same text for human and --json). The
|
||||
// permissive flags route unknown operands/options here instead of
|
||||
// letting Commander emit a raw error before the action runs. We detect
|
||||
// `--json` in the residual args rather than declaring a group option,
|
||||
// which would otherwise shadow each subcommand's own `--json` flag.
|
||||
store.allowExcessArguments(true);
|
||||
store.allowUnknownOption(true);
|
||||
store.action(() => {
|
||||
const operands = store.args;
|
||||
// Flag values are indistinguishable from operands without a full
|
||||
// parse, so the verbatim echo only applies to plain-operand input.
|
||||
const attempted = operands.filter((operand) => !operand.startsWith('-'));
|
||||
const hasFlagLikeToken = operands.some((operand) => operand.startsWith('-'));
|
||||
// The agent contract: --json failures emit one JSON document.
|
||||
if (operands.includes('--json')) {
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`;
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_store_subcommand',
|
||||
message,
|
||||
fix: 'Run a store subcommand, or use the lifecycle command with --store <id>.',
|
||||
},
|
||||
],
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let example = 'openspec new change <change-id> --store <id>';
|
||||
if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) {
|
||||
if (attempted[0] === 'new') {
|
||||
const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : '<change-id>';
|
||||
example = `openspec new change ${changeId} --store <id>`;
|
||||
} else {
|
||||
example = `openspec ${attempted.join(' ')} --store <id>`;
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
attempted.length > 0
|
||||
? `Error: unknown command '${attempted[0]}' for 'openspec store'.`
|
||||
: "Error: missing subcommand for 'openspec store'."
|
||||
);
|
||||
console.error(
|
||||
`Store subcommands manage store registration: ${storeSubcommandsLine}.`
|
||||
);
|
||||
console.error(
|
||||
'To create or work on a change in a store, use the normal command with --store, for example:'
|
||||
);
|
||||
console.error(` ${example}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Its own module so the CLI's command definitions can show it in help text
|
||||
// without loading the workflow implementation.
|
||||
export const DEFAULT_SCHEMA = 'spec-driven';
|
||||
@@ -672,7 +672,7 @@ export async function generateApplyInstructions(
|
||||
total > 0
|
||||
) {
|
||||
state = 'all_done';
|
||||
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
|
||||
instruction = 'All tracked tasks are complete.\nReview or verify the change as appropriate before archiving.';
|
||||
} else if (!tracksFile) {
|
||||
// No tracking file configured in schema - ready to apply
|
||||
state = 'ready';
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface ArchiveInstructions {
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_SCHEMA = 'spec-driven';
|
||||
export { DEFAULT_SCHEMA } from './default-schema.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utility Functions
|
||||
|
||||
+4
-108
@@ -9,7 +9,6 @@
|
||||
import * as os from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { spawn as nodeSpawn } from 'node:child_process';
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import {
|
||||
buildWorksetCodeWorkspaceJson,
|
||||
@@ -64,7 +63,6 @@ import {
|
||||
promptOpenNow,
|
||||
promptToolFromChoices,
|
||||
} from './workset-prompts.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
|
||||
// cross-spawn is CJS with no types and only `workset open` needs it -
|
||||
// loaded lazily so every other CLI invocation skips its module graph.
|
||||
@@ -77,18 +75,18 @@ function defaultSpawn(): typeof nodeSpawn {
|
||||
return cachedSpawn;
|
||||
}
|
||||
|
||||
interface WorksetCreateOptions {
|
||||
export interface WorksetCreateOptions {
|
||||
member?: string[];
|
||||
tool?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface WorksetOpenOptions {
|
||||
export interface WorksetOpenOptions {
|
||||
tool?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface WorksetRemoveOptions {
|
||||
export interface WorksetRemoveOptions {
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
@@ -198,7 +196,7 @@ interface PreparedOpen {
|
||||
codeWorkspacePath: string;
|
||||
}
|
||||
|
||||
class WorksetCommand {
|
||||
export class WorksetCommand {
|
||||
async create(
|
||||
name: string | undefined,
|
||||
options: WorksetCreateOptions = {}
|
||||
@@ -553,105 +551,3 @@ class WorksetCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectMember(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function registerWorksetCommand(program: Command): void {
|
||||
const worksetCommand = new WorksetCommand();
|
||||
const groupDescription =
|
||||
COMMAND_REGISTRY.find((entry) => entry.name === 'workset')?.description ??
|
||||
'Compose, keep, and open personal working views (purely local)';
|
||||
const workset = program.command('workset').description(groupDescription);
|
||||
// Parsed at the group level so `openspec workset --json` keeps the
|
||||
// one-JSON-document contract instead of a raw Commander error. The
|
||||
// parent option matches anywhere; actions read optsWithGlobals().
|
||||
workset.addOption(new Option('--json', 'Output as JSON').hideHelp());
|
||||
|
||||
workset
|
||||
.command('create [name]')
|
||||
.description('Compose and save a named working view of folders you choose')
|
||||
.option(
|
||||
'--member <member>',
|
||||
'Member folder as <path> or <name>=<path>; repeatable, first is the primary',
|
||||
collectMember,
|
||||
[] as string[]
|
||||
)
|
||||
.option('--tool <id>', 'Preferred tool to open this workset with')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string | undefined, _options: WorksetCreateOptions, command: Command) => {
|
||||
await worksetCommand.create(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('Show saved worksets with their members')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (_options: { json?: boolean }, command: Command) => {
|
||||
await worksetCommand.list(command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('open <name>')
|
||||
.description('Open a saved workset in your tool (editor window or agent session)')
|
||||
.option('--tool <id>', 'Open with this tool just this once')
|
||||
.addOption(
|
||||
// Parsed so Commander never owns the error; rejected in the
|
||||
// action with one JSON document. Hidden because help should not
|
||||
// advertise a mode that only rejects.
|
||||
new Option('--json', 'Not supported for open').hideHelp()
|
||||
)
|
||||
.action(async (name: string, _options: WorksetOpenOptions, command: Command) => {
|
||||
await worksetCommand.open(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
workset
|
||||
.command('remove <name>')
|
||||
.description('Delete a saved workset (member folders are never touched)')
|
||||
.option('--yes', 'Confirm removal non-interactively')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (name: string, _options: WorksetRemoveOptions, command: Command) => {
|
||||
await worksetCommand.remove(name, command.optsWithGlobals());
|
||||
});
|
||||
|
||||
const subcommandsLine = workset.commands
|
||||
.map((subcommand) => {
|
||||
const aliases = subcommand.aliases();
|
||||
return aliases.length > 0
|
||||
? `${subcommand.name()} (${aliases.join(', ')})`
|
||||
: subcommand.name();
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// One handler owns missing AND unknown subcommands: known
|
||||
// subcommands dispatch above; everything else lands in this action
|
||||
// (allowExcessArguments routes the unknown operand here), keeping
|
||||
// the one-JSON-document contract for `--json` probes.
|
||||
workset.allowExcessArguments(true);
|
||||
workset.action(() => {
|
||||
const attempted = workset.args.filter(
|
||||
(operand) => !operand.startsWith('-')
|
||||
);
|
||||
const message =
|
||||
attempted.length > 0
|
||||
? `Unknown command '${attempted[0]}' for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`
|
||||
: `Missing subcommand for 'openspec workset'. Workset subcommands: ${subcommandsLine}.`;
|
||||
if (workset.opts().json) {
|
||||
printJson({
|
||||
status: [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown_workset_subcommand',
|
||||
message,
|
||||
fix: 'Run one of the workset subcommands.',
|
||||
} satisfies StoreDiagnostic,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
+14
-6
@@ -341,6 +341,14 @@ function toArchiveDiagnostic(error: unknown): ArchiveDiagnostic {
|
||||
if (isRootSelectionError(error)) {
|
||||
return error.diagnostic;
|
||||
}
|
||||
if (error instanceof RetirementCleanupError) {
|
||||
return {
|
||||
severity: 'error',
|
||||
code: 'archive_retirement_cleanup_failed',
|
||||
message: error.message,
|
||||
fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: 'error',
|
||||
code: 'archive_error',
|
||||
@@ -491,7 +499,7 @@ async function assertCopiedDirectoryUnchanged(
|
||||
* the source untouched rather than copying through a path we could not claim.
|
||||
*/
|
||||
class MoveDestinationRetainedError extends Error {}
|
||||
class RetirementBackupsRetainedError extends Error {}
|
||||
class RetirementCleanupError extends Error {}
|
||||
|
||||
function isFallbackRenameCode(code: string | undefined): boolean {
|
||||
return code === 'EPERM' || code === 'EXDEV';
|
||||
@@ -1273,14 +1281,14 @@ async function finalizeRetirementBackups(
|
||||
snapshot.displacedPath = undefined;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Could not remove the committed retirement backup at ${displacedPath} ` +
|
||||
`Could not finalize the retirement backup at ${displacedPath} ` +
|
||||
`(${error instanceof Error ? error.message : String(error)}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new RetirementBackupsRetainedError(
|
||||
`${errors.join(' ')} The change remains archived and each listed backup was retained for recovery.`
|
||||
throw new RetirementCleanupError(
|
||||
`${errors.join(' ')} The change was archived, but retirement cleanup did not complete. Inspect all reported recovery paths before recovery or cleanup.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2243,7 +2251,7 @@ export class ArchiveCommand {
|
||||
try {
|
||||
await finalizeRetirementBackups(specSnapshots, mainSpecsDir);
|
||||
} catch (cleanupError) {
|
||||
throw new RetirementBackupsRetainedError(
|
||||
throw new RetirementCleanupError(
|
||||
`${error.message} ${
|
||||
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||
}`
|
||||
@@ -2251,7 +2259,7 @@ export class ArchiveCommand {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof RetirementBackupsRetainedError) throw error;
|
||||
if (error instanceof RetirementCleanupError) throw error;
|
||||
const rollbackErrors: Error[] = [];
|
||||
try {
|
||||
await restoreSpecSnapshots(
|
||||
|
||||
@@ -22,7 +22,6 @@ import { getGlobalConfigPath } from './global-config.js';
|
||||
import { writeFileAtomically } from './file-state.js';
|
||||
import { isCiEnvironment } from '../utils/ci.js';
|
||||
import { detectShell } from '../utils/shell-detection.js';
|
||||
import { CompletionFactory } from './completions/factory.js';
|
||||
|
||||
export const COMPLETION_TIP_MESSAGE =
|
||||
"Tip: Run 'openspec completion install' for shell completions";
|
||||
@@ -67,6 +66,9 @@ async function decideTip(): Promise<'show' | 'retire'> {
|
||||
if (!shell) {
|
||||
return 'retire';
|
||||
}
|
||||
// Loaded only here: every shell's generator and installer, needed only on
|
||||
// the rare run that still owes the tip.
|
||||
const { CompletionFactory } = await import('./completions/factory.js');
|
||||
return (await CompletionFactory.createInstaller(shell).isInstalled())
|
||||
? 'retire'
|
||||
: 'show';
|
||||
|
||||
@@ -84,7 +84,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
- If \`state: "blocked"\`: show the message and pause implementation.
|
||||
- If \`missingArtifacts\` is non-empty: ${BLOCKED_STATE_HANDOFF}
|
||||
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
||||
- If \`state: "all_done"\`: congratulate, suggest archive
|
||||
- If \`state: "all_done"\`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
Treat \`context\` as a required prompt-level input. Read and consider it, and
|
||||
@@ -143,7 +143,7 @@ ${PROJECT_ROOT_GUARD}
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
@@ -174,7 +174,8 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! ${ARCHIVE_HANDOFF}
|
||||
All tracked tasks are complete. Review or verify the change as appropriate
|
||||
before archiving. ${ARCHIVE_HANDOFF}
|
||||
\`\`\`
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -304,6 +304,17 @@ export class Validator {
|
||||
),
|
||||
});
|
||||
}
|
||||
// Same limit the main spec enforces after archive (#1976), so
|
||||
// `validate <change> --strict` catches a new overlong requirement
|
||||
// before it lands. MODIFIED is left alone: its text is the existing
|
||||
// requirement, which the specs instruction says to keep whole.
|
||||
if (requirementText && requirementText.length > MAX_REQUIREMENT_TEXT_LENGTH) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: entryPath,
|
||||
message: `ADDED "${block.name}": ${VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG}`,
|
||||
});
|
||||
}
|
||||
const scenarioCount = this.countScenarios(block.raw);
|
||||
if (scenarioCount < 1) {
|
||||
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
||||
@@ -820,7 +831,7 @@ export class Validator {
|
||||
spec.requirements.forEach((req, index) => {
|
||||
if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
|
||||
issues.push({
|
||||
level: 'INFO',
|
||||
level: 'WARNING',
|
||||
path: `requirements[${index}]`,
|
||||
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
|
||||
});
|
||||
|
||||
+3
-29
@@ -68,15 +68,6 @@ export class ViewCommand {
|
||||
});
|
||||
}
|
||||
|
||||
// Display archived changes
|
||||
if (changesData.archived.length > 0) {
|
||||
console.log(chalk.bold.gray('\nArchived Changes'));
|
||||
console.log('─'.repeat(60));
|
||||
changesData.archived.forEach((change) => {
|
||||
console.log(chalk.gray(` ◦ ${change.name}`));
|
||||
});
|
||||
}
|
||||
|
||||
// Display specifications
|
||||
if (specsData.length > 0) {
|
||||
console.log(chalk.bold.blue('\nSpecifications'));
|
||||
@@ -101,32 +92,17 @@ export class ViewCommand {
|
||||
draft: Array<{ name: string }>;
|
||||
active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }>;
|
||||
completed: Array<{ name: string }>;
|
||||
archived: Array<{ name: string }>;
|
||||
}> {
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
const projectRoot = path.dirname(openspecDir);
|
||||
|
||||
if (!fs.existsSync(changesDir)) {
|
||||
return { draft: [], active: [], completed: [], archived: [] };
|
||||
return { draft: [], active: [], completed: [] };
|
||||
}
|
||||
|
||||
const draft: Array<{ name: string }> = [];
|
||||
const active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }> = [];
|
||||
const completed: Array<{ name: string }> = [];
|
||||
let archived: Array<{ name: string }> = [];
|
||||
|
||||
try {
|
||||
archived = fs.readdirSync(path.join(changesDir, 'archive'), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
.map((entry) => ({ name: entry.name }));
|
||||
} catch (error) {
|
||||
// A missing archive, or an `archive` path that is a file, has no archived
|
||||
// changes to show; neither should break the rest of the dashboard.
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
||||
|
||||
@@ -169,9 +145,8 @@ export class ViewCommand {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
completed.sort((a, b) => a.name.localeCompare(b.name));
|
||||
archived.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { draft, active, completed, archived };
|
||||
return { draft, active, completed };
|
||||
}
|
||||
|
||||
private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
|
||||
@@ -200,7 +175,7 @@ export class ViewCommand {
|
||||
}
|
||||
|
||||
private displaySummary(
|
||||
changesData: { draft: any[]; active: any[]; completed: any[]; archived: any[] },
|
||||
changesData: { draft: any[]; active: any[]; completed: any[] },
|
||||
specsData: any[]
|
||||
): void {
|
||||
const totalChanges =
|
||||
@@ -233,7 +208,6 @@ export class ViewCommand {
|
||||
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
|
||||
);
|
||||
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
|
||||
console.log(` ${chalk.gray('●')} Archived Changes: ${chalk.bold(changesData.archived.length)}`);
|
||||
|
||||
if (totalTasks > 0) {
|
||||
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { cliProjectRoot, ensureCliBuilt } from '../helpers/run-cli.js';
|
||||
|
||||
// Every CLI call pays for the modules it loads before the command runs, and
|
||||
// callers such as editors and agents run the CLI many times. A command loads
|
||||
// only the command definitions (names, options, help text) and its own
|
||||
// implementation; everything else loads on demand.
|
||||
|
||||
const binPath = path.join(cliProjectRoot, 'bin', 'openspec.js');
|
||||
const hookUrl = pathToFileURL(
|
||||
path.join(cliProjectRoot, 'test', 'helpers', 'record-loaded-modules.mjs')
|
||||
).href;
|
||||
|
||||
// The module that implements each command, relative to dist/.
|
||||
const COMMAND_IMPLEMENTATIONS: Record<string, string[]> = {
|
||||
init: ['core/init.js'],
|
||||
update: ['core/update.js'],
|
||||
list: ['core/list.js'],
|
||||
view: ['core/view.js'],
|
||||
archive: ['core/archive.js'],
|
||||
change: ['commands/change.js'],
|
||||
spec: ['commands/spec.js'],
|
||||
validate: ['commands/validate.js'],
|
||||
show: ['commands/show.js'],
|
||||
feedback: ['commands/feedback.js'],
|
||||
completion: ['commands/completion.js'],
|
||||
config: ['commands/config.js'],
|
||||
schema: ['commands/schema.js'],
|
||||
store: ['commands/store.js'],
|
||||
doctor: ['commands/doctor.js'],
|
||||
context: ['commands/context.js'],
|
||||
workset: ['commands/workset.js'],
|
||||
status: ['commands/workflow/status.js'],
|
||||
instructions: ['commands/workflow/instructions.js'],
|
||||
templates: ['commands/workflow/templates.js'],
|
||||
schemas: ['commands/workflow/schemas.js'],
|
||||
new: ['commands/workflow/new-change.js'],
|
||||
};
|
||||
|
||||
const workDirs: string[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of workDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function loadedModules(args: string[]): {
|
||||
own: string[];
|
||||
packages: Set<string>;
|
||||
status: number | null;
|
||||
} {
|
||||
const workDir = mkdtempSync(path.join(os.tmpdir(), 'openspec-startup-'));
|
||||
workDirs.push(workDir);
|
||||
const logFile = path.join(workDir, 'modules.log');
|
||||
const result = spawnSync(process.execPath, ['--import', hookUrl, binPath, ...args], {
|
||||
cwd: workDir,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENSPEC_TELEMETRY: '0',
|
||||
OPEN_SPEC_INTERACTIVE: '0',
|
||||
XDG_CONFIG_HOME: path.join(workDir, 'config'),
|
||||
XDG_DATA_HOME: path.join(workDir, 'data'),
|
||||
OPENSPEC_TEST_MODULE_LOG: logFile,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Loaded module URLs use real paths, so compare against the real dist/ path
|
||||
// in case the checkout path goes through a symlink or a Windows short name.
|
||||
const distRoot = realpathSync.native(path.join(cliProjectRoot, 'dist'));
|
||||
|
||||
const urls = readFileSync(logFile, 'utf-8').split('\n').filter(Boolean);
|
||||
const own: string[] = [];
|
||||
const packages = new Set<string>();
|
||||
for (const url of urls) {
|
||||
if (!url.startsWith('file:')) continue;
|
||||
const file = fileURLToPath(url);
|
||||
const segments = file.split(path.sep);
|
||||
const lastNodeModules = segments.lastIndexOf('node_modules');
|
||||
if (lastNodeModules !== -1) {
|
||||
const name = segments[lastNodeModules + 1];
|
||||
packages.add(name.startsWith('@') ? `${name}/${segments[lastNodeModules + 2]}` : name);
|
||||
} else if (file.startsWith(distRoot + path.sep)) {
|
||||
own.push(path.relative(distRoot, file).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
// An empty list means the paths didn't match, and every absence check
|
||||
// below would pass without checking anything.
|
||||
expect(own).not.toEqual([]);
|
||||
return { own, packages, status: result.status };
|
||||
}
|
||||
|
||||
function implementationsLoaded(own: string[]): string[] {
|
||||
return Object.entries(COMMAND_IMPLEMENTATIONS)
|
||||
.filter(([, modules]) => modules.some((module) => own.includes(module)))
|
||||
.map(([command]) => command);
|
||||
}
|
||||
|
||||
describe('CLI startup loads only what the command needs', () => {
|
||||
it.each(['--version', '--help', 'validate --help'])(
|
||||
'`openspec %s` loads the command definitions and nothing else',
|
||||
async (invocation) => {
|
||||
await ensureCliBuilt();
|
||||
const { own, packages, status } = loadedModules(invocation.split(' '));
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect([...packages]).toEqual(['commander']);
|
||||
expect(implementationsLoaded(own)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['config list --json', 'config'],
|
||||
['config path', 'config'],
|
||||
['store list --json', 'store'],
|
||||
['doctor --json', 'doctor'],
|
||||
['schemas --json', 'schemas'],
|
||||
['list --json', 'list'],
|
||||
])('`openspec %s` loads only the %s implementation', async (invocation, command) => {
|
||||
await ensureCliBuilt();
|
||||
const { own } = loadedModules(invocation.split(' '));
|
||||
|
||||
expect(implementationsLoaded(own)).toEqual([command]);
|
||||
});
|
||||
});
|
||||
@@ -152,8 +152,8 @@ describe('openspec view root resolution', () => {
|
||||
expect(result.stdout).toContain('billing');
|
||||
expect(result.stdout).toContain('Active Changes: 2 in progress');
|
||||
expect(result.stdout).toContain('Task Progress: 2/4 (50% complete)');
|
||||
expect(result.stdout).toContain('Archived Changes: 1');
|
||||
expect(result.stdout).toContain('2026-08-27-store-history');
|
||||
expect(result.stdout).not.toContain('Archived');
|
||||
expect(result.stdout).not.toContain('2026-08-27-store-history');
|
||||
const lines = result.stdout.split(/\r?\n/);
|
||||
|
||||
for (const changeName of ['billing-update', 'billing-refactor']) {
|
||||
@@ -228,8 +228,8 @@ describe('openspec view root resolution', () => {
|
||||
cwd: base, env: aliasEnv, timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
expect(viewed.exitCode, viewed.stderr).toBe(0);
|
||||
expect(viewed.stdout).toContain('Archived Changes: 1');
|
||||
expect(viewed.stdout).toContain('2026-08-27-store-history');
|
||||
expect(viewed.stdout).toContain('Active Changes: 2 in progress');
|
||||
expect(viewed.stdout).not.toContain('2026-08-27-store-history');
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
it(
|
||||
|
||||
@@ -1246,9 +1246,23 @@ operations:
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('complete ✓');
|
||||
expect(result.stdout).toContain('ready to be archived');
|
||||
expect(result.stdout).toContain('All tracked tasks are complete');
|
||||
expect(result.stdout).toContain('as appropriate before archiving');
|
||||
expect(result.stdout).not.toContain('ready to be archived');
|
||||
expect(result.stdout).toContain('### Project Context (required instruction input)');
|
||||
expect(result.stdout).toContain('### Operation Guidance (advisory)');
|
||||
|
||||
const jsonResult = await runCLI(
|
||||
['instructions', 'apply', '--change', 'done-apply', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
expect(jsonResult.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(jsonResult.stdout);
|
||||
expect(json.state).toBe('all_done');
|
||||
expect(json.progress).toEqual({ total: 2, complete: 2, remaining: 0 });
|
||||
expect(json.instruction).toContain('All tracked tasks are complete');
|
||||
});
|
||||
|
||||
it('uses spec-driven schema apply configuration', async () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ const T = 30_000;
|
||||
const quote = (value: string) => `"${value}"`;
|
||||
|
||||
async function runConfigCommand(args: string[]): Promise<void> {
|
||||
const { registerConfigCommand } = await import('../../src/commands/config.js');
|
||||
const { registerConfigCommand } = await import('../../src/cli/commands/config.js');
|
||||
const program = new Command();
|
||||
registerConfigCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'config', ...args]);
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('@inquirer/prompts', () => ({
|
||||
}));
|
||||
|
||||
async function runConfigCommand(args: string[]): Promise<void> {
|
||||
const { registerConfigCommand } = await import('../../src/commands/config.js');
|
||||
const { registerConfigCommand } = await import('../../src/cli/commands/config.js');
|
||||
const program = new Command();
|
||||
registerConfigCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'config', ...args]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
async function runConfigCommand(args: string[]): Promise<void> {
|
||||
const { registerConfigCommand } = await import('../../src/commands/config.js');
|
||||
const { registerConfigCommand } = await import('../../src/cli/commands/config.js');
|
||||
const program = new Command();
|
||||
registerConfigCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'config', ...args]);
|
||||
|
||||
@@ -97,7 +97,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
// block-scalar style; the new implementation edits a yaml Document in place.
|
||||
|
||||
async function runSchemaCommand(args: string[]): Promise<void> {
|
||||
const { registerSchemaCommand } = await import('../../src/commands/schema.js');
|
||||
const { registerSchemaCommand } = await import('../../src/cli/commands/schema.js');
|
||||
const program = new Command();
|
||||
registerSchemaCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'schema', ...args]);
|
||||
|
||||
@@ -5,12 +5,8 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
async function runSchemaCommand(
|
||||
args: string[],
|
||||
schemaModule?: typeof import('../../src/commands/schema.js')
|
||||
): Promise<void> {
|
||||
const { registerSchemaCommand } =
|
||||
schemaModule ?? (await import('../../src/commands/schema.js'));
|
||||
async function runSchemaCommand(args: string[]): Promise<void> {
|
||||
const { registerSchemaCommand } = await import('../../src/cli/commands/schema.js');
|
||||
const program = new Command();
|
||||
registerSchemaCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'schema', ...args]);
|
||||
@@ -442,10 +438,7 @@ artifacts:
|
||||
return { schemaDir, before: snapshotTree(schemaDir) };
|
||||
}
|
||||
|
||||
async function runDefaultInit(
|
||||
force: boolean,
|
||||
schemaModule?: typeof import('../../src/commands/schema.js')
|
||||
): Promise<void> {
|
||||
async function runDefaultInit(force: boolean): Promise<void> {
|
||||
await runSchemaCommand(
|
||||
[
|
||||
'init',
|
||||
@@ -455,8 +448,7 @@ artifacts:
|
||||
'proposal,specs,tasks',
|
||||
'--default',
|
||||
'--json',
|
||||
],
|
||||
schemaModule
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -630,8 +622,7 @@ artifacts:
|
||||
const configPath = path.join(tempDir, 'openspec', 'config.yaml');
|
||||
const configBytes = Buffer.from('schema: existing\ncontext: keep me\n');
|
||||
fs.writeFileSync(configPath, configBytes);
|
||||
const schemaModule = await import('../../src/commands/schema.js');
|
||||
const { schemaInitFileOperations } = schemaModule;
|
||||
const { schemaInitFileOperations } = await import('../../src/commands/schema.js');
|
||||
const renameSync = schemaInitFileOperations.renameSync;
|
||||
const renameCalls: Array<[string, string]> = [];
|
||||
schemaInitFileOperations.renameSync = (source, destination) => {
|
||||
@@ -643,7 +634,7 @@ artifacts:
|
||||
};
|
||||
|
||||
try {
|
||||
await runDefaultInit(true, schemaModule);
|
||||
await runDefaultInit(true);
|
||||
} finally {
|
||||
schemaInitFileOperations.renameSync = renameSync;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@inquirer/prompts', () => ({
|
||||
}));
|
||||
|
||||
async function runStoreCommand(args: string[]): Promise<void> {
|
||||
const { registerStoreCommand } = await import('../../src/commands/store.js');
|
||||
const { registerStoreCommand } = await import('../../src/cli/commands/store.js');
|
||||
const program = new Command();
|
||||
registerStoreCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'store', ...args]);
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('@inquirer/prompts', () => ({
|
||||
}));
|
||||
|
||||
async function runStoreCommand(args: string[]): Promise<void> {
|
||||
const { registerStoreCommand } = await import('../../src/commands/store.js');
|
||||
const { registerStoreCommand } = await import('../../src/cli/commands/store.js');
|
||||
const program = new Command();
|
||||
registerStoreCommand(program);
|
||||
await program.parseAsync(['node', 'openspec', 'store', ...args]);
|
||||
|
||||
@@ -897,7 +897,7 @@ describe('interactive compose cancellation (in-process)', () => {
|
||||
async function runCreate(promptsModule: Record<string, unknown>): Promise<void> {
|
||||
vi.doMock('@inquirer/prompts', () => promptsModule);
|
||||
const { registerWorksetCommand } = await import(
|
||||
'../../src/commands/workset.js'
|
||||
'../../src/cli/commands/workset.js'
|
||||
);
|
||||
const { Command } = await import('commander');
|
||||
const program = new Command();
|
||||
@@ -1043,7 +1043,7 @@ describe('interactive compose cancellation (in-process)', () => {
|
||||
confirm: vi.fn(async () => false),
|
||||
}));
|
||||
const { registerWorksetCommand } = await import(
|
||||
'../../src/commands/workset.js'
|
||||
'../../src/cli/commands/workset.js'
|
||||
);
|
||||
const { Command } = await import('commander');
|
||||
|
||||
|
||||
+216
-12
@@ -6,7 +6,7 @@ import { MarkdownParser } from '../../src/core/parsers/markdown-parser.js';
|
||||
import { findMainSpecStructureIssues } from '../../src/core/parsers/spec-structure.js';
|
||||
import { VALIDATION_MESSAGES } from '../../src/core/validation/constants.js';
|
||||
import { formatLocalDate } from '../../src/utils/date.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import { promises as fs, realpathSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
@@ -44,8 +44,10 @@ describe('ArchiveCommand', () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-test-'));
|
||||
// Match archive's canonical root across temporary-directory aliases.
|
||||
tempDir = realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-test-'))
|
||||
);
|
||||
|
||||
// Change to temp directory
|
||||
process.chdir(tempDir);
|
||||
@@ -6566,7 +6568,7 @@ The system SHALL provide a replacement behavior.
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/displaced spec changed.*backup was retained for recovery/s
|
||||
/displaced spec changed.*change was archived/s
|
||||
);
|
||||
|
||||
expect(edited).toBe(true);
|
||||
@@ -7359,7 +7361,7 @@ The system SHALL provide a new behavior.
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps committed retirement state when one backup cleanup fails', async () => {
|
||||
it.each([false, true])('keeps committed retirement state when one backup cleanup fails (json=%s)', async (json) => {
|
||||
const changeName = 'retire-backup-cleanup-failure';
|
||||
const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL);
|
||||
const secondDelta = path.join(changeDir, 'specs', 'z-layer');
|
||||
@@ -7386,9 +7388,24 @@ The system SHALL provide a new behavior.
|
||||
return realUnlink(candidate);
|
||||
});
|
||||
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/change remains archived.*backup was retained for recovery/s
|
||||
);
|
||||
if (json) {
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse((console.log as any).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.root).toBeDefined();
|
||||
expect(payload.status).toEqual([{
|
||||
severity: 'error',
|
||||
code: 'archive_retirement_cleanup_failed',
|
||||
message: expect.stringMatching(/change was archived.*Inspect all reported recovery paths/s),
|
||||
fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
|
||||
}]);
|
||||
} else {
|
||||
await expect(archiveCommand.execute(changeName, { yes: true })).rejects.toThrow(
|
||||
/change was archived.*Inspect all reported recovery paths/s
|
||||
);
|
||||
}
|
||||
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
await expect(
|
||||
@@ -7406,11 +7423,198 @@ The system SHALL provide a new behavior.
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
}
|
||||
await expect(fs.access(path.dirname(targets[0]))).rejects.toThrow();
|
||||
expect(
|
||||
(await fs.readdir(path.dirname(targets[1]))).some((entry) =>
|
||||
const backups = (await fs.readdir(path.dirname(targets[1]))).filter((entry) =>
|
||||
entry.includes('.openspec-retire-')
|
||||
);
|
||||
expect(backups).toHaveLength(1);
|
||||
await expect(
|
||||
fs.readFile(path.join(path.dirname(targets[1]), backups[0]), 'utf-8')
|
||||
).resolves.toBe(mainSpec('z-layer'));
|
||||
});
|
||||
|
||||
it('keeps a pre-mutation archive failure generic in JSON', async () => {
|
||||
const changeName = 'retire-claim-denied-json';
|
||||
const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL);
|
||||
const target = path.join(tempDir, 'openspec', 'specs', 'legacy-layer', 'spec.md');
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, mainSpec('legacy-layer'));
|
||||
const delta = await fs.readFile(path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'), 'utf-8');
|
||||
|
||||
const realOpen = fs.open.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
let claimDenied = false;
|
||||
vi.spyOn(fs, 'open').mockImplementation(async (candidate, flags, mode) => {
|
||||
if (String(candidate) === archiveClaimPath(changeName)) {
|
||||
claimDenied = true;
|
||||
throw Object.assign(new Error('claim open denied'), { code: 'EACCES' });
|
||||
}
|
||||
return realOpen(candidate, flags, mode);
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
expect(claimDenied).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse((console.log as any).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.status).toEqual([{
|
||||
severity: 'error',
|
||||
code: 'archive_error',
|
||||
message: expect.stringContaining('claim open denied'),
|
||||
}]);
|
||||
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(mainSpec('legacy-layer'));
|
||||
await expect(
|
||||
fs.readFile(path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'), 'utf-8')
|
||||
).resolves.toBe(delta);
|
||||
expect(await fs.readdir(path.dirname(target))).toEqual(['spec.md']);
|
||||
expect(await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['edited', 'replaced', 'removed'] as const)(
|
||||
'reports a concurrently %s retirement backup in JSON without losing surviving content',
|
||||
async (backupChange) => {
|
||||
const changeName = 'retire-backup-edited-json';
|
||||
const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL);
|
||||
const targetDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer');
|
||||
const target = path.join(targetDir, 'spec.md');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.writeFile(target, mainSpec('legacy-layer'));
|
||||
|
||||
const archivePath = path.join(
|
||||
tempDir, 'openspec', 'changes', 'archive', `${formatLocalDate()}-${changeName}`
|
||||
);
|
||||
const realRename = fs.rename.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
let editedBackup: string | undefined;
|
||||
let backupChanged = false;
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
const result = await realRename(source, destination);
|
||||
if (String(source) === changeDir && String(destination) === archivePath) {
|
||||
const backup = (await fs.readdir(targetDir)).find((entry) =>
|
||||
entry.includes('.openspec-retire-')
|
||||
);
|
||||
expect(backup).toBeDefined();
|
||||
editedBackup = path.join(targetDir, backup!);
|
||||
if (backupChange !== 'edited') await fs.unlink(editedBackup);
|
||||
if (backupChange !== 'removed') {
|
||||
await fs.writeFile(editedBackup, 'concurrent content in retirement backup\n');
|
||||
}
|
||||
backupChanged = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
expect(backupChanged).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse((console.log as any).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
if (backupChange === 'removed') {
|
||||
expect(payload.status[0].message).not.toContain('each listed backup was retained');
|
||||
}
|
||||
expect(payload.status).toEqual([{
|
||||
severity: 'error',
|
||||
code: 'archive_retirement_cleanup_failed',
|
||||
message: expect.stringMatching(/displaced spec changed.*change was archived/s),
|
||||
fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
|
||||
}]);
|
||||
expect(editedBackup).toBeDefined();
|
||||
expect(payload.status[0].message).toContain(editedBackup);
|
||||
if (backupChange === 'removed') {
|
||||
await expect(fs.access(editedBackup!)).rejects.toThrow();
|
||||
} else {
|
||||
await expect(fs.readFile(editedBackup!, 'utf-8')).resolves.toBe(
|
||||
'concurrent content in retirement backup\n'
|
||||
);
|
||||
}
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
await expect(fs.access(archivePath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('reports all recovery paths when fallback source and multiple backup cleanups fail', async () => {
|
||||
const changeName = 'retire-combined-cleanup-failure';
|
||||
const changeDir = await createChange(changeName, 'a-layer', REMOVE_ALL);
|
||||
const secondDelta = path.join(changeDir, 'specs', 'z-layer');
|
||||
await fs.mkdir(secondDelta, { recursive: true });
|
||||
await fs.writeFile(path.join(secondDelta, 'spec.md'), REMOVE_ALL);
|
||||
const targets = ['a-layer', 'z-layer'].map((capability) =>
|
||||
path.join(tempDir, 'openspec', 'specs', capability, 'spec.md')
|
||||
);
|
||||
for (const [index, target] of targets.entries()) {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, mainSpec(index === 0 ? 'a-layer' : 'z-layer'));
|
||||
}
|
||||
|
||||
const archivePath = path.join(
|
||||
tempDir, 'openspec', 'changes', 'archive', `${formatLocalDate()}-${changeName}`
|
||||
);
|
||||
const realRename = fs.rename.bind(fs);
|
||||
const realRmdir = fs.rmdir.bind(fs);
|
||||
const realUnlink = fs.unlink.bind(fs);
|
||||
onTestFinished(() => vi.restoreAllMocks());
|
||||
let stagedSource: string | undefined;
|
||||
let fallbackInjected = false;
|
||||
let sourceCleanupDenied = false;
|
||||
let backupCleanupsDenied = 0;
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => {
|
||||
if (String(source) === changeDir && String(destination) === archivePath) {
|
||||
fallbackInjected = true;
|
||||
throw Object.assign(new Error('cross-device move'), { code: 'EXDEV' });
|
||||
}
|
||||
return realRename(source, destination);
|
||||
});
|
||||
// Source removal claims and deletes each verified entry, then removes the
|
||||
// staged root last; deny that final step so a partly removed staged
|
||||
// source is left behind.
|
||||
vi.spyOn(fs, 'rmdir').mockImplementation(async (candidate, options) => {
|
||||
if (path.basename(String(candidate)).startsWith('.openspec-move-')) {
|
||||
stagedSource = String(candidate);
|
||||
sourceCleanupDenied = true;
|
||||
throw Object.assign(new Error('partial source cleanup'), { code: 'EACCES' });
|
||||
}
|
||||
return realRmdir(candidate, options);
|
||||
});
|
||||
vi.spyOn(fs, 'unlink').mockImplementation(async (candidate) => {
|
||||
if (String(candidate).includes('.openspec-retire-')) {
|
||||
backupCleanupsDenied += 1;
|
||||
throw Object.assign(new Error('backup cleanup denied'), { code: 'EACCES' });
|
||||
}
|
||||
return realUnlink(candidate);
|
||||
});
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, json: true });
|
||||
|
||||
expect(fallbackInjected).toBe(true);
|
||||
expect(sourceCleanupDenied).toBe(true);
|
||||
expect(backupCleanupsDenied).toBe(2);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(vi.mocked(console.log).mock.calls[0][0]);
|
||||
expect(payload.archive).toBeNull();
|
||||
expect(payload.status).toHaveLength(1);
|
||||
expect(payload.status[0].fix).toContain('all recovery paths');
|
||||
expect(stagedSource).toBeDefined();
|
||||
expect(payload.status[0].message).toContain(stagedSource);
|
||||
expect(payload.status[0].message).toContain('complete destination was retained');
|
||||
await expect(fs.access(path.join(stagedSource!, 'tasks.md'))).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(archivePath, 'tasks.md'), 'utf-8')).resolves.toContain('[x]');
|
||||
for (const [index, target] of targets.entries()) {
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
const backup = (await fs.readdir(path.dirname(target))).find((entry) =>
|
||||
entry.includes('.openspec-retire-')
|
||||
)
|
||||
).toBe(true);
|
||||
);
|
||||
expect(backup).toBeDefined();
|
||||
const backupPath = path.join(path.dirname(target), backup!);
|
||||
expect(payload.status[0].message).toContain(backupPath);
|
||||
await expect(fs.readFile(backupPath, 'utf-8')).resolves.toBe(
|
||||
mainSpec(index === 0 ? 'a-layer' : 'z-layer')
|
||||
);
|
||||
}
|
||||
await expect(fs.access(changeDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
|
||||
@@ -292,11 +292,11 @@ describe('an unparseable global config', () => {
|
||||
});
|
||||
|
||||
describe('openspec config', () => {
|
||||
let registerConfigCommand: typeof import('../../src/commands/config.js').registerConfigCommand;
|
||||
let registerConfigCommand: typeof import('../../src/cli/commands/config.js').registerConfigCommand;
|
||||
|
||||
// Imported once: the command module pulls in most of the CLI.
|
||||
beforeAll(async () => {
|
||||
({ registerConfigCommand } = await import('../../src/commands/config.js'));
|
||||
({ registerConfigCommand } = await import('../../src/cli/commands/config.js'));
|
||||
}, 60_000);
|
||||
|
||||
async function runConfig(args: string[]): Promise<void> {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getCommandContents } from '../../../src/core/shared/skill-generation.js
|
||||
import { MAX_CONTEXT_SIZE } from '../../../src/core/project-config.js';
|
||||
import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js';
|
||||
import { ALL_WORKFLOWS } from '../../../src/core/profiles.js';
|
||||
import { parseTaskLines } from '../../../src/utils/task-progress.js';
|
||||
|
||||
// Templates carry optional-workflow conditionals; a body only means anything
|
||||
// once resolved against a workflow set. Unless a test says otherwise, these are
|
||||
@@ -117,6 +118,28 @@ describe('default proposal guidance', () => {
|
||||
});
|
||||
|
||||
describe('default task guidance', () => {
|
||||
it('keeps tracked tasks within the pre-archive workflow (#1790)', () => {
|
||||
const tasks = defaultSchema.artifacts.find(artifact => artifact.id === 'tasks');
|
||||
expect(tasks).toBeDefined();
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/Track implementation and verification work that can be completed before\s+archive/
|
||||
);
|
||||
expect(tasks!.instruction).toMatch(
|
||||
/preserve those steps as plain bullets in an\s+optional `## Workflow follow-up` section at the end of tasks.md/
|
||||
);
|
||||
|
||||
const examples = [...tasks!.instruction.matchAll(/```\s*([\s\S]*?)```/g)];
|
||||
expect(examples).toHaveLength(2);
|
||||
const implementation = examples[0][1];
|
||||
const followUp = examples[1][1];
|
||||
expect(followUp).toContain('## Workflow follow-up');
|
||||
expect(followUp).toContain('- Verify the archived result.');
|
||||
expect(parseTaskLines(followUp)).toEqual([]);
|
||||
expect(parseTaskLines(`${implementation}\n${followUp}`)).toEqual(
|
||||
parseTaskLines(implementation)
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a concrete verification method in each task (#345)', () => {
|
||||
const tasks = defaultSchema.artifacts.find(artifact => artifact.id === 'tasks');
|
||||
expect(tasks).toBeDefined();
|
||||
|
||||
@@ -22,8 +22,12 @@ describe('specs instruction requirement length (#1976)', () => {
|
||||
expect(instruction).toContain(`${MAX_REQUIREMENT_TEXT_LENGTH} characters or fewer`);
|
||||
expect(instruction).toContain('split a requirement that covers several behaviors');
|
||||
// Existing requirements under MODIFIED must be copied whole (scenario-loss
|
||||
// validation rejects a split), and the limit is only an INFO hint.
|
||||
expect(instruction).toContain('This is an informational hint, not an error.');
|
||||
// validation rejects a split), and the limit is a warning that fails --strict.
|
||||
expect(instruction).toContain('`openspec validate --strict` fails on it.');
|
||||
expect(instruction).toContain('Under MODIFIED, keep the existing requirement block whole');
|
||||
// Strict CI catches new requirements before archive, and an existing long
|
||||
// requirement has a split path that keeps every scenario (#1976).
|
||||
expect(instruction).toContain('flags longer descriptions in ADDED requirements and in the main spec');
|
||||
expect(instruction).toContain('keep its header and every scenario');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,14 +79,14 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getExploreSkillTemplate: 'c1fddb294758004936add586f5826694cb06175cff935b75fd3a8d92332332e6',
|
||||
getNewChangeSkillTemplate: '0e5035b7b42198afc430206a1dbc9579096650ef0813d85e837d5a6cd0b98a85',
|
||||
getContinueChangeSkillTemplate: 'c2c8a0ba7f8c8fc7b174793832cd50f7c404eb8e1f7d49c47000993d621633b6',
|
||||
getApplyChangeSkillTemplate: 'a6a9aa080ba062045533b33a71ad9358d9d5fef1756616f3feac7dea64aba289',
|
||||
getApplyChangeSkillTemplate: 'c4f02c29137e19b34bf1dc59146941b8c920214aa2cf3ce80ae7f1b982e976fc',
|
||||
getFfChangeSkillTemplate: 'd091600476a815ba99f69b446bcd46af5bf73d1c2810215a0c6196937d019cf6',
|
||||
getSyncSpecsSkillTemplate: 'bc80fe9b07eaa289e5eb8a3ce65eb7df722a16d864e37283c678220712e4f230',
|
||||
getOnboardSkillTemplate: '84258a06c0ca88de708a23dd74e9a17efe11eff63a071b3864c781dcd5a0a4b7',
|
||||
getOpsxExploreCommandTemplate: '5d11f8ecb4c457140a3e874a8bf7aa72674e922e698c208832b1f34d3c617719',
|
||||
getOpsxNewCommandTemplate: '6d504fef1e0d4ced7c423f4cc9d9d2cee11b1a6224edf685e06a3f0757e0ebff',
|
||||
getOpsxContinueCommandTemplate: '241c50f97d5d681412d456d6b982743c3a5babeb77017fc8099c418bcf0d92df',
|
||||
getOpsxApplyCommandTemplate: '21ccc013710ffb9f3a93ede9f9fb7e3eecaac292fcb3f3be30e2562cae9c4127',
|
||||
getOpsxApplyCommandTemplate: '9b733946aa7216b45d084bb5affd7071086db6f91282c3666731a17543825ff1',
|
||||
getOpsxFfCommandTemplate: '743a7304c7efc84aa87f556154c034e1e0e561c276c51870a30ada58f33eb9af',
|
||||
getArchiveChangeSkillTemplate: '04a029782fc4137971fad6f54cfda3685d7e0b835b06565f9a509b4878093099',
|
||||
getBulkArchiveChangeSkillTemplate: '44dbd3c7a347e5f8339b2141393f2ac36017527cce251483fe70f2059c1e286e',
|
||||
@@ -107,7 +107,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
|
||||
'openspec-explore': '7d80caf9cd25a2565ba190b1297f1631c7f2c2db5e614597b4284abc0118ea70',
|
||||
'openspec-new-change': '27e09d43785953827efc9a98bb9d6cf06db48fe6abe7e1c049409fe5b5061323',
|
||||
'openspec-continue-change': '1f92fad53022270e96f8ea34de75f7c12c08225edd5a9e8f4e864b63b5ef79c5',
|
||||
'openspec-apply-change': '4bcd8c1dc3d0d86a2e4c605fc60d6ee54e0e0433f248c1d602c12927935e6963',
|
||||
'openspec-apply-change': '991a1d4687f1c8c7147d8c3b68cf14e9c515c6c53bb026356dac3b80e909b827',
|
||||
'openspec-ff-change': 'a7ab656d46f04d45dff0c8888df4a126a2e62288b7336f7445bce4d1715055f5',
|
||||
'openspec-sync-specs': '3909936a236a21a9a6d5bf495f90b396b3b68fc9220d7b2c1894668653beb2e4',
|
||||
'openspec-archive-change': '5f0d131a885dcdcd9ba2172ea9a42bc6748125e24b8c4eecb7c86f1a4aea83af',
|
||||
@@ -1267,6 +1267,13 @@ describe('apply skill/command shared instruction core', () => {
|
||||
expect(getApplyChangeSkillTemplate().instructions).toBe(core);
|
||||
expect(getOpsxApplyCommandTemplate().content).toBe(core);
|
||||
});
|
||||
|
||||
it('keeps task completion distinct from archive readiness (#1790)', () => {
|
||||
const core = getApplyInstructions();
|
||||
expect(core).toContain('All tracked tasks are complete');
|
||||
expect(core).toMatch(/Review or verify the change as appropriate\s+before archiving/);
|
||||
expect(core).not.toContain('All tasks complete! You can archive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow guidance matches the packaged templates (#1138)', () => {
|
||||
|
||||
@@ -256,12 +256,37 @@ ${requirementPrefix}${'x'.repeat(length - requirementPrefix.length)}
|
||||
expect.objectContaining({ message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG })
|
||||
);
|
||||
expect(overLimit.issues).toContainEqual({
|
||||
level: 'INFO',
|
||||
level: 'WARNING',
|
||||
path: 'requirements[0]',
|
||||
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails strict validation, but not normal validation, on an overlong requirement (#1976)', async () => {
|
||||
const spec = `# Overlong requirement
|
||||
|
||||
## Purpose
|
||||
This specification checks how strict mode treats an overlong requirement description.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Overlong
|
||||
The system SHALL ${'x'.repeat(MAX_REQUIREMENT_TEXT_LENGTH)}
|
||||
|
||||
#### Scenario: Overlong is checked
|
||||
- **WHEN** the requirement is validated
|
||||
- **THEN** the length finding is reported`;
|
||||
|
||||
const normal = await new Validator().validateSpecContent('overlong', spec);
|
||||
const strict = await new Validator(true).validateSpecContent('overlong', spec);
|
||||
|
||||
expect(normal.valid).toBe(true);
|
||||
expect(strict.valid).toBe(false);
|
||||
expect(strict.issues).toEqual([
|
||||
expect.objectContaining({ level: 'WARNING', message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect missing overview section', async () => {
|
||||
const specContent = `# User Authentication Spec
|
||||
|
||||
@@ -483,6 +508,62 @@ Then result`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChangeDeltaSpecs requirement length (#1976)', () => {
|
||||
const requirementPrefix = 'The system SHALL ';
|
||||
const writeDelta = async (name: string, section: 'ADDED' | 'MODIFIED', length: number) => {
|
||||
const changeDir = path.join(testDir, name);
|
||||
const specsDir = path.join(changeDir, 'specs', 'test-spec');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(specsDir, 'spec.md'),
|
||||
`## ${section} Requirements
|
||||
|
||||
### Requirement: Long
|
||||
${requirementPrefix}${'x'.repeat(length - requirementPrefix.length)}
|
||||
|
||||
#### Scenario: Long is checked
|
||||
- **WHEN** the change is validated
|
||||
- **THEN** the length finding is reported`
|
||||
);
|
||||
return changeDir;
|
||||
};
|
||||
|
||||
it('fails strict, but not normal, validation on an overlong ADDED requirement', async () => {
|
||||
const changeDir = await writeDelta('added-long', 'ADDED', MAX_REQUIREMENT_TEXT_LENGTH + 1);
|
||||
|
||||
const normal = await new Validator().validateChangeDeltaSpecs(changeDir);
|
||||
const strict = await new Validator(true).validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(normal.valid).toBe(true);
|
||||
expect(strict.valid).toBe(false);
|
||||
expect(strict.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
level: 'WARNING',
|
||||
message: `ADDED "Long": ${VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG}`,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts an ADDED requirement at the limit', async () => {
|
||||
const changeDir = await writeDelta('added-at-limit', 'ADDED', MAX_REQUIREMENT_TEXT_LENGTH);
|
||||
|
||||
const strict = await new Validator(true).validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(strict.valid).toBe(true);
|
||||
expect(strict.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not flag an overlong MODIFIED requirement, which keeps the existing text whole', async () => {
|
||||
const changeDir = await writeDelta('modified-long', 'MODIFIED', MAX_REQUIREMENT_TEXT_LENGTH + 1);
|
||||
|
||||
const strict = await new Validator(true).validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(strict.issues.map((i) => i.message)).not.toContainEqual(
|
||||
expect.stringContaining(VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChangeDeltaSpecs with metadata', () => {
|
||||
it('rejects a delta that both renames and removes the same requirement', async () => {
|
||||
// Parity with archive: apply-time rejects this contradiction, so
|
||||
|
||||
+10
-89
@@ -313,106 +313,27 @@ describe('ViewCommand', () => {
|
||||
expect(completedLines.some(line => line.includes('subtask-change'))).toBe(false);
|
||||
});
|
||||
|
||||
it('lists archived directories in name order without affecting current changes or task progress', async () => {
|
||||
it('renders the same dashboard however many changes are archived (#2030)', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
const archiveDir = path.join(changesDir, 'archive');
|
||||
await fs.mkdir(path.join(archiveDir, '2026-01-02-zebra'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(archiveDir, '2026-01-02-zebra', 'tasks.md'),
|
||||
'- [x] Done\n- [ ] Unfinished when archived\n- [ ] Another task\n'
|
||||
);
|
||||
// Archived directories need neither a proposal nor a tasks file.
|
||||
await fs.mkdir(path.join(archiveDir, '2026-01-01-alpha'));
|
||||
await fs.mkdir(path.join(archiveDir, '.hidden-change'));
|
||||
await fs.writeFile(path.join(archiveDir, 'README.md'), 'Archive notes');
|
||||
await fs.mkdir(path.join(changesDir, 'draft-change'));
|
||||
await fs.mkdir(path.join(changesDir, 'draft-change'), { recursive: true });
|
||||
await fs.mkdir(path.join(changesDir, 'active-change'));
|
||||
await fs.writeFile(path.join(changesDir, 'active-change', 'tasks.md'), '- [x] Done\n- [ ] Pending\n');
|
||||
await fs.mkdir(path.join(changesDir, 'completed-change'));
|
||||
await fs.writeFile(path.join(changesDir, 'completed-change', 'tasks.md'), '- [x] Done\n');
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
const withoutHistory = logOutput.map(stripAnsi);
|
||||
|
||||
const lines = logOutput.map(stripAnsi);
|
||||
const output = lines.join('\n');
|
||||
expect(output).toContain('Archived Changes: 2');
|
||||
expect(output).toContain('Draft Changes: 1');
|
||||
expect(output).toContain('Active Changes: 1 in progress');
|
||||
expect(output).toContain('Completed Changes: 1');
|
||||
expect(output).toContain('Task Progress: 1/2 (50% complete)');
|
||||
expect(output).not.toContain('.hidden-change');
|
||||
expect(output).not.toContain('README.md');
|
||||
|
||||
const archiveHeading = lines.indexOf('\nArchived Changes');
|
||||
expect(archiveHeading).toBeGreaterThan(lines.indexOf('\nCompleted Changes'));
|
||||
const archivedLines = lines.filter(line => line.includes('2026-01-'));
|
||||
expect(archivedLines).toHaveLength(2);
|
||||
expect(archivedLines[0]).toContain('2026-01-01-alpha');
|
||||
expect(archivedLines[1]).toContain('2026-01-02-zebra');
|
||||
expect(archivedLines.every(line => lines.indexOf(line) > archiveHeading)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['missing changes', 'missing archive', 'empty archive', 'hidden entries only'])(
|
||||
'shows a zero archive count without an archived section for %s',
|
||||
async (state) => {
|
||||
const openspecDir = path.join(tempDir, 'openspec');
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
const archiveDir = path.join(changesDir, 'archive');
|
||||
await fs.mkdir(openspecDir);
|
||||
if (state !== 'missing changes') {
|
||||
await fs.mkdir(changesDir);
|
||||
}
|
||||
if (state === 'empty archive' || state === 'hidden entries only') {
|
||||
await fs.mkdir(archiveDir);
|
||||
}
|
||||
if (state === 'hidden entries only') {
|
||||
await fs.mkdir(path.join(archiveDir, '.hidden-change'));
|
||||
await fs.writeFile(path.join(archiveDir, 'README.md'), 'Archive notes');
|
||||
}
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
const lines = logOutput.map(stripAnsi);
|
||||
expect(lines.join('\n')).toContain('Archived Changes: 0');
|
||||
expect(lines).not.toContain('\nArchived Changes');
|
||||
expect(lines.join('\n')).not.toContain('Task Progress:');
|
||||
// Archives grow for the life of a project; the dashboard must not grow with them.
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
const archived = path.join(changesDir, 'archive', `2026-01-01-shipped-${i}`);
|
||||
await fs.mkdir(archived, { recursive: true });
|
||||
await fs.writeFile(path.join(archived, 'tasks.md'), '- [x] Done\n- [ ] Left unfinished\n');
|
||||
}
|
||||
);
|
||||
|
||||
it('still renders the dashboard when the archive path is a file', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(path.join(changesDir, 'active-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'active-change', 'tasks.md'), '- [x] Done\n- [ ] Pending\n');
|
||||
await fs.writeFile(path.join(changesDir, 'archive'), 'Not a directory');
|
||||
|
||||
logOutput = [];
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
const lines = logOutput.map(stripAnsi);
|
||||
expect(lines.join('\n')).toContain('Active Changes: 1 in progress');
|
||||
expect(lines.join('\n')).toContain('Archived Changes: 0');
|
||||
expect(lines).not.toContain('\nArchived Changes');
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('surfaces unreadable archive directories', async ({ skip }) => {
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
await fs.mkdir(archiveDir, { recursive: true });
|
||||
await fs.chmod(archiveDir, 0o000);
|
||||
|
||||
try {
|
||||
// Root and some filesystems do not enforce permission bits.
|
||||
let unreadable = false;
|
||||
try {
|
||||
await fs.readdir(archiveDir);
|
||||
} catch {
|
||||
unreadable = true;
|
||||
}
|
||||
if (!unreadable) skip();
|
||||
|
||||
await expect(new ViewCommand().execute(tempDir)).rejects.toMatchObject({ code: 'EACCES' });
|
||||
expect(logOutput.map(stripAnsi).join('\n')).not.toContain('Archived Changes: 0');
|
||||
} finally {
|
||||
await fs.chmod(archiveDir, 0o755);
|
||||
}
|
||||
expect(logOutput.map(stripAnsi)).toEqual(withoutHistory);
|
||||
});
|
||||
|
||||
it('aligns progress bars in Active Changes when a change name exceeds 30 characters (#1986)', async () => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Preloaded with `node --import` by test/cli-e2e/startup-modules.test.ts.
|
||||
// Records the URL of every module the process loads (built-ins excluded) and
|
||||
// writes them, one per line, to the file named by OPENSPEC_TEST_MODULE_LOG
|
||||
// when the process exits.
|
||||
import * as nodeModule from 'node:module';
|
||||
import { appendFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const logFile = process.env.OPENSPEC_TEST_MODULE_LOG;
|
||||
|
||||
if (logFile) {
|
||||
writeFileSync(logFile, '');
|
||||
if (typeof nodeModule.registerHooks === 'function') {
|
||||
// Node 22.15+/23.5+: synchronous, in-thread hooks that also see require().
|
||||
const loaded = [];
|
||||
nodeModule.registerHooks({
|
||||
load(url, context, nextLoad) {
|
||||
if (!url.startsWith('node:')) loaded.push(url);
|
||||
return nextLoad(url, context);
|
||||
},
|
||||
});
|
||||
process.on('exit', () => appendFileSync(logFile, loaded.join('\n')));
|
||||
} else {
|
||||
// Node 20: asynchronous hooks on the loader thread. They see every module
|
||||
// reached through `import`, which covers the CLI's own (ESM) modules and
|
||||
// the entry module of every package it imports.
|
||||
const hooks = `
|
||||
import { appendFileSync } from 'node:fs';
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (!url.startsWith('node:')) appendFileSync(${JSON.stringify(logFile)}, url + '\\n');
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
`;
|
||||
nodeModule.register(`data:text/javascript,${encodeURIComponent(hooks)}`);
|
||||
}
|
||||
}
|
||||
Generated
+10
-10
@@ -7,8 +7,8 @@ settings:
|
||||
overrides:
|
||||
postcss: ^8.5.28
|
||||
sharp: ^0.35.3
|
||||
brace-expansion@<=5.0.8: '>=5.0.9 <6'
|
||||
fast-uri@<3.1.6: ^3.1.6
|
||||
brace-expansion@<5.0.12: '>=5.0.12 <6'
|
||||
fast-uri@<3.1.8: ^3.1.8
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
|
||||
importers:
|
||||
@@ -1171,8 +1171,8 @@ packages:
|
||||
resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
bytes@3.0.0:
|
||||
@@ -1390,8 +1390,8 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.7:
|
||||
resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
|
||||
fast-uri@3.1.8:
|
||||
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
@@ -3234,7 +3234,7 @@ snapshots:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.7
|
||||
fast-uri: 3.1.8
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -3284,7 +3284,7 @@ snapshots:
|
||||
widest-line: 4.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -3516,7 +3516,7 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.7: {}
|
||||
fast-uri@3.1.8: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
optionalDependencies:
|
||||
@@ -4301,7 +4301,7 @@ snapshots:
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
|
||||
@@ -12,8 +12,12 @@ allowBuilds:
|
||||
overrides:
|
||||
postcss: ^8.5.28
|
||||
sharp: ^0.35.3
|
||||
brace-expansion@<=5.0.8: '>=5.0.9 <6'
|
||||
fast-uri@<3.1.6: ^3.1.6
|
||||
# GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr — brace-expansion
|
||||
# DoS via nested or comma-heavy brace groups. Dev-only (serve > serve-handler >
|
||||
# minimatch); never in the static site.
|
||||
brace-expansion@<5.0.12: '>=5.0.12 <6'
|
||||
# GHSA-hrr3-gc8f-f4qj — fast-uri. Dev-only (serve > ajv).
|
||||
fast-uri@<3.1.8: ^3.1.8
|
||||
# GHSA-2v37-7h3g-55p8 / CVE-2026-67213 — nanoid infinite loop on size=0. Build-time
|
||||
# only (transitive via postcss); this is a statically exported site with no server
|
||||
# runtime. Remove once transitive nanoid is >=3.3.17 (check: pnpm why nanoid).
|
||||
|
||||
Reference in New Issue
Block a user