mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-13 05:09:32 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd0720e9b | ||
|
|
af7b6e3ed1 | ||
|
|
63089e01df | ||
|
|
287c1f3654 | ||
|
|
7359b4846a | ||
|
|
88526e6b93 | ||
|
|
367aa12892 | ||
|
|
dcfb6afe0c | ||
|
|
e5b321c80c | ||
|
|
86925b2b2d | ||
|
|
c8b839f3b4 | ||
|
|
3a0992020b | ||
|
|
4ec34c744b | ||
|
|
5534779843 | ||
|
|
54467f2e1c | ||
|
|
745845dd3e | ||
|
|
604ecb8bd1 | ||
|
|
5a4837c37d |
@@ -15,10 +15,12 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
test_pr:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 10
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -48,7 +50,68 @@ jobs:
|
||||
- name: Upload test coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
name: coverage-report-pr
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
test_matrix:
|
||||
name: Test (${{ matrix.label }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
if: github.event_name != 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
shell: bash
|
||||
label: linux-bash
|
||||
- os: macos-latest
|
||||
shell: bash
|
||||
label: macos-bash
|
||||
- os: windows-latest
|
||||
shell: pwsh
|
||||
label: windows-pwsh
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: ${{ matrix.shell }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Print environment diagnostics
|
||||
run: |
|
||||
node -p "JSON.stringify({ platform: process.platform, arch: process.arch, shell: process.env.SHELL || process.env.ComSpec || '' })"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Upload test coverage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-main
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
@@ -122,15 +185,15 @@ jobs:
|
||||
echo "Changesets not configured, skipping validation"
|
||||
fi
|
||||
|
||||
required-checks:
|
||||
required-checks-pr:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint]
|
||||
if: always()
|
||||
needs: [test_pr, lint]
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
run: |
|
||||
if [[ "${{ needs.test.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.test_pr.result }}" != "success" ]]; then
|
||||
echo "Test job failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -138,4 +201,22 @@ jobs:
|
||||
echo "Lint job failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required checks passed!"
|
||||
echo "All required checks passed!"
|
||||
|
||||
required-checks-main:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_matrix, lint]
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
run: |
|
||||
if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then
|
||||
echo "Matrix test job failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.lint.result }}" != "success" ]]; then
|
||||
echo "Lint job failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required checks passed!"
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
# Repository Guidelines
|
||||
|
||||
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
|
||||
## Project Structure & Module Organization
|
||||
OpenSpec ships as a TypeScript-first CLI. Source code lives in `src`, with feature logic in `core`, interactive flows in `cli`, reusable helpers in `utils`, and command wiring in `commands`. After `pnpm run build`, deliverables land in `dist` and feed the published entry point `bin/openspec.js`. Specs and change proposals reside in `openspec/specs` and `openspec/changes`; update them whenever behavior shifts so automation stays aligned. Shared assets live in `assets`, and Vitest suites in `test` mirror the source layout for easy cross-reference.
|
||||
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
## Build, Test, and Development Commands
|
||||
Run `pnpm install` to sync dependencies. `pnpm run build` compiles TypeScript to `dist` and must stay green before release. Use `pnpm run dev` for a `tsc --watch` loop and `pnpm run dev:cli` to rebuild then execute the local CLI. `pnpm test` runs the Vitest suite once, `pnpm run test:watch` keeps it hot while iterating, and `pnpm run test:coverage` verifies instrumentation thresholds. Use `pnpm run changeset` when preparing a release entry.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
<!-- OPENSPEC:END -->
|
||||
## Coding Style & Naming Conventions
|
||||
We follow idiomatic TypeScript with ES modules, 2-space indentation, and semicolons. Prefer named exports from index barrels and keep filenames kebab-cased (e.g., `list-command.ts`). Classes use `PascalCase`, functions and variables use `camelCase`, and constants representing flags may use `SCREAMING_SNAKE_CASE`. Keep modules small, colocate helpers under `src/utils`, and avoid new dependencies without spec-backed justification.
|
||||
|
||||
## Complexity Management
|
||||
## Testing Guidelines
|
||||
Every behavior change needs Vitest coverage under `test`, co-located by feature (e.g., `test/core/update.test.ts`). Name suites after the module under test and lean on `vitest.setup.ts` for shared configuration. Run `pnpm test` before pushing and add regression cases for each bug fix or spec requirement.
|
||||
|
||||
**Default to minimal solutions:**
|
||||
- Propose <100 lines of new code for features
|
||||
- Prefer single-file implementations until proven insufficient
|
||||
- Avoid frameworks, abstractions, and optimizations without clear justification
|
||||
- Choose boring, well-understood patterns over novel approaches
|
||||
## Commit & Pull Request Guidelines
|
||||
Commits follow Conventional Commits (`type(scope): subject`) and stay single-line. Reference the touched module in the scope when practical. Each PR should summarize the spec or issue it fulfills, list manual verification steps, and note updates to any `openspec/` assets. Include CLI output snippets or screenshots when the UX changes, and ensure CI and coverage checks pass before requesting review.
|
||||
|
||||
**Question requests for complexity:**
|
||||
- Caching? → Ask for performance data and targets
|
||||
- New framework? → Suggest plain code first
|
||||
- Extra layers? → Start with the thinnest viable design
|
||||
|
||||
**Justify complexity with data:**
|
||||
- Performance metrics showing current solution is too slow
|
||||
- Concrete scale requirements (e.g., >1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring an abstraction
|
||||
|
||||
## Package Manager
|
||||
Always use pnpm (NOT npm or yarn) for all Node.js package management:
|
||||
- Install dependencies: `pnpm install`
|
||||
- Add packages: `pnpm add [package]`
|
||||
- Run scripts: `pnpm run [script]`
|
||||
|
||||
## Git Commits
|
||||
Use conventional commits with these rules:
|
||||
- Format: `type(scope): subject` (e.g., `fix: resolve auth error`, `feat(api): add user endpoint`)
|
||||
- Keep commit messages to ONE line only - no body or footer
|
||||
- Common types: feat, fix, docs, style, refactor, test, chore
|
||||
- Never add co-authorship lines or attribution
|
||||
## OpenSpec Workflow Tips
|
||||
Treat specs as the contract: update `openspec/project.md` or the relevant `openspec/specs/*.md` before coding, then run `pnpm run dev:cli` to validate the CLI against the revised artifacts. `openspec list --specs` confirms the catalog, and `openspec change` drafts proposals—commit these alongside code so reviewers can trace rationale to implementation.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add OpenSpec change proposals for CLI improvements and enhanced user experience
|
||||
- Add Opencode slash commands support for AI-driven development workflows
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add documentation improvements including --yes flag for archive command template and Discord badge
|
||||
- Fix normalize line endings in markdown parser to handle CRLF files properly
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://nodejs.org/"><img alt="node version" src="https://img.shields.io/node/v/@fission-ai/openspec?style=flat-square" /></a>
|
||||
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" /></a>
|
||||
<a href="https://conventionalcommits.org"><img alt="Conventional Commits" src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square" /></a>
|
||||
<a href="https://discord.gg/saTQQGQZ"><img alt="Discord" src="https://img.shields.io/discord/1411657095639601154?logo=discord&logoColor=white&style=flat-square" /></a>
|
||||
<a href="https://discord.gg/saTQQGQZ"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=flat-square" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -83,13 +83,14 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
|------|----------|
|
||||
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
|
||||
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
|
||||
#### AGENTS.md Compatible
|
||||
These tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
| Codex • Amp • Jules • OpenCode • Gemini CLI • GitHub Copilot • Others |
|
||||
| Codex • Amp • Jules • Gemini CLI • GitHub Copilot • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
@@ -184,13 +185,13 @@ You: Please archive the change
|
||||
(Shortcut for tools with slash commands: /openspec:archive add-profile-filters)
|
||||
|
||||
AI: I'll archive the add-profile-filters change.
|
||||
*Runs: openspec archive add-profile-filters*
|
||||
*Runs: openspec archive add-profile-filters --yes*
|
||||
✓ Change archived successfully. Specs updated. Ready for the next feature!
|
||||
```
|
||||
|
||||
Or run the command yourself in terminal:
|
||||
```bash
|
||||
$ openspec archive add-profile-filters # Archive the completed change
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
```
|
||||
|
||||
**Note:** Tools with native slash commands (Claude Code, Cursor) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
@@ -202,7 +203,7 @@ openspec list # View active change folders
|
||||
openspec view # Interactive dashboard of specs and changes
|
||||
openspec show <change> # Display change details (proposal, tasks, spec updates)
|
||||
openspec validate <change> # Check spec formatting and structure
|
||||
openspec archive <change> # Move a completed change into archive/
|
||||
openspec archive <change> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
|
||||
```
|
||||
|
||||
## Example: How AI Creates OpenSpec Files
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const runTsc = (args = []) => {
|
||||
const tscPath = require.resolve('typescript/bin/tsc');
|
||||
execFileSync(process.execPath, [tscPath, ...args], { stdio: 'inherit' });
|
||||
};
|
||||
|
||||
console.log('🔨 Building OpenSpec...\n');
|
||||
|
||||
@@ -14,10 +22,10 @@ if (existsSync('dist')) {
|
||||
// Run TypeScript compiler (use local version explicitly)
|
||||
console.log('Compiling TypeScript...');
|
||||
try {
|
||||
execSync('./node_modules/.bin/tsc -v', { stdio: 'inherit' });
|
||||
execSync('./node_modules/.bin/tsc', { stdio: 'inherit' });
|
||||
runTsc(['--version']);
|
||||
runTsc();
|
||||
console.log('\n✅ Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('\n❌ Build failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -58,7 +58,7 @@ Skip proposal for:
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive [change] --skip-specs` for tooling-only changes
|
||||
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -95,7 +95,7 @@ openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] # Archive after deployment
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -117,6 +117,7 @@ openspec validate [change] --strict
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -447,7 +448,7 @@ openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] # Mark complete
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -13,3 +13,9 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
@@ -12,6 +12,11 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
|
||||
## 4. Verification
|
||||
- [x] 4.1 Add tests verifying slash command files are created and updated correctly.
|
||||
|
||||
## 5. OpenCode Integration
|
||||
- [x] 5.1 Generate `.opencode/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.
|
||||
- [x] 5.2 Update existing `.opencode/commands/*` files during `openspec update`.
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
Recent cross-shell regressions for `openspec` commands revealed that our existing unit/integration tests do not exercise the packaged CLI or shell-specific behavior. The prior attempt at Vitest spawn tests stalled because it coupled e2e coverage with `pnpm pack` installs, which fail in network-restricted environments. With those findings incorporated, we now need an approved plan to realign the work.
|
||||
|
||||
## What Changes
|
||||
- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and shared helpers.
|
||||
- Expand coverage to cross-shell/OS matrices once the spawn harness is stable, ensuring both the direct `node dist/cli/index.js` invocation and the bin shim are exercised with non-TTY defaults and captured diagnostics.
|
||||
- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and a shared `runCLI` helper.
|
||||
- Expand coverage once the spawn harness is stable, keeping the initial matrix focused on bash jobs for Linux/macOS and `pwsh` on Windows while exercising both the direct `node dist/cli/index.js` invocation and the bin shim with non-TTY defaults and captured diagnostics.
|
||||
- Treat packaging/install validation as an optional CI safeguard: when a runner has registry access, run a simple pnpm-based pack→install→smoke-test flow; otherwise document it as out of scope while closing remaining hardening items.
|
||||
- Close out the remaining cross-shell hardening items: ensure `.gitattributes` covers packaged assets, enforce executable bits for CLI shims during CI, and finish the pending SIGINT handling improvements.
|
||||
|
||||
## Impact
|
||||
- Tests: add `test/cli-e2e` spawn suite, helpers, and fixture usage updates; adjust `vitest.setup.ts` as needed.
|
||||
- Tooling: update GitHub Actions workflows to add shell/OS matrices and (optionally) a packaging install check where network is available.
|
||||
- Docs: keep `CROSS-SHELL-PLAN.md` aligned with the phased rollout and record any limitations called out during execution.
|
||||
- Tests: add `test/cli-e2e` spawn suite, create the shared `runCLI` helper, and adjust `vitest.setup.ts` as needed.
|
||||
- Tooling: update GitHub Actions workflows with the lightweight matrix above and (optionally) a packaging install check where network is available.
|
||||
- Docs: note phase progress and any limitations inline in this proposal (or the relevant spec) so future phases have clear context.
|
||||
|
||||
### Phase 1 Status
|
||||
- Shared `test/helpers/run-cli.ts` guarantees the CLI bundle exists before spawning and enforces non-TTY defaults for every invocation.
|
||||
- New `test/cli-e2e/basic.test.ts` covers `--help`, `--version`, a successful `validate --all --json`, and an unknown-item error path against the `tmp-init` fixture copy.
|
||||
- Legacy top-level `validate` exec tests now rely on `runCLI`, avoiding manual `execSync` usage while keeping their fixture authoring intact.
|
||||
- CI matrix groundwork is in place (bash on Linux/macOS, pwsh on Windows) so the spawn suite runs the same way the helper does across supported shells.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
## 1. Phase 1 – Stabilize Local Spawn Coverage
|
||||
- [ ] 1.1 Update `vitest.setup.ts` and helpers so the CLI build runs once and `runCLI` executes `node dist/cli.js` with non-TTY defaults.
|
||||
- [ ] 1.2 Reuse the minimal fixture set (`tmp-init` or copy) to seed initial spawn tests for help/version, a happy-path `validate`, and a representative error flow.
|
||||
- [ ] 1.3 Document the Phase 1 coverage details in `CROSS-SHELL-PLAN.md`, noting any outstanding gaps.
|
||||
- [x] 1.1 Add `test/helpers/run-cli.ts` that ensures the build runs once and executes `node dist/cli/index.js` with non-TTY defaults; update `vitest.setup.ts` to reuse the shared build step.
|
||||
- [x] 1.2 Seed `test/cli-e2e` using the minimal fixture set (`tmp-init` or copy) to cover help/version, a happy-path `validate`, and a representative error flow via the new helper.
|
||||
- [x] 1.3 Migrate the highest-value existing CLI exec tests (e.g., validate) onto `runCLI` and summarize Phase 1 coverage in this proposal for the next phase.
|
||||
|
||||
## 2. Phase 2 – Expand Cross-Shell Validation
|
||||
- [ ] 2.1 Exercise both entry points (`node dist/cli.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.
|
||||
- [ ] 2.2 Extend GitHub Actions to run the spawn suite across a matrix of shells (bash, zsh, fish, pwsh, cmd) on macOS, Linux, and Windows runners.
|
||||
- [ ] 2.1 Exercise both entry points (`node dist/cli/index.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.
|
||||
- [x] 2.2 Extend GitHub Actions to run the spawn suite on bash jobs for Linux/macOS and a `pwsh` job on Windows; capture shell/OS diagnostics and note follow-ups for additional shells.
|
||||
|
||||
## 3. Phase 3 – Package Validation (Optional)
|
||||
- [ ] 3.1 Add a simple CI job on runners with registry access that runs `pnpm pack`, installs the tarball into a temp workspace (e.g., `pnpm add --no-save`), and executes `pnpm exec openspec --version`.
|
||||
- [ ] 3.2 If network-restricted environments can’t exercise installs, document the limitation in `CROSS-SHELL-PLAN.md` and skip the job there.
|
||||
- [ ] 3.3 Close out remaining hardening items from the original cross-shell plan (e.g., `.gitattributes`, chmod enforcement, SIGINT follow-ups) and update the plan accordingly.
|
||||
- [ ] 3.2 If network-restricted environments can’t exercise installs, skip the job and note the limitation in this proposal’s rollout log.
|
||||
- [ ] 3.3 Close out the enumerated hardening items: extend `.gitattributes` to cover packaged assets, enforce executable bits for CLI shims during CI, and finish the outstanding SIGINT handling work; update this proposal once they land.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
## 1. Guard the regression
|
||||
- [ ] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected.
|
||||
- [ ] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success.
|
||||
- [x] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected.
|
||||
- [x] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success.
|
||||
|
||||
## 2. Normalize parsing
|
||||
- [ ] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\r`.
|
||||
- [ ] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path.
|
||||
- [x] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\r`.
|
||||
- [x] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path.
|
||||
|
||||
## 3. Document and verify
|
||||
- [ ] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings.
|
||||
- [ ] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix.
|
||||
- [x] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings.
|
||||
- [x] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
@@ -19,5 +19,6 @@ export interface AIToolOption {
|
||||
export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Claude Code (✅ OpenSpec custom slash commands available)', value: 'claude', available: true, successLabel: 'Claude Code' },
|
||||
{ name: 'Cursor (✅ OpenSpec custom slash commands available)', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
{ name: 'OpenCode (✅ OpenSpec custom slash commands available)', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'AGENTS.md (works with Codex, Amp, Copilot, …)', value: 'agents', available: true, successLabel: 'your AGENTS.md-compatible assistant' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".opencode/command/openspec-proposal.md",
|
||||
apply: ".opencode/command/openspec-apply.md",
|
||||
archive: ".opencode/command/openspec-archive.md",
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
agent: build
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
apply: `---
|
||||
agent: build
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
archive: `---
|
||||
agent: build
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`,
|
||||
};
|
||||
|
||||
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "opencode";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { ClaudeSlashCommandConfigurator } from './claude.js';
|
||||
import { CursorSlashCommandConfigurator } from './cursor.js';
|
||||
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
|
||||
|
||||
export class SlashCommandRegistry {
|
||||
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
|
||||
@@ -8,9 +9,11 @@ export class SlashCommandRegistry {
|
||||
static {
|
||||
const claude = new ClaudeSlashCommandConfigurator();
|
||||
const cursor = new CursorSlashCommandConfigurator();
|
||||
const opencode = new OpenCodeSlashCommandConfigurator();
|
||||
|
||||
this.configurators.set(claude.toolId, claude);
|
||||
this.configurators.set(cursor.toolId, cursor);
|
||||
this.configurators.set(opencode.toolId, opencode);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
|
||||
@@ -43,7 +43,8 @@ export class JsonConverter {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split('/');
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'specs' || parts[i] === 'changes') {
|
||||
@@ -53,7 +54,8 @@ export class JsonConverter {
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = parts[parts.length - 1];
|
||||
return fileName.replace('.md', '');
|
||||
const fileName = parts[parts.length - 1] ?? '';
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+332
-241
@@ -8,7 +8,7 @@ import {
|
||||
isUpKey,
|
||||
useKeypress,
|
||||
usePagination,
|
||||
useState
|
||||
useState,
|
||||
} from '@inquirer/core';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
@@ -16,70 +16,33 @@ import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { TemplateManager, ProjectContext } from './templates/index.js';
|
||||
import { ToolRegistry } from './configurators/registry.js';
|
||||
import { SlashCommandRegistry } from './configurators/slash/registry.js';
|
||||
import { OpenSpecConfig, AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption } from './config.js';
|
||||
import {
|
||||
OpenSpecConfig,
|
||||
AI_TOOLS,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
} from './config.js';
|
||||
|
||||
const PROGRESS_SPINNER = {
|
||||
interval: 80,
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓']
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
||||
};
|
||||
|
||||
const PALETTE = {
|
||||
white: chalk.hex('#f4f4f4'),
|
||||
lightGray: chalk.hex('#c8c8c8'),
|
||||
midGray: chalk.hex('#8a8a8a'),
|
||||
darkGray: chalk.hex('#4a4a4a')
|
||||
darkGray: chalk.hex('#4a4a4a'),
|
||||
};
|
||||
|
||||
const LETTER_MAP: Record<string, string[]> = {
|
||||
O: [
|
||||
' ████ ',
|
||||
'██ ██',
|
||||
'██ ██',
|
||||
'██ ██',
|
||||
' ████ '
|
||||
],
|
||||
P: [
|
||||
'█████ ',
|
||||
'██ ██',
|
||||
'█████ ',
|
||||
'██ ',
|
||||
'██ '
|
||||
],
|
||||
E: [
|
||||
'██████',
|
||||
'██ ',
|
||||
'█████ ',
|
||||
'██ ',
|
||||
'██████'
|
||||
],
|
||||
N: [
|
||||
'██ ██',
|
||||
'███ ██',
|
||||
'██ ███',
|
||||
'██ ██',
|
||||
'██ ██'
|
||||
],
|
||||
S: [
|
||||
' █████',
|
||||
'██ ',
|
||||
' ████ ',
|
||||
' ██',
|
||||
'█████ '
|
||||
],
|
||||
C: [
|
||||
' █████',
|
||||
'██ ',
|
||||
'██ ',
|
||||
'██ ',
|
||||
' █████'
|
||||
],
|
||||
' ': [
|
||||
' ',
|
||||
' ',
|
||||
' ',
|
||||
' ',
|
||||
' '
|
||||
]
|
||||
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
|
||||
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
|
||||
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
|
||||
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
|
||||
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
|
||||
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
|
||||
' ': [' ', ' ', ' ', ' ', ' '],
|
||||
};
|
||||
|
||||
type ToolLabel = {
|
||||
@@ -87,7 +50,8 @@ type ToolLabel = {
|
||||
annotation?: string;
|
||||
};
|
||||
|
||||
const sanitizeToolLabel = (raw: string): string => raw.replace(/✅/gu, '✔').trim();
|
||||
const sanitizeToolLabel = (raw: string): string =>
|
||||
raw.replace(/✅/gu, '✔').trim();
|
||||
|
||||
const parseToolLabel = (raw: string): ToolLabel => {
|
||||
const sanitized = sanitizeToolLabel(raw);
|
||||
@@ -97,7 +61,7 @@ const parseToolLabel = (raw: string): ToolLabel => {
|
||||
}
|
||||
return {
|
||||
primary: match[1].trim(),
|
||||
annotation: match[2].trim()
|
||||
annotation: match[2].trim(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,165 +82,189 @@ type WizardStep = 'intro' | 'select' | 'review';
|
||||
|
||||
type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise<string[]>;
|
||||
|
||||
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>((config, done) => {
|
||||
const totalSteps = 3;
|
||||
const [step, setStep] = useState<WizardStep>('intro');
|
||||
const [cursor, setCursor] = useState<number>(0);
|
||||
const [selected, setSelected] = useState<string[]>(() => config.initialSelected ?? []);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
|
||||
(config, done) => {
|
||||
const totalSteps = 3;
|
||||
const [step, setStep] = useState<WizardStep>('intro');
|
||||
const [cursor, setCursor] = useState<number>(0);
|
||||
const [selected, setSelected] = useState<string[]>(
|
||||
() => config.initialSelected ?? []
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
|
||||
const selectedSet = new Set(selected);
|
||||
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
|
||||
|
||||
const updateSelected = (next: Set<string>) => {
|
||||
const ordered = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => next.has(value));
|
||||
setSelected(ordered);
|
||||
};
|
||||
const updateSelected = (next: Set<string>) => {
|
||||
const ordered = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => next.has(value));
|
||||
setSelected(ordered);
|
||||
};
|
||||
|
||||
const page = usePagination({
|
||||
items: config.choices,
|
||||
active: cursor,
|
||||
pageSize,
|
||||
loop: config.choices.length > 1,
|
||||
renderItem: ({ item, isActive }) => {
|
||||
const isSelected = selectedSet.has(item.value);
|
||||
const cursorSymbol = isActive ? PALETTE.white('›') : PALETTE.midGray(' ');
|
||||
const indicator = isSelected ? PALETTE.white('◉') : PALETTE.midGray('○');
|
||||
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
|
||||
const label = `${nameColor(item.label.primary)}${item.configured ? PALETTE.midGray(' (already configured)') : ''}`;
|
||||
return `${cursorSymbol} ${indicator} ${label}`;
|
||||
}
|
||||
});
|
||||
const page = usePagination({
|
||||
items: config.choices,
|
||||
active: cursor,
|
||||
pageSize,
|
||||
loop: config.choices.length > 1,
|
||||
renderItem: ({ item, isActive }) => {
|
||||
const isSelected = selectedSet.has(item.value);
|
||||
const cursorSymbol = isActive
|
||||
? PALETTE.white('›')
|
||||
: PALETTE.midGray(' ');
|
||||
const indicator = isSelected
|
||||
? PALETTE.white('◉')
|
||||
: PALETTE.midGray('○');
|
||||
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
|
||||
const label = `${nameColor(item.label.primary)}${
|
||||
item.configured ? PALETTE.midGray(' (already configured)') : ''
|
||||
}`;
|
||||
return `${cursorSymbol} ${indicator} ${label}`;
|
||||
},
|
||||
});
|
||||
|
||||
useKeypress((key) => {
|
||||
if (step === 'intro') {
|
||||
if (isEnterKey(key)) {
|
||||
setStep('select');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'select') {
|
||||
if (isUpKey(key)) {
|
||||
const previousIndex = cursor <= 0 ? config.choices.length - 1 : cursor - 1;
|
||||
setCursor(previousIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDownKey(key)) {
|
||||
const nextIndex = cursor >= config.choices.length - 1 ? 0 : cursor + 1;
|
||||
setCursor(nextIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSpaceKey(key)) {
|
||||
const current = config.choices[cursor];
|
||||
if (!current) return;
|
||||
|
||||
const next = new Set(selected);
|
||||
if (next.has(current.value)) {
|
||||
next.delete(current.value);
|
||||
} else {
|
||||
next.add(current.value);
|
||||
useKeypress((key) => {
|
||||
if (step === 'intro') {
|
||||
if (isEnterKey(key)) {
|
||||
setStep('select');
|
||||
}
|
||||
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
if (selected.length === 0) {
|
||||
setError('Select at least one AI tool to continue.');
|
||||
if (step === 'select') {
|
||||
if (isUpKey(key)) {
|
||||
const previousIndex =
|
||||
cursor <= 0 ? config.choices.length - 1 : cursor - 1;
|
||||
setCursor(previousIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setStep('review');
|
||||
setError(null);
|
||||
|
||||
if (isDownKey(key)) {
|
||||
const nextIndex =
|
||||
cursor >= config.choices.length - 1 ? 0 : cursor + 1;
|
||||
setCursor(nextIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSpaceKey(key)) {
|
||||
const current = config.choices[cursor];
|
||||
if (!current) return;
|
||||
|
||||
const next = new Set(selected);
|
||||
if (next.has(current.value)) {
|
||||
next.delete(current.value);
|
||||
} else {
|
||||
next.add(current.value);
|
||||
}
|
||||
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
if (selected.length === 0) {
|
||||
setError('Select at least one AI tool to continue.');
|
||||
return;
|
||||
}
|
||||
setStep('review');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
setSelected([]);
|
||||
setError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
setSelected([]);
|
||||
setError(null);
|
||||
if (step === 'review') {
|
||||
if (isEnterKey(key)) {
|
||||
const finalSelection = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => selectedSet.has(value));
|
||||
done(finalSelection);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBackspaceKey(key) || key.name === 'escape') {
|
||||
setStep('select');
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (step === 'review') {
|
||||
if (isEnterKey(key)) {
|
||||
const finalSelection = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => selectedSet.has(value));
|
||||
done(finalSelection);
|
||||
return;
|
||||
const selectedNames = config.choices
|
||||
.filter((choice) => selectedSet.has(choice.value))
|
||||
.map((choice) => choice.label.primary);
|
||||
|
||||
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
|
||||
const lines: string[] = [];
|
||||
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
|
||||
lines.push('');
|
||||
|
||||
if (step === 'intro') {
|
||||
const introHeadline = config.extendMode
|
||||
? 'Extend your OpenSpec tooling'
|
||||
: 'Configure your OpenSpec tooling';
|
||||
const introBody = config.extendMode
|
||||
? 'We detected an existing setup. We will help you refresh or add integrations.'
|
||||
: "Let's get your AI assistants connected so they understand OpenSpec.";
|
||||
|
||||
lines.push(PALETTE.white(introHeadline));
|
||||
lines.push(PALETTE.midGray(introBody));
|
||||
lines.push('');
|
||||
lines.push(PALETTE.midGray('Press Enter to continue.'));
|
||||
} else if (step === 'select') {
|
||||
lines.push(PALETTE.white(config.baseMessage));
|
||||
lines.push(
|
||||
PALETTE.midGray(
|
||||
'Use ↑/↓ to move · Space to toggle · Enter to review selections.'
|
||||
)
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(page);
|
||||
lines.push('');
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(
|
||||
`${PALETTE.midGray('Selected')}: ${PALETTE.midGray(
|
||||
'None selected yet'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
lines.push(PALETTE.midGray('Selected:'));
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (isBackspaceKey(key) || key.name === 'escape') {
|
||||
setStep('select');
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const selectedNames = config.choices
|
||||
.filter((choice) => selectedSet.has(choice.value))
|
||||
.map((choice) => choice.label.primary);
|
||||
|
||||
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
|
||||
const lines: string[] = [];
|
||||
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
|
||||
lines.push('');
|
||||
|
||||
if (step === 'intro') {
|
||||
const introHeadline = config.extendMode
|
||||
? 'Extend your OpenSpec tooling'
|
||||
: 'Configure your OpenSpec tooling';
|
||||
const introBody = config.extendMode
|
||||
? 'We detected an existing setup. We will help you refresh or add integrations.'
|
||||
: "Let's get your AI assistants connected so they understand OpenSpec.";
|
||||
|
||||
lines.push(PALETTE.white(introHeadline));
|
||||
lines.push(PALETTE.midGray(introBody));
|
||||
lines.push('');
|
||||
lines.push(PALETTE.midGray('Press Enter to continue.'));
|
||||
} else if (step === 'select') {
|
||||
lines.push(PALETTE.white(config.baseMessage));
|
||||
lines.push(PALETTE.midGray('Use ↑/↓ to move · Space to toggle · Enter to review selections.'));
|
||||
lines.push('');
|
||||
lines.push(page);
|
||||
lines.push('');
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(`${PALETTE.midGray('Selected')}: ${PALETTE.midGray('None selected yet')}`);
|
||||
} else {
|
||||
lines.push(PALETTE.midGray('Selected:'));
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
lines.push(PALETTE.white('Review selections'));
|
||||
lines.push(
|
||||
PALETTE.midGray('Press Enter to confirm or Backspace to adjust.')
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(
|
||||
PALETTE.midGray('No tools selected. Press Backspace to return.')
|
||||
);
|
||||
} else {
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(PALETTE.white('Review selections'));
|
||||
lines.push(PALETTE.midGray('Press Enter to confirm or Backspace to adjust.'));
|
||||
lines.push('');
|
||||
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(PALETTE.midGray('No tools selected. Press Backspace to return.'));
|
||||
} else {
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
});
|
||||
);
|
||||
|
||||
type InitCommandOptions = {
|
||||
prompt?: ToolSelectionPrompt;
|
||||
@@ -307,32 +295,48 @@ export class InitCommand {
|
||||
if (extendMode) {
|
||||
throw new Error(
|
||||
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
||||
`Use 'openspec update' to update the structure.`
|
||||
`Use 'openspec update' to update the structure.`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('You must select at least one AI tool to configure.');
|
||||
}
|
||||
|
||||
const availableTools = AI_TOOLS.filter(tool => tool.available);
|
||||
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
||||
const selectedIds = new Set(config.aiTools);
|
||||
const selectedTools = availableTools.filter(tool => selectedIds.has(tool.value));
|
||||
const created = selectedTools.filter(tool => !existingToolStates[tool.value]);
|
||||
const refreshed = selectedTools.filter(tool => existingToolStates[tool.value]);
|
||||
const skippedExisting = availableTools.filter(tool => !selectedIds.has(tool.value) && existingToolStates[tool.value]);
|
||||
const skipped = availableTools.filter(tool => !selectedIds.has(tool.value) && !existingToolStates[tool.value]);
|
||||
const selectedTools = availableTools.filter((tool) =>
|
||||
selectedIds.has(tool.value)
|
||||
);
|
||||
const created = selectedTools.filter(
|
||||
(tool) => !existingToolStates[tool.value]
|
||||
);
|
||||
const refreshed = selectedTools.filter(
|
||||
(tool) => existingToolStates[tool.value]
|
||||
);
|
||||
const skippedExisting = availableTools.filter(
|
||||
(tool) => !selectedIds.has(tool.value) && existingToolStates[tool.value]
|
||||
);
|
||||
const skipped = availableTools.filter(
|
||||
(tool) => !selectedIds.has(tool.value) && !existingToolStates[tool.value]
|
||||
);
|
||||
|
||||
// Step 1: Create directory structure
|
||||
if (!extendMode) {
|
||||
const structureSpinner = this.startSpinner('Creating OpenSpec structure...');
|
||||
const structureSpinner = this.startSpinner(
|
||||
'Creating OpenSpec structure...'
|
||||
);
|
||||
await this.createDirectoryStructure(openspecPath);
|
||||
await this.generateFiles(openspecPath, config);
|
||||
structureSpinner.stopAndPersist({
|
||||
symbol: PALETTE.white('▌'),
|
||||
text: PALETTE.white('OpenSpec structure created')
|
||||
text: PALETTE.white('OpenSpec structure created'),
|
||||
});
|
||||
} else {
|
||||
ora({ stream: process.stdout }).info(PALETTE.midGray('ℹ OpenSpec already initialized. Skipping base scaffolding.'));
|
||||
ora({ stream: process.stdout }).info(
|
||||
PALETTE.midGray(
|
||||
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Configure AI tools
|
||||
@@ -340,30 +344,49 @@ export class InitCommand {
|
||||
await this.configureAITools(projectPath, openspecDir, config.aiTools);
|
||||
toolSpinner.stopAndPersist({
|
||||
symbol: PALETTE.white('▌'),
|
||||
text: PALETTE.white('AI tools configured')
|
||||
text: PALETTE.white('AI tools configured'),
|
||||
});
|
||||
|
||||
// Success message
|
||||
this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode);
|
||||
this.displaySuccessMessage(
|
||||
selectedTools,
|
||||
created,
|
||||
refreshed,
|
||||
skippedExisting,
|
||||
skipped,
|
||||
extendMode
|
||||
);
|
||||
}
|
||||
|
||||
private async validate(projectPath: string, _openspecPath: string): Promise<boolean> {
|
||||
private async validate(
|
||||
projectPath: string,
|
||||
_openspecPath: string
|
||||
): Promise<boolean> {
|
||||
const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
|
||||
|
||||
// Check write permissions
|
||||
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
|
||||
if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {
|
||||
throw new Error(`Insufficient permissions to write to ${projectPath}`);
|
||||
}
|
||||
return extendMode;
|
||||
}
|
||||
|
||||
private async getConfiguration(existingTools: Record<string, boolean>, extendMode: boolean): Promise<OpenSpecConfig> {
|
||||
const selectedTools = await this.promptForAITools(existingTools, extendMode);
|
||||
private async getConfiguration(
|
||||
existingTools: Record<string, boolean>,
|
||||
extendMode: boolean
|
||||
): Promise<OpenSpecConfig> {
|
||||
const selectedTools = await this.promptForAITools(
|
||||
existingTools,
|
||||
extendMode
|
||||
);
|
||||
return { aiTools: selectedTools };
|
||||
}
|
||||
|
||||
private async promptForAITools(existingTools: Record<string, boolean>, extendMode: boolean): Promise<string[]> {
|
||||
const availableTools = AI_TOOLS.filter(tool => tool.available);
|
||||
private async promptForAITools(
|
||||
existingTools: Record<string, boolean>,
|
||||
extendMode: boolean
|
||||
): Promise<string[]> {
|
||||
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
return [];
|
||||
@@ -373,7 +396,9 @@ export class InitCommand {
|
||||
? 'Which AI tools would you like to add or refresh?'
|
||||
: 'Which AI tools do you use?';
|
||||
const initialSelected = extendMode
|
||||
? availableTools.filter(tool => existingTools[tool.value]).map(tool => tool.value)
|
||||
? availableTools
|
||||
.filter((tool) => existingTools[tool.value])
|
||||
.map((tool) => tool.value)
|
||||
: [];
|
||||
|
||||
return this.prompt({
|
||||
@@ -382,13 +407,15 @@ export class InitCommand {
|
||||
choices: availableTools.map((tool) => ({
|
||||
value: tool.value,
|
||||
label: parseToolLabel(tool.name),
|
||||
configured: Boolean(existingTools[tool.value])
|
||||
configured: Boolean(existingTools[tool.value]),
|
||||
})),
|
||||
initialSelected
|
||||
initialSelected,
|
||||
});
|
||||
}
|
||||
|
||||
private async getExistingToolStates(projectPath: string): Promise<Record<string, boolean>> {
|
||||
private async getExistingToolStates(
|
||||
projectPath: string
|
||||
): Promise<Record<string, boolean>> {
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const tool of AI_TOOLS) {
|
||||
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
|
||||
@@ -396,14 +423,22 @@ export class InitCommand {
|
||||
return states;
|
||||
}
|
||||
|
||||
private async isToolConfigured(projectPath: string, toolId: string): Promise<boolean> {
|
||||
private async isToolConfigured(
|
||||
projectPath: string,
|
||||
toolId: string
|
||||
): Promise<boolean> {
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (configFile && await FileSystemUtils.fileExists(path.join(projectPath, configFile))) return true;
|
||||
if (
|
||||
configFile &&
|
||||
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
|
||||
)
|
||||
return true;
|
||||
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (!slashConfigurator) return false;
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path))) return true;
|
||||
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -413,7 +448,7 @@ export class InitCommand {
|
||||
openspecPath,
|
||||
path.join(openspecPath, 'specs'),
|
||||
path.join(openspecPath, 'changes'),
|
||||
path.join(openspecPath, 'changes', 'archive')
|
||||
path.join(openspecPath, 'changes', 'archive'),
|
||||
];
|
||||
|
||||
for (const dir of directories) {
|
||||
@@ -421,24 +456,32 @@ export class InitCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateFiles(openspecPath: string, config: OpenSpecConfig): Promise<void> {
|
||||
private async generateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
const context: ProjectContext = {
|
||||
// Could be enhanced with prompts for project details
|
||||
};
|
||||
|
||||
const templates = TemplateManager.getTemplates(context);
|
||||
|
||||
|
||||
for (const template of templates) {
|
||||
const filePath = path.join(openspecPath, template.path);
|
||||
const content = typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
: template.content;
|
||||
|
||||
const content =
|
||||
typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
: template.content;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
private async configureAITools(projectPath: string, openspecDir: string, toolIds: string[]): Promise<void> {
|
||||
private async configureAITools(
|
||||
projectPath: string,
|
||||
openspecDir: string,
|
||||
toolIds: string[]
|
||||
): Promise<void> {
|
||||
for (const toolId of toolIds) {
|
||||
const configurator = ToolRegistry.get(toolId);
|
||||
if (configurator && configurator.isAvailable) {
|
||||
@@ -469,34 +512,80 @@ export class InitCommand {
|
||||
console.log();
|
||||
console.log(PALETTE.lightGray('Tool summary:'));
|
||||
const summaryLines = [
|
||||
created.length ? `${PALETTE.white('▌')} ${PALETTE.white('Created:')} ${this.formatToolNames(created)}` : null,
|
||||
refreshed.length ? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray('Refreshed:')} ${this.formatToolNames(refreshed)}` : null,
|
||||
skippedExisting.length ? `${PALETTE.midGray('▌')} ${PALETTE.midGray('Skipped (already configured):')} ${this.formatToolNames(skippedExisting)}` : null,
|
||||
skipped.length ? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray('Skipped:')} ${this.formatToolNames(skipped)}` : null
|
||||
created.length
|
||||
? `${PALETTE.white('▌')} ${PALETTE.white(
|
||||
'Created:'
|
||||
)} ${this.formatToolNames(created)}`
|
||||
: null,
|
||||
refreshed.length
|
||||
? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray(
|
||||
'Refreshed:'
|
||||
)} ${this.formatToolNames(refreshed)}`
|
||||
: null,
|
||||
skippedExisting.length
|
||||
? `${PALETTE.midGray('▌')} ${PALETTE.midGray(
|
||||
'Skipped (already configured):'
|
||||
)} ${this.formatToolNames(skippedExisting)}`
|
||||
: null,
|
||||
skipped.length
|
||||
? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray(
|
||||
'Skipped:'
|
||||
)} ${this.formatToolNames(skipped)}`
|
||||
: null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
for (const line of summaryLines) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(PALETTE.midGray('Use `openspec update` to refresh shared OpenSpec instructions in the future.'));
|
||||
console.log(
|
||||
PALETTE.midGray(
|
||||
'Use `openspec update` to refresh shared OpenSpec instructions in the future.'
|
||||
)
|
||||
);
|
||||
|
||||
// Get the selected tool name(s) for display
|
||||
const toolName = this.formatToolNames(selectedTools);
|
||||
|
||||
console.log();
|
||||
console.log(`Next steps - Copy these prompts to ${toolName}:`);
|
||||
console.log(chalk.gray('────────────────────────────────────────────────────────────'));
|
||||
console.log(
|
||||
chalk.gray('────────────────────────────────────────────────────────────')
|
||||
);
|
||||
console.log(PALETTE.white('1. Populate your project context:'));
|
||||
console.log(PALETTE.lightGray(' "Please read openspec/project.md and help me fill it out'));
|
||||
console.log(PALETTE.lightGray(' with details about my project, tech stack, and conventions"\n'));
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' "Please read openspec/project.md and help me fill it out'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' with details about my project, tech stack, and conventions"\n'
|
||||
)
|
||||
);
|
||||
console.log(PALETTE.white('2. Create your first change proposal:'));
|
||||
console.log(PALETTE.lightGray(' "I want to add [YOUR FEATURE HERE]. Please create an'));
|
||||
console.log(PALETTE.lightGray(' OpenSpec change proposal for this feature"\n'));
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' "I want to add [YOUR FEATURE HERE]. Please create an'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.lightGray(' OpenSpec change proposal for this feature"\n')
|
||||
);
|
||||
console.log(PALETTE.white('3. Learn the OpenSpec workflow:'));
|
||||
console.log(PALETTE.lightGray(' "Please explain the OpenSpec workflow from openspec/AGENTS.md'));
|
||||
console.log(PALETTE.lightGray(' and how I should work with you on this project"'));
|
||||
console.log(PALETTE.darkGray('────────────────────────────────────────────────────────────\n'));
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' "Please explain the OpenSpec workflow from openspec/AGENTS.md'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.lightGray(' and how I should work with you on this project"')
|
||||
);
|
||||
console.log(
|
||||
PALETTE.darkGray(
|
||||
'────────────────────────────────────────────────────────────\n'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private formatToolNames(tools: AIToolOption[]): string {
|
||||
@@ -510,7 +599,9 @@ export class InitCommand {
|
||||
const base = names.slice(0, -1).map((name) => PALETTE.white(name));
|
||||
const last = PALETTE.white(names[names.length - 1]);
|
||||
|
||||
return `${base.join(PALETTE.midGray(', '))}${base.length ? PALETTE.midGray(', and ') : ''}${last}`;
|
||||
return `${base.join(PALETTE.midGray(', '))}${
|
||||
base.length ? PALETTE.midGray(', and ') : ''
|
||||
}${last}`;
|
||||
}
|
||||
|
||||
private renderBanner(_extendMode: boolean): void {
|
||||
@@ -527,7 +618,7 @@ export class InitCommand {
|
||||
PALETTE.lightGray,
|
||||
PALETTE.midGray,
|
||||
PALETTE.lightGray,
|
||||
PALETTE.white
|
||||
PALETTE.white,
|
||||
];
|
||||
|
||||
console.log();
|
||||
@@ -544,7 +635,7 @@ export class InitCommand {
|
||||
text,
|
||||
stream: process.stdout,
|
||||
color: 'gray',
|
||||
spinner: PROGRESS_SPINNER
|
||||
spinner: PROGRESS_SPINNER,
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class ChangeParser extends MarkdownParser {
|
||||
|
||||
private parseRenames(content: string): Array<{ from: string; to: string }> {
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
const lines = content.split('\n');
|
||||
const lines = ChangeParser.normalizeContent(content).split('\n');
|
||||
|
||||
let currentRename: { from?: string; to?: string } = {};
|
||||
|
||||
@@ -177,7 +177,8 @@ export class ChangeParser extends MarkdownParser {
|
||||
}
|
||||
|
||||
private parseSectionsFromContent(content: string): Section[] {
|
||||
const lines = content.split('\n');
|
||||
const normalizedContent = ChangeParser.normalizeContent(content);
|
||||
const lines = normalizedContent.split('\n');
|
||||
const sections: Section[] = [];
|
||||
const stack: Section[] = [];
|
||||
|
||||
|
||||
@@ -12,10 +12,15 @@ export class MarkdownParser {
|
||||
private currentLine: number;
|
||||
|
||||
constructor(content: string) {
|
||||
this.lines = content.split('\n');
|
||||
const normalized = MarkdownParser.normalizeContent(content);
|
||||
this.lines = normalized.split('\n');
|
||||
this.currentLine = 0;
|
||||
}
|
||||
|
||||
protected static normalizeContent(content: string): string {
|
||||
return content.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
parseSpec(name: string): Spec {
|
||||
const sections = this.parseSections();
|
||||
const purpose = this.findSection(sections, 'Purpose')?.content || '';
|
||||
|
||||
@@ -22,7 +22,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/;
|
||||
* Extracts the Requirements section from a spec file and parses requirement blocks.
|
||||
*/
|
||||
export function extractRequirementsSection(content: string): RequirementsSectionParts {
|
||||
const lines = content.split('\n');
|
||||
const normalized = normalizeLineEndings(content);
|
||||
const lines = normalized.split('\n');
|
||||
const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
|
||||
|
||||
if (reqHeaderIndex === -1) {
|
||||
@@ -102,11 +103,16 @@ export interface DeltaPlan {
|
||||
renamed: Array<{ from: string; to: string }>;
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
||||
*/
|
||||
export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
const sections = splitTopLevelSections(content);
|
||||
const normalized = normalizeLineEndings(content);
|
||||
const sections = splitTopLevelSections(normalized);
|
||||
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
|
||||
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
|
||||
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
|
||||
@@ -136,7 +142,7 @@ function splitTopLevelSections(content: string): Record<string, string> {
|
||||
|
||||
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
|
||||
if (!sectionBody) return [];
|
||||
const lines = sectionBody.split('\n');
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
const blocks: RequirementBlock[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
@@ -161,7 +167,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc
|
||||
function parseRemovedNames(sectionBody: string): string[] {
|
||||
if (!sectionBody) return [];
|
||||
const names: string[] = [];
|
||||
const lines = sectionBody.split('\n');
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
for (const line of lines) {
|
||||
const m = line.match(REQUIREMENT_HEADER_REGEX);
|
||||
if (m) {
|
||||
@@ -180,7 +186,7 @@ function parseRemovedNames(sectionBody: string): string[] {
|
||||
function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {
|
||||
if (!sectionBody) return [];
|
||||
const pairs: Array<{ from: string; to: string }> = [];
|
||||
const lines = sectionBody.split('\n');
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
let current: { from?: string; to?: string } = {};
|
||||
for (const line of lines) {
|
||||
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
||||
|
||||
@@ -58,7 +58,7 @@ Skip proposal for:
|
||||
After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive [change] --skip-specs\` for tooling-only changes
|
||||
- Use \`openspec archive [change] --skip-specs --yes\` for tooling-only changes
|
||||
- Run \`openspec validate --strict\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -95,7 +95,7 @@ openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] # Archive after deployment
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -117,6 +117,7 @@ openspec validate [change] --strict
|
||||
- \`--strict\` - Comprehensive validation
|
||||
- \`--no-interactive\` - Disable prompts
|
||||
- \`--skip-specs\` - Archive without spec updates
|
||||
- \`--yes\`/\`-y\` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -447,7 +448,7 @@ openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] # Mark complete
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
\`\`\`
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -32,7 +32,7 @@ const applyReferences = `**Reference**
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. Identify the requested change ID (via the prompt or \`openspec list\`).
|
||||
2. Run \`openspec archive <id>\` to let the CLI move the change and apply spec updates (use \`--skip-specs\` only for tooling-only work).
|
||||
2. Run \`openspec archive <id> --yes\` to let the CLI move the change and apply spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
3. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
4. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
|
||||
@@ -331,7 +331,8 @@ export class Validator {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split('/');
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
|
||||
// Look for the directory name after 'specs' or 'changes'
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
@@ -343,8 +344,9 @@ export class Validator {
|
||||
}
|
||||
|
||||
// Fallback to filename without extension if not in expected structure
|
||||
const fileName = parts[parts.length - 1];
|
||||
return fileName.replace('.md', '');
|
||||
const fileName = parts[parts.length - 1] ?? '';
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
|
||||
}
|
||||
|
||||
private createReport(issues: ValidationIssue[]): ValidationReport {
|
||||
@@ -393,4 +395,4 @@ export class Validator {
|
||||
const matches = blockRaw.match(/^####\s+/gm);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterAll, describe, it, expect } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function prepareFixture(fixtureName: string): Promise<string> {
|
||||
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-cli-e2e-'));
|
||||
tempRoots.push(base);
|
||||
const projectDir = path.join(base, 'project');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const fixtureDir = path.join(cliProjectRoot, 'test', 'fixtures', fixtureName);
|
||||
await fs.cp(fixtureDir, projectDir, { recursive: true });
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('openspec CLI e2e basics', () => {
|
||||
it('shows help output', async () => {
|
||||
const result = await runCLI(['--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: openspec');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('reports the package version', async () => {
|
||||
const pkgRaw = await fs.readFile(path.join(cliProjectRoot, 'package.json'), 'utf-8');
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
const result = await runCLI(['--version']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it('validates the tmp-init fixture with --all --json', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['validate', '--all', '--json'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.trim();
|
||||
expect(output).not.toBe('');
|
||||
const json = JSON.parse(output);
|
||||
expect(json.summary?.totals?.failed).toBe(0);
|
||||
expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an error for unknown items in the fixture', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("Unknown item 'does-not-exist'");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,33 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
describe('top-level validate command', () => {
|
||||
const projectRoot = process.cwd();
|
||||
const testDir = path.join(projectRoot, 'test-validate-command-tmp');
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
const specsDir = path.join(testDir, 'openspec', 'specs');
|
||||
const bin = path.join(projectRoot, 'bin', 'openspec.js');
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
// Create a valid spec
|
||||
const specContent = `## Purpose
|
||||
Valid spec for testing.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Foo
|
||||
Text
|
||||
|
||||
#### Scenario: Bar
|
||||
Given A\nWhen B\nThen C`;
|
||||
const specContent = [
|
||||
'## Purpose',
|
||||
'This spec ensures the validation harness exercises a deterministic alpha module for automated tests.',
|
||||
'',
|
||||
'## Requirements',
|
||||
'',
|
||||
'### Requirement: Alpha module SHALL produce deterministic output',
|
||||
'The alpha module SHALL produce a deterministic response for validation.',
|
||||
'',
|
||||
'#### Scenario: Deterministic alpha run',
|
||||
'- **GIVEN** a configured alpha module',
|
||||
'- **WHEN** the module runs the default flow',
|
||||
'- **THEN** the output matches the expected fixture result',
|
||||
].join('\n');
|
||||
await fs.mkdir(path.join(specsDir, 'alpha'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'alpha', 'spec.md'), specContent, 'utf-8');
|
||||
|
||||
@@ -33,10 +35,26 @@ Given A\nWhen B\nThen C`;
|
||||
const changeContent = `# Test Change\n\n## Why\nBecause reasons that are sufficiently long for validation.\n\n## What Changes\n- **alpha:** Add something`;
|
||||
await fs.mkdir(path.join(changesDir, 'c1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'c1', 'proposal.md'), changeContent, 'utf-8');
|
||||
const deltaContent = [
|
||||
'## ADDED Requirements',
|
||||
'### Requirement: Validator SHALL support alpha change deltas',
|
||||
'The validator SHALL accept deltas provided by the test harness.',
|
||||
'',
|
||||
'#### Scenario: Apply alpha delta',
|
||||
'- **GIVEN** the test change delta',
|
||||
'- **WHEN** openspec validate runs',
|
||||
'- **THEN** the validator reports the change as valid',
|
||||
].join('\n');
|
||||
const c1DeltaDir = path.join(changesDir, 'c1', 'specs', 'alpha');
|
||||
await fs.mkdir(c1DeltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(c1DeltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
|
||||
// Duplicate name for ambiguity test
|
||||
await fs.mkdir(path.join(changesDir, 'dup'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'dup', 'proposal.md'), changeContent, 'utf-8');
|
||||
const dupDeltaDir = path.join(changesDir, 'dup', 'specs', 'dup');
|
||||
await fs.mkdir(dupDeltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(dupDeltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
await fs.mkdir(path.join(specsDir, 'dup'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'dup', 'spec.md'), specContent, 'utf-8');
|
||||
});
|
||||
@@ -45,78 +63,71 @@ Given A\nWhen B\nThen C`;
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints a helpful hint when no args in non-interactive mode', () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalEnv = { ...process.env };
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
process.env.OPEN_SPEC_INTERACTIVE = '0';
|
||||
let err: any;
|
||||
try {
|
||||
execSync(`node ${bin} validate`, { encoding: 'utf-8' });
|
||||
} catch (e) { err = e; }
|
||||
expect(err).toBeDefined();
|
||||
expect(err.status).not.toBe(0);
|
||||
expect(err.stderr.toString()).toContain('Nothing to validate. Try one of:');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
process.env = originalEnv;
|
||||
}
|
||||
it('prints a helpful hint when no args in non-interactive mode', async () => {
|
||||
const result = await runCLI(['validate'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Nothing to validate. Try one of:');
|
||||
});
|
||||
|
||||
it('validates all with --all and outputs JSON summary', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
let outStr = '';
|
||||
try {
|
||||
outStr = execSync(`node ${bin} validate --all --json`, { encoding: 'utf-8' });
|
||||
} catch (e: any) {
|
||||
// If exit code is non-zero (e.g., on failures), still parse stdout JSON
|
||||
outStr = e.stdout?.toString?.() ?? '';
|
||||
}
|
||||
const json = JSON.parse(outStr);
|
||||
expect(Array.isArray(json.items)).toBe(true);
|
||||
expect(json.summary?.totals?.items).toBeDefined();
|
||||
expect(json.version).toBe('1.0');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
it('validates all with --all and outputs JSON summary', async () => {
|
||||
const result = await runCLI(['validate', '--all', '--json'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.trim();
|
||||
expect(output).not.toBe('');
|
||||
const json = JSON.parse(output);
|
||||
expect(Array.isArray(json.items)).toBe(true);
|
||||
expect(json.summary?.totals?.items).toBeDefined();
|
||||
expect(json.version).toBe('1.0');
|
||||
});
|
||||
|
||||
it('validates only specs with --specs and respects --concurrency', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
let outStr = '';
|
||||
try {
|
||||
outStr = execSync(`node ${bin} validate --specs --json --concurrency 1`, { encoding: 'utf-8' });
|
||||
} catch (e: any) {
|
||||
outStr = e.stdout?.toString?.() ?? '';
|
||||
}
|
||||
const json = JSON.parse(outStr);
|
||||
// All items should be specs
|
||||
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
it('validates only specs with --specs and respects --concurrency', async () => {
|
||||
const result = await runCLI(['validate', '--specs', '--json', '--concurrency', '1'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.trim();
|
||||
expect(output).not.toBe('');
|
||||
const json = JSON.parse(output);
|
||||
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors on ambiguous item names and suggests type override', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
let err: any;
|
||||
try {
|
||||
execSync(`node ${bin} validate dup`, { encoding: 'utf-8' });
|
||||
} catch (e) { err = e; }
|
||||
expect(err).toBeDefined();
|
||||
expect(err.stderr.toString()).toContain('Ambiguous item');
|
||||
expect(err.status).not.toBe(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
it('errors on ambiguous item names and suggests type override', async () => {
|
||||
const result = await runCLI(['validate', 'dup'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Ambiguous item');
|
||||
});
|
||||
|
||||
it('accepts change proposals saved with CRLF line endings', async () => {
|
||||
const changeId = 'crlf-change';
|
||||
const toCrlf = (segments: string[]) => segments.join('\n').replace(/\n/g, '\r\n');
|
||||
|
||||
const crlfContent = toCrlf([
|
||||
'# CRLF Proposal',
|
||||
'',
|
||||
'## Why',
|
||||
'This change verifies validation works with Windows line endings.',
|
||||
'',
|
||||
'## What Changes',
|
||||
'- **alpha:** Ensure validation passes on CRLF files',
|
||||
]);
|
||||
|
||||
await fs.mkdir(path.join(changesDir, changeId), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), crlfContent, 'utf-8');
|
||||
|
||||
const deltaContent = toCrlf([
|
||||
'## ADDED Requirements',
|
||||
'### Requirement: Parser SHALL accept CRLF change proposals',
|
||||
'The parser SHALL accept CRLF change proposals without manual edits.',
|
||||
'',
|
||||
'#### Scenario: Validate CRLF change',
|
||||
'- **GIVEN** a change proposal saved with CRLF line endings',
|
||||
'- **WHEN** a developer runs openspec validate on the proposal',
|
||||
'- **THEN** validation succeeds without section errors',
|
||||
]);
|
||||
|
||||
const deltaDir = path.join(changesDir, changeId, 'specs', 'alpha');
|
||||
await fs.mkdir(deltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
|
||||
const result = await runCLI(['validate', changeId], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
+134
-41
@@ -43,7 +43,7 @@ describe('InitCommand', () => {
|
||||
selectionQueue = [];
|
||||
mockPrompt.mockReset();
|
||||
initCommand = new InitCommand({ prompt: mockPrompt });
|
||||
|
||||
|
||||
// Mock console.log to suppress output during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
@@ -56,14 +56,20 @@ describe('InitCommand', () => {
|
||||
describe('execute', () => {
|
||||
it('should create OpenSpec directory structure', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const openspecPath = path.join(testDir, 'openspec');
|
||||
expect(await directoryExists(openspecPath)).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
await directoryExists(path.join(openspecPath, 'changes', 'archive'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should create AGENTS.md and project.md', async () => {
|
||||
@@ -73,23 +79,31 @@ describe('InitCommand', () => {
|
||||
|
||||
const openspecPath = path.join(testDir, 'openspec');
|
||||
expect(await fileExists(path.join(openspecPath, 'AGENTS.md'))).toBe(true);
|
||||
expect(await fileExists(path.join(openspecPath, 'project.md'))).toBe(true);
|
||||
expect(await fileExists(path.join(openspecPath, 'project.md'))).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const agentsContent = await fs.readFile(path.join(openspecPath, 'AGENTS.md'), 'utf-8');
|
||||
const agentsContent = await fs.readFile(
|
||||
path.join(openspecPath, 'AGENTS.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(agentsContent).toContain('OpenSpec Instructions');
|
||||
|
||||
const projectContent = await fs.readFile(path.join(openspecPath, 'project.md'), 'utf-8');
|
||||
|
||||
const projectContent = await fs.readFile(
|
||||
path.join(openspecPath, 'project.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(projectContent).toContain('Project Context');
|
||||
});
|
||||
|
||||
it('should create CLAUDE.md when Claude Code is selected', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
expect(await fileExists(claudePath)).toBe(true);
|
||||
|
||||
|
||||
const content = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain('OpenSpec Instructions');
|
||||
@@ -98,13 +112,14 @@ describe('InitCommand', () => {
|
||||
|
||||
it('should update existing CLAUDE.md with markers', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
const existingContent = '# My Project Instructions\nCustom instructions here';
|
||||
const existingContent =
|
||||
'# My Project Instructions\nCustom instructions here';
|
||||
await fs.writeFile(claudePath, existingContent);
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const updatedContent = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('OpenSpec Instructions');
|
||||
@@ -134,9 +149,18 @@ describe('InitCommand', () => {
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const claudeProposal = path.join(testDir, '.claude/commands/openspec/proposal.md');
|
||||
const claudeApply = path.join(testDir, '.claude/commands/openspec/apply.md');
|
||||
const claudeArchive = path.join(testDir, '.claude/commands/openspec/archive.md');
|
||||
const claudeProposal = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/proposal.md'
|
||||
);
|
||||
const claudeApply = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/apply.md'
|
||||
);
|
||||
const claudeArchive = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(claudeProposal)).toBe(true);
|
||||
expect(await fileExists(claudeApply)).toBe(true);
|
||||
@@ -154,7 +178,9 @@ describe('InitCommand', () => {
|
||||
const archiveContent = await fs.readFile(claudeArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
expect(archiveContent).toContain('`--skip-specs` only for tooling-only work');
|
||||
expect(archiveContent).toContain(
|
||||
'`--skip-specs` only for tooling-only work'
|
||||
);
|
||||
});
|
||||
|
||||
it('should create Cursor slash command files with templates', async () => {
|
||||
@@ -162,9 +188,18 @@ describe('InitCommand', () => {
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md');
|
||||
const cursorApply = path.join(testDir, '.cursor/commands/openspec-apply.md');
|
||||
const cursorArchive = path.join(testDir, '.cursor/commands/openspec-archive.md');
|
||||
const cursorProposal = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-proposal.md'
|
||||
);
|
||||
const cursorApply = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-apply.md'
|
||||
);
|
||||
const cursorArchive = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(cursorProposal)).toBe(true);
|
||||
expect(await fileExists(cursorApply)).toBe(true);
|
||||
@@ -183,27 +218,76 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create OpenCode slash command files with templates', async () => {
|
||||
queueSelections('opencode', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const openCodeProposal = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-proposal.md'
|
||||
);
|
||||
const openCodeApply = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-apply.md'
|
||||
);
|
||||
const openCodeArchive = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(openCodeProposal)).toBe(true);
|
||||
expect(await fileExists(openCodeApply)).toBe(true);
|
||||
expect(await fileExists(openCodeArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(openCodeProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('agent: build');
|
||||
expect(proposalContent).toContain(
|
||||
'description: Scaffold a new OpenSpec change and validate strictly.'
|
||||
);
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(openCodeApply, 'utf-8');
|
||||
expect(applyContent).toContain('agent: build');
|
||||
expect(applyContent).toContain(
|
||||
'description: Implement an approved OpenSpec change and keep tasks in sync.'
|
||||
);
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(openCodeArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('agent: build');
|
||||
expect(archiveContent).toContain(
|
||||
'description: Archive a deployed OpenSpec change and update specs.'
|
||||
);
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should add new tool when OpenSpec already exists', async () => {
|
||||
queueSelections('claude', DONE, 'cursor', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md');
|
||||
const cursorProposal = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-proposal.md'
|
||||
);
|
||||
expect(await fileExists(cursorProposal)).toBe(true);
|
||||
});
|
||||
|
||||
it('should error when extend mode selects no tools', async () => {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await expect(initCommand.execute(testDir)).rejects.toThrow(/OpenSpec seems to already be initialized/);
|
||||
await expect(initCommand.execute(testDir)).rejects.toThrow(
|
||||
/OpenSpec seems to already be initialized/
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-existent target directory', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
const newDir = path.join(testDir, 'new-project');
|
||||
await initCommand.execute(newDir);
|
||||
|
||||
|
||||
const openspecPath = path.join(newDir, 'openspec');
|
||||
expect(await directoryExists(openspecPath)).toBe(true);
|
||||
});
|
||||
@@ -211,9 +295,9 @@ describe('InitCommand', () => {
|
||||
it('should display success message with selected tool name', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
const logSpy = vi.spyOn(console, 'log');
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const calls = logSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('Copy these prompts to Claude Code');
|
||||
});
|
||||
@@ -225,7 +309,9 @@ describe('InitCommand', () => {
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const calls = logSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('Copy these prompts to your AGENTS.md-compatible assistant');
|
||||
expect(calls).toContain(
|
||||
'Copy these prompts to your AGENTS.md-compatible assistant'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,7 +323,7 @@ describe('InitCommand', () => {
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseMessage: expect.stringContaining('Which AI tools do you use?')
|
||||
baseMessage: expect.stringContaining('Which AI tools do you use?'),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -247,7 +333,7 @@ describe('InitCommand', () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
// When other tools are added, we'd test their specific configurations here
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
expect(await fileExists(claudePath)).toBe(true);
|
||||
@@ -259,7 +345,9 @@ describe('InitCommand', () => {
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const claudeChoice = secondRunArgs.choices.find((choice: any) => choice.value === 'claude');
|
||||
const claudeChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -269,16 +357,21 @@ describe('InitCommand', () => {
|
||||
// This is tricky to test cross-platform, but we can test the error message
|
||||
const readOnlyDir = path.join(testDir, 'readonly');
|
||||
await fs.mkdir(readOnlyDir);
|
||||
|
||||
|
||||
// Mock the permission check to fail
|
||||
const originalCheck = fs.writeFile;
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath: any, ...args: any[]) => {
|
||||
if (typeof filePath === 'string' && filePath.includes('.openspec-test-')) {
|
||||
throw new Error('EACCES: permission denied');
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(
|
||||
async (filePath: any, ...args: any[]) => {
|
||||
if (
|
||||
typeof filePath === 'string' &&
|
||||
filePath.includes('.openspec-test-')
|
||||
) {
|
||||
throw new Error('EACCES: permission denied');
|
||||
}
|
||||
return originalCheck.call(fs, filePath, ...args);
|
||||
}
|
||||
return originalCheck.call(fs, filePath, ...args);
|
||||
});
|
||||
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(
|
||||
/Insufficient permissions/
|
||||
|
||||
@@ -169,6 +169,25 @@ Some general description of changes without specific deltas`;
|
||||
|
||||
expect(change.deltas).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses change documents saved with CRLF line endings', () => {
|
||||
const crlfContent = [
|
||||
'# CRLF Change',
|
||||
'',
|
||||
'## Why',
|
||||
'Reasons on Windows editors should parse like POSIX environments.',
|
||||
'',
|
||||
'## What Changes',
|
||||
'- **alpha:** Add cross-platform parsing coverage',
|
||||
].join('\r\n');
|
||||
|
||||
const parser = new MarkdownParser(crlfContent);
|
||||
const change = parser.parseChange('crlf-change');
|
||||
|
||||
expect(change.why).toContain('Windows editors should parse');
|
||||
expect(change.deltas).toHaveLength(1);
|
||||
expect(change.deltas[0].spec).toBe('alpha');
|
||||
});
|
||||
});
|
||||
|
||||
describe('section parsing', () => {
|
||||
|
||||
+113
-31
@@ -15,11 +15,11 @@ describe('UpdateCommand', () => {
|
||||
// Create a temporary test directory
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
|
||||
// Create openspec directory
|
||||
const openspecDir = path.join(testDir, 'openspec');
|
||||
await fs.mkdir(openspecDir, { recursive: true });
|
||||
|
||||
|
||||
updateCommand = new UpdateCommand();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ More content after.`;
|
||||
await fs.writeFile(claudePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -54,17 +54,22 @@ More content after.`;
|
||||
expect(updatedContent).toContain('OpenSpec Instructions');
|
||||
expect(updatedContent).toContain('Some existing content here');
|
||||
expect(updatedContent).toContain('More content after');
|
||||
|
||||
|
||||
// Check console output
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: CLAUDE.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Claude slash command files', async () => {
|
||||
const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md');
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: OpenSpec: Proposal
|
||||
@@ -84,13 +89,19 @@ Old slash content
|
||||
const updated = await fs.readFile(proposalPath, 'utf-8');
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated slash commands: .claude/commands/openspec/proposal.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .claude/commands/openspec/proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -98,7 +109,7 @@ Old slash content
|
||||
it('should not create CLAUDE.md if it does not exist', async () => {
|
||||
// Ensure CLAUDE.md does not exist
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -131,9 +142,51 @@ Old body
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated slash commands: .cursor/commands/openspec-apply.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .cursor/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing OpenCode slash command files', async () => {
|
||||
const openCodePath = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-apply.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(openCodePath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Old description
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(openCodePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(openCodePath, 'utf-8');
|
||||
expect(updated).toContain('id: openspec-apply');
|
||||
expect(updated).toContain('Work through tasks sequentially');
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .opencode/command/openspec-apply.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -145,7 +198,9 @@ Old body
|
||||
|
||||
// Should only update OpenSpec instructions
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -157,23 +212,33 @@ Old body
|
||||
// For now, we test with just CLAUDE.md.
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.mkdir(path.dirname(claudePath), { recursive: true });
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
await fs.writeFile(
|
||||
claudePath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report updating with new format
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: CLAUDE.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip creating missing slash commands during update', async () => {
|
||||
const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md');
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
await fs.writeFile(proposalPath, `---
|
||||
await fs.writeFile(
|
||||
proposalPath,
|
||||
`---
|
||||
name: OpenSpec: Proposal
|
||||
description: Existing file
|
||||
category: OpenSpec
|
||||
@@ -181,12 +246,17 @@ tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old content
|
||||
<!-- OPENSPEC:END -->`);
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const applyExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/apply.md'));
|
||||
const archiveExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/archive.md'));
|
||||
const applyExists = await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude/commands/openspec/apply.md')
|
||||
);
|
||||
const archiveExists = await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude/commands/openspec/archive.md')
|
||||
);
|
||||
|
||||
expect(applyExists).toBe(false);
|
||||
expect(archiveExists).toBe(false);
|
||||
@@ -195,7 +265,7 @@ Old content
|
||||
it('should never create new AI tool files', async () => {
|
||||
// Get all configurators
|
||||
const configurators = ToolRegistry.getAll();
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -253,7 +323,9 @@ Old content
|
||||
expect(updated).not.toContain('Old content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)'
|
||||
);
|
||||
expect(logMessage).not.toContain('AGENTS.md (created)');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -261,7 +333,10 @@ Old content
|
||||
|
||||
it('should throw error if openspec directory does not exist', async () => {
|
||||
// Remove openspec directory
|
||||
await fs.rm(path.join(testDir, 'openspec'), { recursive: true, force: true });
|
||||
await fs.rm(path.join(testDir, 'openspec'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Execute update command and expect error
|
||||
await expect(updateCommand.execute(testDir)).rejects.toThrow(
|
||||
@@ -272,19 +347,24 @@ Old content
|
||||
it('should handle configurator errors gracefully', async () => {
|
||||
// Create CLAUDE.md file but make it read-only to cause an error
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
await fs.writeFile(
|
||||
claudePath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
await fs.chmod(claudePath, 0o444); // Read-only
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
const errorSpy = vi.spyOn(console, 'error');
|
||||
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
|
||||
const writeSpy = vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => {
|
||||
if (filePath.endsWith('CLAUDE.md')) {
|
||||
throw new Error('EACCES: permission denied, open');
|
||||
}
|
||||
const writeSpy = vi
|
||||
.spyOn(FileSystemUtils, 'writeFile')
|
||||
.mockImplementation(async (filePath, content) => {
|
||||
if (filePath.endsWith('CLAUDE.md')) {
|
||||
throw new Error('EACCES: permission denied, open');
|
||||
}
|
||||
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
|
||||
// Execute update command - should not throw
|
||||
await updateCommand.execute(testDir);
|
||||
@@ -292,7 +372,9 @@ Old content
|
||||
// Should report the failure
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Failed to update: CLAUDE.md');
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Test Change
|
||||
|
||||
## Why
|
||||
Because reasons that are sufficiently long for validation.
|
||||
|
||||
## What Changes
|
||||
- **alpha:** Add something
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Parser SHALL accept CRLF change proposals
|
||||
The parser SHALL accept CRLF change proposals without manual edits.
|
||||
|
||||
#### Scenario: Validate CRLF change
|
||||
- **GIVEN** a change proposal saved with CRLF line endings
|
||||
- **WHEN** a developer runs openspec validate on the proposal
|
||||
- **THEN** validation succeeds without section errors
|
||||
@@ -0,0 +1,12 @@
|
||||
## Purpose
|
||||
This spec ensures the validation harness exercises a deterministic alpha module for automated tests.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Alpha module SHALL produce deterministic output
|
||||
The alpha module SHALL produce a deterministic response for validation.
|
||||
|
||||
#### Scenario: Deterministic alpha run
|
||||
- **GIVEN** a configured alpha module
|
||||
- **WHEN** the module runs the default flow
|
||||
- **THEN** the output matches the expected fixture result
|
||||
@@ -0,0 +1,139 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js');
|
||||
|
||||
let buildPromise: Promise<void> | undefined;
|
||||
|
||||
interface RunCommandOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
interface RunCLIOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface RunCLIResult {
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
command: string;
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], options: RunCommandOptions = {}) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? projectRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.on('error', (error) => reject(error));
|
||||
child.on('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
|
||||
reject(new Error(`Command failed (${reason}): ${command} ${args.join(' ')}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureCliBuilt() {
|
||||
if (existsSync(cliEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!buildPromise) {
|
||||
buildPromise = runCommand('pnpm', ['run', 'build']).catch((error) => {
|
||||
buildPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
await buildPromise;
|
||||
|
||||
if (!existsSync(cliEntry)) {
|
||||
throw new Error('CLI entry point missing after build. Expected dist/cli/index.js');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): Promise<RunCLIResult> {
|
||||
await ensureCliBuilt();
|
||||
|
||||
const finalArgs = Array.isArray(args) ? args : [args];
|
||||
const invocation = [cliEntry, ...finalArgs].join(' ');
|
||||
|
||||
return new Promise<RunCLIResult>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [cliEntry, ...finalArgs], {
|
||||
cwd: options.cwd ?? projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
OPEN_SPEC_INTERACTIVE: '0',
|
||||
...options.env,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
|
||||
child.stdout?.setEncoding('utf-8');
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
|
||||
child.stderr?.setEncoding('utf-8');
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
command: `node ${invocation}`,
|
||||
});
|
||||
});
|
||||
|
||||
if (options.input && child.stdin) {
|
||||
child.stdin.end(options.input);
|
||||
} else if (child.stdin) {
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const cliProjectRoot = projectRoot;
|
||||
+4
-19
@@ -1,21 +1,6 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { ensureCliBuilt } from './test/helpers/run-cli.js';
|
||||
|
||||
// Run once before all tests
|
||||
// Ensure the CLI bundle exists before tests execute
|
||||
export async function setup() {
|
||||
const distPath = path.join(process.cwd(), 'dist', 'cli', 'index.js');
|
||||
|
||||
if (!existsSync(distPath)) {
|
||||
console.log('Building project before tests...');
|
||||
try {
|
||||
execSync('pnpm run build', {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to build project:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
await ensureCliBuilt();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user