Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 4772d71b4e Merge main into fix-windsurf-workrules
Resolve conflict in docs/supported-tools.md: keep both the new Trae
entry from main and the updated Windsurf workflows path from this branch.
2026-01-29 17:57:26 -08:00
Haven-SandTabish Bidiwale ddbfa529f4 docs: modernize opsx.md (#616)
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-29 17:38:23 -08:00
Costica Puntaru [Tica]andTabish Bidiwale d3c3d66e67 fix(archive): fall back to copy+remove on EPERM/EXDEV (fixes #197) (#605)
On Windows, fs.rename() often fails with EPERM when moving non-empty
directories. Fall back to recursive copy then rm when rename throws
EPERM or EXDEV so 'openspec archive' succeeds where Move-Item works.

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-29 17:26:58 -08:00
moe1214andTabish Bidiwale 277be194ef docs: support Trae AI (#601)
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-29 17:23:30 -08:00
Tabish Bidiwale f45ba73a5f feat: add openspec dashboard command for web-based project browsing (#615)
Implements a new `openspec dashboard` command that serves a local HTTP server with a web-based dashboard for exploring changes, specs, and archive. Features include:
- Three-tab navigation for Changes, Specifications, and Archive
- Click artifacts to view rendered markdown in a detail panel
- Domain-grouped specs with requirement counts
- Task progress tracking for active changes
- Artifact status indicators (proposal, specs, design, tasks)
- Archive pagination with reverse chronological sorting
- Zero external dependencies (Node.js built-in http module)
- Port auto-increment (3000-3010) with --port override
- Cross-platform browser opening (macOS, Linux, Windows)
- Path traversal prevention on artifact API

Includes comprehensive tests for markdown renderer, data gathering, and API security (43 tests, all passing).
2026-01-29 16:45:07 -08:00
yangjun d59a0b347a fix a missing update 2026-01-28 22:35:59 +08:00
yangjun 04dc6deb5d fix windsurf workrules 2026-01-28 21:56:58 +08:00
Tabish Bidiwale 41305753b4 Fix schema listing command in migration guide (#608) 2026-01-27 19:59:47 -08:00
Jérôme BenoitandTabish Bidiwale 86d2e04cae chore(nix): improve flake with dynamic version and build optimization (#550)
* chore(nix): improve flake with dynamic version and source filtering

- Read version dynamically from package.json instead of hardcoding
- Add lib.fileset source filtering to exclude node_modules and build artifacts
- Update update-flake.sh to support dynamic version pattern
- Add hash change detection to skip unnecessary rebuilds
- Improve error handling with automatic rollback on failure
- Update specs to reflect dynamic version behavior

* chore(ci): bump Nix actions to latest versions

- nix-installer-action: v13 → v21
- magic-nix-cache-action: v8 → v13
- Update validation message for unchanged flake.nix

* chore: add changeset for Nix improvements

* fix(nix): make update-flake.sh portable to macOS

- Fix grep pattern on line 37 to include opening parenthesis
- Replace GNU grep -oP with portable sed alternatives (lines 53, 68, 70)
- Ensures script works on both Linux and macOS (BSD sed/grep)

* fix(nix): properly check build verification exit status

Fix logic bug where build failures were incorrectly reported as success.
The script now:
- Captures build exit code and output separately
- Fails fast if build returns non-zero exit code
- Only checks for 'dirty tree' warning if build succeeded

This addresses CodeRabbit review feedback on line 101-107.

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-27 14:23:11 -08:00
27 changed files with 1949 additions and 99 deletions
+13
View File
@@ -0,0 +1,13 @@
---
"@fission-ai/openspec": patch
---
### Improvements
- **Nix flake maintenance** — Version now read dynamically from package.json, reducing manual sync issues
- **Nix build optimization** — Source filtering excludes node_modules and artifacts, improving build times
- **update-flake.sh script** — Detects when hash is already correct, skipping unnecessary rebuilds
### Other
- Updated Nix CI actions to latest versions (nix-installer v21, magic-nix-cache v13)
+3 -3
View File
@@ -190,10 +190,10 @@ jobs:
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v13
uses: DeterminateSystems/nix-installer-action@v21
- name: Setup Nix cache
uses: DeterminateSystems/magic-nix-cache-action@v8
uses: DeterminateSystems/magic-nix-cache-action@v13
- name: Build with Nix
run: nix build
@@ -229,7 +229,7 @@ jobs:
- name: Check flake.nix modifications
run: |
if git diff --quiet flake.nix; then
echo "⚠️ Warning: flake.nix was not modified by update script"
echo "ℹ️ flake.nix unchanged (hash already up-to-date)"
else
echo "✅ flake.nix was updated by script"
git diff flake.nix
+1
View File
@@ -569,6 +569,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
| Cursor | `/opsx-new`, `/opsx-apply` |
| Windsurf | `/opsx-new`, `/opsx-apply` |
| Copilot | `/opsx-new`, `/opsx-apply` |
| Trae | `/openspec-new-change`, `/openspec-apply-change` |
The functionality is identical regardless of syntax.
+2 -2
View File
@@ -466,7 +466,7 @@ When determining which schema to use, OPSX checks in order:
List all available schemas:
```bash
openspec workflow schemas
openspec schemas
```
### Custom Schemas
@@ -510,7 +510,7 @@ Check that your `rules:` keys match your schema's artifact IDs:
Run this to see valid artifact IDs:
```bash
openspec workflow schemas --json
openspec schemas --json
```
### Config not being applied
+17 -18
View File
@@ -1,14 +1,16 @@
# OPSX Workflow
> **Compatibility:** Claude Code only (for now). Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).
> Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).
## What Is It?
OPSX is a **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.
OPSX is now the standard workflow for OpenSpec.
It's a **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.
## Why This Exists
The standard OpenSpec workflow works, but it's **locked down**:
The legacy OpenSpec workflow works, but it's **locked down**:
- **Instructions are hardcoded** — buried in TypeScript, you can't change them
- **All-or-nothing** — one big command creates everything, can't test individual pieces
@@ -23,7 +25,7 @@ The standard OpenSpec workflow works, but it's **locked down**:
4. **Iterate quickly** — change a template, test immediately, no rebuild
```
Standard workflow: OPSX:
Legacy workflow: OPSX:
┌────────────────────────┐ ┌────────────────────────┐
│ Hardcoded in package │ │ schema.yaml │◄── You edit this
│ (can't change) │ │ templates/*.md │◄── Or this
@@ -57,14 +59,11 @@ You're "in planning phase", then "in implementation phase", then "done". But rea
## Setup
```bash
# 1. Make sure you have openspec installed and initialized
# Make sure you have openspec installed — skills are automatically generated
openspec init
# 2. Generate the experimental skills
openspec experimental
```
This creates skills in `.claude/skills/` that Claude Code auto-detects.
This creates skills in `.claude/skills/` (or equivalent) that AI coding assistants auto-detect.
During setup, you'll be prompted to create a **project config** (`openspec/config.yaml`). This is optional but recommended.
@@ -74,7 +73,7 @@ Project config lets you set defaults and inject project-specific context into al
### Creating Config
Config is created during `experimental`, or manually:
Config is created during `openspec init`, or manually:
```yaml
# openspec/config.yaml
@@ -288,7 +287,7 @@ Think of it like git branches:
## What's Different?
| | Standard (`/openspec:proposal`) | Experimental (`/opsx:*`) |
| | Legacy (`/openspec:proposal`) | OPSX (`/opsx:*`) |
|---|---|---|
| **Structure** | One big proposal document | Discrete artifacts with dependencies |
| **Workflow** | Linear phases: plan → implement → archive | Fluid actions — do anything anytime |
@@ -299,13 +298,13 @@ Think of it like git branches:
## Architecture Deep Dive
This section explains how OPSX works under the hood and how it compares to the standard workflow.
This section explains how OPSX works under the hood and how it compares to the legacy workflow.
### Philosophy: Phases vs Actions
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STANDARD WORKFLOW │
│ LEGACY WORKFLOW │
│ (Phase-Locked, All-or-Nothing) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
@@ -347,11 +346,11 @@ This section explains how OPSX works under the hood and how it compares to the s
### Component Architecture
**Standard workflow** uses hardcoded templates in TypeScript:
**Legacy workflow** uses hardcoded templates in TypeScript:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STANDARD WORKFLOW COMPONENTS │
│ LEGACY WORKFLOW COMPONENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Hardcoded Templates (TypeScript strings) │
@@ -446,7 +445,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
### Information Flow
**Standard workflow** — agent receives static instructions:
**Legacy workflow** — agent receives static instructions:
```
User: "/openspec:proposal"
@@ -505,7 +504,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no
### Iteration Model
**Standard workflow** — awkward to iterate:
**Legacy workflow** — awkward to iterate:
```
┌─────────┐ ┌─────────┐ ┌─────────┐
@@ -598,7 +597,7 @@ artifacts:
### Summary
| Aspect | Standard | OPSX |
| Aspect | Legacy | OPSX |
|--------|----------|------|
| **Templates** | Hardcoded TypeScript | External YAML + Markdown |
| **Dependencies** | None (all at once) | DAG with topological sort |
+3 -2
View File
@@ -33,7 +33,8 @@ For each tool you select, OpenSpec installs:
| Qoder | `.qoder/skills/` | `.qoder/commands/opsx/` |
| Qwen Code | `.qwen/skills/` | `.qwen/commands/` |
| RooCode | `.roo/skills/` | `.roo/commands/` |
| Windsurf | `.windsurf/skills/` | `.windsurf/commands/opsx/` |
| Trae | `.trae/skills/` | `.trae/skills/` (via `/openspec-*`) |
| Windsurf | `.windsurf/skills/` | `.windsurf/workflows/` |
## Non-Interactive Setup
@@ -50,7 +51,7 @@ openspec init --tools all
openspec init --tools none
```
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `opencode`, `qoder`, `qwen`, `roocode`, `windsurf`
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `opencode`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
## What Gets Installed
+35 -8
View File
@@ -5,23 +5,47 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
outputs =
{ self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
in
{
packages = forAllSystems (system:
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
in
{
default = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "openspec";
version = "0.23.0";
version = (builtins.fromJSON (builtins.readFile ./package.json)).version;
src = ./.;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./src
./bin
./schemas
./scripts
./test
./package.json
./pnpm-lock.yaml
./tsconfig.json
./build.js
./vitest.config.ts
./vitest.setup.ts
./eslint.config.js
];
};
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
@@ -55,7 +79,8 @@
mainProgram = "openspec";
};
});
});
}
);
apps = forAllSystems (system: {
default = {
@@ -64,7 +89,8 @@
};
});
devShells = forAllSystems (system:
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
@@ -82,6 +108,7 @@
echo "Run 'pnpm install' to install dependencies"
'';
};
});
}
);
};
}
@@ -0,0 +1,60 @@
## 1. Project Setup and Command Registration
- [x] 1.1 Create directory structure: `src/core/dashboard/` with `index.ts`, `server.ts`, `data.ts`, `markdown.ts`
- [x] 1.2 Register `openspec dashboard` command in `src/cli/index.ts` with `--port` and `--no-open` options
- [x] 1.3 Create `DashboardCommand` class in `src/core/dashboard/index.ts` that validates openspec directory exists
## 2. Data Gathering Module
- [x] 2.1 Implement `getChangesData()` in `data.ts` that returns draft/active/completed changes with artifact existence status (proposal.md, specs/, design.md, tasks.md)
- [x] 2.2 Implement `getSpecsData()` in `data.ts` that returns specs grouped by domain prefix with requirement counts
- [x] 2.3 Implement `getArchiveData()` in `data.ts` that returns archived changes with parsed dates, sorted reverse chronologically
- [x] 2.4 Implement `getSummary()` in `data.ts` that aggregates counts for dashboard summary section
- [x] 2.5 Implement `getArtifactContent()` in `data.ts` that reads a markdown file by relative path with path traversal protection
## 3. Markdown Renderer
- [x] 3.1 Implement markdown-to-HTML converter in `markdown.ts` handling headings, paragraphs, bold, italic, and line breaks
- [x] 3.2 Add support for fenced code blocks and inline code
- [x] 3.3 Add support for unordered lists, ordered lists, and checkboxes
- [x] 3.4 Add support for blockquotes, horizontal rules, and links
## 4. HTTP Server and API
- [x] 4.1 Implement HTTP server in `server.ts` using Node.js built-in `http` module
- [x] 4.2 Add route `GET /` that serves the embedded single-page HTML dashboard
- [x] 4.3 Add route `GET /api/summary` returning aggregated project data
- [x] 4.4 Add route `GET /api/changes` returning changes with artifact status
- [x] 4.5 Add route `GET /api/specs` returning specs grouped by domain
- [x] 4.6 Add route `GET /api/archive` returning archive entries with pagination (default 50)
- [x] 4.7 Add route `GET /api/artifact?path=<relative>` returning rendered markdown HTML with path traversal guard
- [x] 4.8 Implement port selection with auto-increment from default 3000 to 3010
- [x] 4.9 Implement cross-platform browser opening (open/xdg-open/start)
- [x] 4.10 Add graceful shutdown on SIGINT/SIGTERM
## 5. Dashboard HTML/CSS/JS
- [x] 5.1 Create embedded HTML template with navigation tabs for Changes, Specs, and Archive sections
- [x] 5.2 Implement Changes view with status grouping (draft/active/completed), artifact indicators, and progress bars
- [x] 5.3 Implement Specs view with domain-prefix grouping and requirement count display
- [x] 5.4 Implement Archive view with date-based listing and "load more" pagination
- [x] 5.5 Implement artifact detail panel that fetches and displays rendered markdown on click
- [x] 5.6 Add basic responsive CSS styling with monospace fonts matching terminal aesthetic
## 6. Testing
- [x] 6.1 Add unit tests for markdown renderer in `test/core/dashboard/markdown.test.ts`
- [x] 6.2 Add unit tests for data gathering functions in `test/core/dashboard/data.test.ts`
- [x] 6.3 Add unit tests for path traversal prevention in artifact endpoint
- [x] 6.4 Add unit tests for port selection logic
- [x] 6.5 Add unit tests for domain grouping logic
- [x] 6.6 Add unit tests for archive date parsing
## 7. Polish and Integration
- [x] 7.1 Add CLI help text for the dashboard command
- [x] 7.2 Add shell completion entries for dashboard command and its options
- [x] 7.3 Handle edge cases: empty project, missing directories, unparseable specs
- [x] 7.4 Ensure cross-platform path handling with `path.join()` throughout
- [x] 7.5 Run linting and fix any issues (`pnpm run lint`)
- [x] 7.6 Run full test suite (`pnpm test`)
@@ -1,17 +1,18 @@
## ADDED Requirements
### Requirement: Automatic Version Update
The script SHALL automatically update the version in flake.nix to match package.json.
### Requirement: Dynamic Version Support
The script SHALL support flake.nix configurations that read version dynamically from package.json.
#### Scenario: Version extraction from package.json
#### Scenario: Version validation
- **WHEN** script runs
- **THEN** version is read from package.json using Node.js
- **AND** version field in flake.nix is updated to match
- **AND** script verifies flake.nix uses dynamic version pattern
- **AND** warns if hardcoded version is detected
#### Scenario: Version already up-to-date
- **WHEN** script runs and flake.nix version already matches package.json
- **THEN** script reports version is up-to-date
- **AND** continues to hash update
#### Scenario: Version display
- **WHEN** script runs
- **THEN** script displays current package version
- **AND** indicates version is read dynamically by flake.nix
### Requirement: Automatic Hash Determination
The script SHALL automatically determine and update the correct pnpm dependency hash.
@@ -29,7 +30,8 @@ The script SHALL automatically determine and update the correct pnpm dependency
#### Scenario: Hash update failure
- **WHEN** script cannot extract hash from build output
- **THEN** script exits with error
- **THEN** script restores original hash to flake.nix
- **AND** exits with error code 1
- **AND** displays build output for debugging
### Requirement: Build Verification
@@ -55,8 +57,13 @@ The script SHALL provide clear progress information and next steps.
#### Scenario: Success summary
- **WHEN** script completes successfully
- **THEN** summary shows updated version and hash
- **AND** next steps are displayed (test, commit, etc.)
- **THEN** summary shows version and hash changes
- **AND** next steps are displayed (test, verify, commit)
#### Scenario: No changes needed
- **WHEN** hash is already up-to-date
- **THEN** script reports no changes needed
- **AND** exits with success code 0
### Requirement: Script Safety
The script SHALL fail fast on errors and use safe defaults.
@@ -49,7 +49,7 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting
- **WHEN** formatting a command for Windsurf
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
- **AND** file path SHALL follow pattern `.windsurf/commands/opsx/<id>.md`
- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`
### Requirement: Command generator function
+2 -2
View File
@@ -33,8 +33,8 @@ The CI system SHALL validate that the update-flake.sh script executes successful
- **WHEN** the CI runs the update script validation
- **THEN** the script SHALL execute without errors
- **AND** the script SHALL correctly extract the version from package.json
- **AND** the script SHALL update flake.nix with the correct version
- **AND** the script SHALL correctly read the version from package.json
- **AND** the script SHALL validate that flake.nix uses dynamic version from package.json
#### Scenario: Update script with mock hash
+8 -9
View File
@@ -4,9 +4,9 @@ Utility scripts for OpenSpec maintenance and development.
## update-flake.sh
Updates `flake.nix` version and dependency hash automatically.
Updates `flake.nix` pnpm dependency hash automatically.
**When to use**: After updating dependencies or releasing a new version.
**When to use**: After updating dependencies (`pnpm install`, `pnpm update`).
**Usage**:
```bash
@@ -14,19 +14,18 @@ Updates `flake.nix` version and dependency hash automatically.
```
**What it does**:
1. Extracts version from `package.json`
2. Updates version in `flake.nix`
3. Automatically determines the correct pnpm dependency hash
4. Updates the hash in `flake.nix`
5. Verifies the build succeeds
1. Reads version from `package.json` (dynamically used by `flake.nix`)
2. Automatically determines the correct pnpm dependency hash
3. Updates the hash in `flake.nix`
4. Verifies the build succeeds
**Example workflow**:
```bash
# After version bump and dependency updates
# After dependency updates
pnpm install
./scripts/update-flake.sh
git add flake.nix
git commit -m "chore: update flake.nix for v0.18.0"
git commit -m "chore: update flake.nix dependency hash"
```
## postinstall.js
+84 -31
View File
@@ -1,14 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
# Script to update flake.nix version and dependency hash
# Run this after updating package.json version
# Updates pnpm dependency hash in flake.nix after pnpm-lock.yaml changes.
# Version is read dynamically from package.json.
# Usage: ./scripts/update-flake.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FLAKE_FILE="$PROJECT_ROOT/flake.nix"
PACKAGE_JSON="$PROJECT_ROOT/package.json"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Detect OS and set sed in-place flag
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS (BSD sed) requires empty string argument for -i
@@ -18,58 +26,103 @@ else
SED_INPLACE=(-i)
fi
echo "==> Updating flake.nix..."
echo -e "${BLUE}==> Updating flake.nix pnpm dependency hash...${NC}"
echo ""
# Extract version from package.json
VERSION=$(node -p "require('$PACKAGE_JSON').version")
echo " Detected version: $VERSION"
echo -e "${BLUE}📦 Detected package version:${NC} $VERSION"
# Update version in flake.nix
if ! grep -q "version = \"$VERSION\"" "$FLAKE_FILE"; then
echo " Updating version in flake.nix..."
sed "${SED_INPLACE[@]}" "s|version = \"[^\"]*\"|version = \"$VERSION\"|" "$FLAKE_FILE"
else
echo " Version already up-to-date in flake.nix"
# Verify flake.nix uses dynamic version
if ! grep -q "(builtins.fromJSON (builtins.readFile ./package.json)).version" "$FLAKE_FILE"; then
echo -e "${YELLOW}⚠️ Warning: flake.nix doesn't use dynamic version from package.json${NC}"
echo -e " Expected pattern: version = (builtins.fromJSON (builtins.readFile ./package.json)).version;"
echo ""
fi
# Check if pnpm-lock.yaml exists
if [ ! -f "$PROJECT_ROOT/pnpm-lock.yaml" ]; then
echo -e "${RED}❌ Error: pnpm-lock.yaml not found${NC}"
exit 1
fi
echo -e "${BLUE}🔧 Current pnpm-lock.yaml:${NC} $(stat -c%y "$PROJECT_ROOT/pnpm-lock.yaml" 2>/dev/null || stat -f%Sm "$PROJECT_ROOT/pnpm-lock.yaml")"
echo ""
# Get current hash from flake.nix
CURRENT_HASH=$(sed -nE 's/.*hash = "(sha256-[^"]+)".*/\1/p' "$FLAKE_FILE" | head -1)
echo -e "${BLUE}📌 Current hash:${NC} $CURRENT_HASH"
echo ""
# Set placeholder hash to trigger error
echo " Setting placeholder hash..."
echo -e "${YELLOW}⏳ Setting placeholder hash to calculate correct value...${NC}"
PLACEHOLDER="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$FLAKE_FILE"
# Try to build and capture the correct hash
echo " Building to get correct hash (this will fail)..."
BUILD_OUTPUT=$(nix build 2>&1 || true)
echo -e "${BLUE}🔨 Building to determine correct hash (expected to fail)...${NC}"
BUILD_OUTPUT=$(nix build --no-link 2>&1 || true)
# Extract the correct hash from error output (portable - works on macOS and Linux)
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -o 'got:[[:space:]]*sha256-[A-Za-z0-9+/=]*' | head -1 | sed 's/got:[[:space:]]*//')
# Extract the correct hash from error output
# Try multiple patterns for compatibility with different Nix versions
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | sed -nE 's/.*got:[[:space:]]*(sha256-[A-Za-z0-9+/=]+).*/\1/p' | head -1)
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | sed -nE 's/.*got:.*(sha256-[A-Za-z0-9+/=]+).*/\1/p' | head -1)
fi
if [ -z "$CORRECT_HASH" ]; then
echo "❌ Error: Could not extract hash from build output"
echo "Build output:"
echo -e "${RED}❌ Error: Could not extract hash from build output${NC}"
echo ""
echo -e "${YELLOW}Build output:${NC}"
echo "$BUILD_OUTPUT"
echo ""
echo -e "${YELLOW}Restoring original hash...${NC}"
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CURRENT_HASH\"|" "$FLAKE_FILE"
exit 1
fi
echo " Detected hash: $CORRECT_HASH"
echo -e "${GREEN}✓ Calculated hash:${NC} $CORRECT_HASH"
echo ""
# Update flake.nix with correct hash
# Check if hash changed
if [ "$CURRENT_HASH" = "$CORRECT_HASH" ]; then
echo -e "${GREEN}✓ Hash is already up-to-date!${NC}"
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
echo ""
echo -e "${BLUE}ℹ️ No changes needed. Your flake is in sync with pnpm-lock.yaml${NC}"
exit 0
fi
echo -e "${YELLOW}🔄 Updating hash in flake.nix...${NC}"
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
# Verify the build works
echo " Verifying build..."
if nix build 2>&1 | grep -q "warning: Git tree.*is dirty"; then
echo "⚠️ Warning: Git tree is dirty, but build succeeded"
else
echo "✅ Build successful"
echo -e "${BLUE}🔍 Verifying build with new hash...${NC}"
BUILD_OUTPUT=$(nix build --no-link 2>&1) && BUILD_SUCCESS=true || BUILD_SUCCESS=false
if [ "$BUILD_SUCCESS" = false ]; then
echo -e "${RED}❌ Build verification failed!${NC}"
echo ""
echo "$BUILD_OUTPUT"
exit 1
fi
if echo "$BUILD_OUTPUT" | grep -q "warning: Git tree.*is dirty"; then
echo -e "${YELLOW}⚠️ Git tree is dirty, but build succeeded${NC}"
else
echo -e "${GREEN}✓ Build verification successful${NC}"
fi
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}✅ flake.nix updated successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "✅ flake.nix updated successfully!"
echo " Version: $VERSION"
echo " Hash: $CORRECT_HASH"
echo -e "${BLUE}📋 Summary:${NC}"
echo -e " Version: $VERSION ${YELLOW}(read dynamically from package.json)${NC}"
echo -e " Old hash: $CURRENT_HASH"
echo -e " New hash: $CORRECT_HASH"
echo ""
echo -e "${BLUE}📝 Next steps:${NC}"
echo -e " 1. Test: ${GREEN}nix run . -- --version${NC}"
echo -e " 2. Verify: ${GREEN}nix flake check${NC}"
echo -e " 3. Commit: ${GREEN}git add flake.nix${NC}"
echo ""
echo "Next steps:"
echo " 1. Test: nix run . -- --version"
echo " 2. Commit: git add flake.nix"
echo " 3. Include in version bump commit"
+17
View File
@@ -201,6 +201,23 @@ program
}
});
program
.command('dashboard')
.description('Start a local web-based dashboard for browsing changes, specs, and archive')
.option('--port <number>', 'Server port (default: 3000)')
.option('--no-open', 'Do not open browser automatically')
.action(async (options?: { port?: string; open?: boolean }) => {
try {
const { DashboardCommand } = await import('../core/dashboard/index.js');
const cmd = new DashboardCommand();
await cmd.execute('.', options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Change command with subcommands
const changeCmd = program
.command('change')
+40 -3
View File
@@ -10,6 +10,43 @@ import {
type SpecUpdate,
} from './specs-apply.js';
/**
* Recursively copy a directory. Used when fs.rename fails (e.g. EPERM on Windows).
*/
async function copyDirRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Move a directory from src to dest. On Windows, fs.rename() often fails with
* EPERM when the directory is non-empty or another process has it open (IDE,
* file watcher, antivirus). Fall back to copy-then-remove when rename fails
* with EPERM or EXDEV.
*/
async function moveDirectory(src: string, dest: string): Promise<void> {
try {
await fs.rename(src, dest);
} catch (err: any) {
const code = err?.code;
if (code === 'EPERM' || code === 'EXDEV') {
await copyDirRecursive(src, dest);
await fs.rm(src, { recursive: true, force: true });
} else {
throw err;
}
}
}
export class ArchiveCommand {
async execute(
changeName?: string,
@@ -244,9 +281,9 @@ export class ArchiveCommand {
// Create archive directory if needed
await fs.mkdir(archiveDir, { recursive: true });
// Move change to archive
await fs.rename(changeDir, archivePath);
// Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)
await moveDirectory(changeDir, archivePath);
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
}
@@ -33,14 +33,14 @@ function formatTagsArray(tags: string[]): string {
/**
* Windsurf adapter for command generation.
* File path: .windsurf/commands/opsx/<id>.md
* File path: .windsurf/workflows/opsx-<id>.md
* Frontmatter: name, description, category, tags
*/
export const windsurfAdapter: ToolCommandAdapter = {
toolId: 'windsurf',
getFilePath(commandId: string): string {
return path.join('.windsurf', 'commands', 'opsx', `${commandId}.md`);
return path.join('.windsurf', 'workflows', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
+1
View File
@@ -38,6 +38,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
+250
View File
@@ -0,0 +1,250 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, type TaskProgress } from '../../utils/task-progress.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { renderMarkdown } from './markdown.js';
export interface ChangeArtifacts {
proposal: boolean;
specs: boolean;
design: boolean;
tasks: boolean;
}
export interface ChangeEntry {
name: string;
status: 'draft' | 'active' | 'completed';
artifacts: ChangeArtifacts;
progress: TaskProgress;
}
export interface SpecEntry {
name: string;
requirementCount: number;
}
export interface SpecGroup {
domain: string;
specs: SpecEntry[];
}
export interface ArchiveEntry {
name: string;
date: string;
changeName: string;
}
export interface DashboardSummary {
changes: { draft: number; active: number; completed: number; total: number };
specs: { total: number; totalRequirements: number };
archive: { total: number };
}
async function dirExists(dirPath: string): Promise<boolean> {
try {
const stat = await fs.stat(dirPath);
return stat.isDirectory();
} catch {
return false;
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function getArtifacts(changeDir: string): Promise<ChangeArtifacts> {
const [proposal, specs, design, tasks] = await Promise.all([
fileExists(path.join(changeDir, 'proposal.md')),
dirExists(path.join(changeDir, 'specs')),
fileExists(path.join(changeDir, 'design.md')),
fileExists(path.join(changeDir, 'tasks.md')),
]);
return { proposal, specs, design, tasks };
}
export async function getChangesData(openspecDir: string): Promise<ChangeEntry[]> {
const changesDir = path.join(openspecDir, 'changes');
if (!(await dirExists(changesDir))) {
return [];
}
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changes: ChangeEntry[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'archive' || entry.name.startsWith('.')) continue;
const changeDir = path.join(changesDir, entry.name);
const [progress, artifacts] = await Promise.all([
getTaskProgressForChange(changesDir, entry.name),
getArtifacts(changeDir),
]);
let status: ChangeEntry['status'];
if (progress.total === 0) {
status = 'draft';
} else if (progress.completed === progress.total) {
status = 'completed';
} else {
status = 'active';
}
changes.push({ name: entry.name, status, artifacts, progress });
}
changes.sort((a, b) => a.name.localeCompare(b.name));
return changes;
}
export async function getSpecsData(openspecDir: string): Promise<SpecGroup[]> {
const specsDir = path.join(openspecDir, 'specs');
if (!(await dirExists(specsDir))) {
return [];
}
const entries = await fs.readdir(specsDir, { withFileTypes: true });
const specs: SpecEntry[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const specFile = path.join(specsDir, entry.name, 'spec.md');
if (!(await fileExists(specFile))) continue;
let requirementCount = 0;
try {
const content = await fs.readFile(specFile, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(entry.name);
requirementCount = spec.requirements.length;
} catch {
// If spec can't be parsed, include with 0 count
}
specs.push({ name: entry.name, requirementCount });
}
specs.sort((a, b) => a.name.localeCompare(b.name));
// Group by domain prefix (text before first hyphen)
const groupMap = new Map<string, SpecEntry[]>();
for (const spec of specs) {
const hyphenIdx = spec.name.indexOf('-');
const domain = hyphenIdx > 0 ? spec.name.substring(0, hyphenIdx) : spec.name;
const group = groupMap.get(domain);
if (group) {
group.push(spec);
} else {
groupMap.set(domain, [spec]);
}
}
const groups: SpecGroup[] = [];
for (const [domain, domainSpecs] of groupMap) {
groups.push({ domain, specs: domainSpecs });
}
groups.sort((a, b) => a.domain.localeCompare(b.domain));
return groups;
}
export async function getArchiveData(
openspecDir: string,
limit = 50,
offset = 0
): Promise<{ entries: ArchiveEntry[]; total: number }> {
const archiveDir = path.join(openspecDir, 'changes', 'archive');
if (!(await dirExists(archiveDir))) {
return { entries: [], total: 0 };
}
const dirEntries = await fs.readdir(archiveDir, { withFileTypes: true });
const allEntries: ArchiveEntry[] = [];
for (const entry of dirEntries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
// Parse date from YYYY-MM-DD-<name> format
const dateMatch = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
if (dateMatch) {
allEntries.push({
name: entry.name,
date: dateMatch[1],
changeName: dateMatch[2],
});
} else {
allEntries.push({
name: entry.name,
date: '',
changeName: entry.name,
});
}
}
// Sort reverse chronologically (most recent first)
allEntries.sort((a, b) => b.name.localeCompare(a.name));
const total = allEntries.length;
const entries = allEntries.slice(offset, offset + limit);
return { entries, total };
}
export async function getSummary(openspecDir: string): Promise<DashboardSummary> {
const changes = await getChangesData(openspecDir);
const specGroups = await getSpecsData(openspecDir);
const archive = await getArchiveData(openspecDir, 1, 0);
const draft = changes.filter((c) => c.status === 'draft').length;
const active = changes.filter((c) => c.status === 'active').length;
const completed = changes.filter((c) => c.status === 'completed').length;
let totalSpecs = 0;
let totalRequirements = 0;
for (const group of specGroups) {
totalSpecs += group.specs.length;
totalRequirements += group.specs.reduce((sum, s) => sum + s.requirementCount, 0);
}
return {
changes: { draft, active, completed, total: draft + active + completed },
specs: { total: totalSpecs, totalRequirements },
archive: { total: archive.total },
};
}
export async function getArtifactContent(
openspecDir: string,
relativePath: string
): Promise<{ html: string } | { error: string; status: number }> {
// Resolve and verify path stays within openspec directory
const resolvedOpenspec = path.resolve(openspecDir);
const resolvedPath = path.resolve(openspecDir, relativePath);
if (!resolvedPath.startsWith(resolvedOpenspec + path.sep) && resolvedPath !== resolvedOpenspec) {
return { error: 'Forbidden: path outside openspec directory', status: 403 };
}
// Only allow .md and .yaml files
const ext = path.extname(resolvedPath).toLowerCase();
if (ext !== '.md' && ext !== '.yaml' && ext !== '.yml') {
return { error: 'Forbidden: only .md and .yaml files are allowed', status: 403 };
}
try {
const content = await fs.readFile(resolvedPath, 'utf-8');
if (ext === '.md') {
return { html: renderMarkdown(content) };
}
// YAML files: render as code block
return { html: `<pre><code>${content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>` };
} catch {
return { error: 'File not found', status: 404 };
}
}
+63
View File
@@ -0,0 +1,63 @@
import path from 'path';
import { existsSync } from 'fs';
import { startServer, findAvailablePort, openBrowser } from './server.js';
export interface DashboardOptions {
port?: string;
open?: boolean;
}
export class DashboardCommand {
async execute(targetPath: string = '.', options?: DashboardOptions): Promise<void> {
const openspecDir = path.resolve(targetPath, 'openspec');
if (!existsSync(openspecDir)) {
throw new Error('No OpenSpec project found. Run "openspec init" first.');
}
const requestedPort = parseInt(options?.port || '3000', 10);
const shouldOpen = options?.open !== false;
let port: number;
if (options?.port) {
// Explicit port: use it directly, fail if unavailable
port = requestedPort;
} else {
// Default: auto-increment from 3000 to 3010
port = await findAvailablePort(3000, 3010);
}
const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });
const url = `http://127.0.0.1:${port}`;
server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');
if (shouldOpen) {
openBrowser(url);
}
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Use --port to specify a different port.`
);
}
throw err;
});
// Graceful shutdown
const shutdown = () => {
console.log('\nShutting down dashboard server...');
server.close(() => {
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Lightweight markdown-to-HTML renderer for OpenSpec artifacts.
* Handles the subset of markdown used in OpenSpec: headings, paragraphs,
* bold, italic, code, lists, checkboxes, blockquotes, horizontal rules, links.
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderInline(text: string): string {
let result = escapeHtml(text);
// Inline code (must come before bold/italic to avoid conflicts)
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold + italic
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Links
result = result.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
return result;
}
export function renderMarkdown(markdown: string): string {
const lines = markdown.split('\n');
const html: string[] = [];
let i = 0;
let inCodeBlock = false;
let codeLines: string[] = [];
let inList = false;
let listType: 'ul' | 'ol' = 'ul';
let inBlockquote = false;
let blockquoteLines: string[] = [];
let paragraphLines: string[] = [];
function flushParagraph() {
if (paragraphLines.length > 0) {
html.push(`<p>${renderInline(paragraphLines.join(' '))}</p>`);
paragraphLines = [];
}
}
function flushList() {
if (inList) {
html.push(`</${listType}>`);
inList = false;
}
}
function flushBlockquote() {
if (inBlockquote) {
html.push(`<blockquote>${renderMarkdown(blockquoteLines.join('\n'))}</blockquote>`);
blockquoteLines = [];
inBlockquote = false;
}
}
while (i < lines.length) {
const line = lines[i];
// Fenced code blocks
if (line.trimStart().startsWith('```')) {
if (!inCodeBlock) {
flushParagraph();
flushList();
flushBlockquote();
inCodeBlock = true;
codeLines = [];
i++;
continue;
} else {
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
codeLines = [];
inCodeBlock = false;
i++;
continue;
}
}
if (inCodeBlock) {
codeLines.push(line);
i++;
continue;
}
// Empty line
if (line.trim() === '') {
flushParagraph();
flushList();
flushBlockquote();
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line.trim())) {
flushParagraph();
flushList();
flushBlockquote();
html.push('<hr>');
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushParagraph();
flushList();
flushBlockquote();
const level = headingMatch[1].length;
html.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`);
i++;
continue;
}
// Blockquote
if (line.startsWith('> ') || line === '>') {
flushParagraph();
flushList();
inBlockquote = true;
blockquoteLines.push(line.replace(/^>\s?/, ''));
i++;
continue;
}
if (inBlockquote) {
flushBlockquote();
}
// Checkbox list items
const checkboxMatch = line.match(/^[-*]\s+\[([ x])\]\s+(.*)$/i);
if (checkboxMatch) {
flushParagraph();
flushBlockquote();
if (!inList || listType !== 'ul') {
flushList();
html.push('<ul class="checklist">');
inList = true;
listType = 'ul';
}
const checked = checkboxMatch[1].toLowerCase() === 'x';
const checkedAttr = checked ? ' checked disabled' : ' disabled';
html.push(
`<li><input type="checkbox"${checkedAttr}> ${renderInline(checkboxMatch[2])}</li>`
);
i++;
continue;
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
flushParagraph();
flushBlockquote();
if (!inList || listType !== 'ul') {
flushList();
html.push('<ul>');
inList = true;
listType = 'ul';
}
html.push(`<li>${renderInline(ulMatch[1])}</li>`);
i++;
continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
flushParagraph();
flushBlockquote();
if (!inList || listType !== 'ol') {
flushList();
html.push('<ol>');
inList = true;
listType = 'ol';
}
html.push(`<li>${renderInline(olMatch[1])}</li>`);
i++;
continue;
}
// Regular paragraph line
flushList();
flushBlockquote();
paragraphLines.push(line);
i++;
}
// Flush remaining state
if (inCodeBlock) {
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
}
flushParagraph();
flushList();
flushBlockquote();
return html.join('\n');
}
+591
View File
@@ -0,0 +1,591 @@
import http from 'http';
import { exec } from 'child_process';
import { URL } from 'url';
import {
getChangesData,
getSpecsData,
getArchiveData,
getSummary,
getArtifactContent,
} from './data.js';
function sendJson(res: http.ServerResponse, data: unknown, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function sendHtml(res: http.ServerResponse, html: string, status = 200) {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
function getDashboardHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenSpec Dashboard</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--text: #e0e0e0;
--text-dim: #8888a0;
--accent: #00b4d8;
--green: #06d6a0;
--yellow: #ffd166;
--red: #ef476f;
--border: #2a2a4a;
--mono: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--mono);
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 {
font-size: 18px;
font-weight: 600;
}
header h1 span { color: var(--accent); }
.summary {
display: flex;
gap: 24px;
font-size: 13px;
color: var(--text-dim);
}
.summary .val { color: var(--text); font-weight: 600; }
nav {
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
gap: 0;
}
nav button {
background: none;
border: none;
color: var(--text-dim);
padding: 12px 24px;
font-family: var(--mono);
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
nav button:hover { color: var(--text); }
nav button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.container {
display: flex;
height: calc(100vh - 97px);
}
.panel {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.detail-panel {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
border-left: 1px solid var(--border);
background: var(--surface);
display: none;
}
.detail-panel.visible { display: block; }
.detail-panel .close-btn {
float: right;
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 4px 10px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
border-radius: 3px;
}
.detail-panel .close-btn:hover { color: var(--text); border-color: var(--text-dim); }
.detail-panel h2 { font-size: 16px; margin-bottom: 16px; color: var(--accent); }
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
margin: 20px 0 10px;
}
.section-title:first-child { margin-top: 0; }
.empty-state {
color: var(--text-dim);
font-style: italic;
padding: 16px 0;
font-size: 13px;
}
.change-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 8px;
cursor: default;
}
.change-card .name {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.change-card .artifacts {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.artifact-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
transition: background 0.15s;
}
.artifact-badge.present {
background: var(--surface2);
color: var(--accent);
border: 1px solid var(--accent);
}
.artifact-badge.missing {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
cursor: default;
}
.artifact-badge.present:hover {
background: var(--accent);
color: var(--bg);
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-dim);
}
.progress-bar {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.progress-fill.low { background: var(--red); }
.progress-fill.mid { background: var(--yellow); }
.progress-fill.high { background: var(--green); }
.domain-group {
margin-bottom: 20px;
}
.domain-label {
font-size: 13px;
font-weight: 600;
color: var(--accent);
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.spec-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.spec-item:hover { background: var(--surface2); }
.spec-item .req-count { color: var(--text-dim); font-size: 12px; }
.archive-item {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
font-size: 13px;
border-radius: 4px;
transition: background 0.15s;
cursor: pointer;
}
.archive-item:hover { background: var(--surface2); }
.archive-date { color: var(--text-dim); font-size: 12px; min-width: 90px; }
.load-more {
display: block;
margin: 16px auto;
padding: 8px 24px;
background: var(--surface2);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
}
.load-more:hover { background: var(--accent); color: var(--bg); }
/* Markdown content styles */
.md-content h1, .md-content h2, .md-content h3,
.md-content h4, .md-content h5, .md-content h6 {
margin: 20px 0 10px;
color: var(--text);
}
.md-content h1 { font-size: 22px; }
.md-content h2 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.md-content h3 { font-size: 15px; }
.md-content p { margin: 10px 0; line-height: 1.6; font-size: 13px; }
.md-content ul, .md-content ol { margin: 10px 0; padding-left: 24px; font-size: 13px; }
.md-content li { margin: 4px 0; line-height: 1.5; }
.md-content ul.checklist { list-style: none; padding-left: 4px; }
.md-content ul.checklist li { display: flex; align-items: baseline; gap: 6px; }
.md-content code {
background: var(--bg);
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.md-content pre {
background: var(--bg);
padding: 14px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
font-size: 12px;
line-height: 1.5;
}
.md-content pre code { padding: 0; background: none; }
.md-content blockquote {
border-left: 3px solid var(--accent);
padding: 8px 16px;
margin: 12px 0;
color: var(--text-dim);
background: rgba(0,180,216,0.05);
}
.md-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.md-content a { color: var(--accent); }
.md-content strong { color: var(--text); }
</style>
</head>
<body>
<header>
<h1><span>OpenSpec</span> Dashboard</h1>
<div class="summary" id="summary"></div>
</header>
<nav>
<button class="active" data-tab="changes">Changes</button>
<button data-tab="specs">Specifications</button>
<button data-tab="archive">Archive</button>
</nav>
<div class="container">
<div class="panel" id="main-panel"></div>
<div class="detail-panel" id="detail-panel">
<button class="close-btn" onclick="closeDetail()">close</button>
<h2 id="detail-title"></h2>
<div class="md-content" id="detail-content"></div>
</div>
</div>
<script>
let currentTab = 'changes';
let archiveOffset = 0;
let archiveTotal = 0;
let archiveEntries = [];
document.querySelectorAll('nav button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
closeDetail();
loadTab(currentTab);
});
});
async function api(path) {
const res = await fetch(path);
return res.json();
}
async function loadSummary() {
const s = await api('/api/summary');
document.getElementById('summary').innerHTML =
'<div>Changes: <span class="val">' + s.changes.total + '</span></div>' +
'<div>Specs: <span class="val">' + s.specs.total + '</span></div>' +
'<div>Requirements: <span class="val">' + s.specs.totalRequirements + '</span></div>' +
'<div>Archived: <span class="val">' + s.archive.total + '</span></div>';
}
async function loadTab(tab) {
if (tab === 'changes') await loadChanges();
else if (tab === 'specs') await loadSpecs();
else if (tab === 'archive') { archiveOffset = 0; archiveEntries = []; await loadArchive(); }
}
async function loadChanges() {
const changes = await api('/api/changes');
const panel = document.getElementById('main-panel');
const draft = changes.filter(c => c.status === 'draft');
const active = changes.filter(c => c.status === 'active');
const completed = changes.filter(c => c.status === 'completed');
let html = '';
if (changes.length === 0) {
html = '<div class="empty-state">No changes found.</div>';
panel.innerHTML = html;
return;
}
if (active.length > 0) {
html += '<div class="section-title">Active</div>';
active.forEach(c => { html += changeCard(c); });
}
if (draft.length > 0) {
html += '<div class="section-title">Draft</div>';
draft.forEach(c => { html += changeCard(c); });
}
if (completed.length > 0) {
html += '<div class="section-title">Completed</div>';
completed.forEach(c => { html += changeCard(c); });
}
panel.innerHTML = html;
}
function changeCard(c) {
const pct = c.progress.total > 0 ? Math.round((c.progress.completed / c.progress.total) * 100) : 0;
const cls = pct < 33 ? 'low' : pct < 66 ? 'mid' : 'high';
const artifacts = [
badge('proposal', c.artifacts.proposal, 'changes/' + c.name + '/proposal.md'),
badge('specs', c.artifacts.specs, 'changes/' + c.name + '/specs'),
badge('design', c.artifacts.design, 'changes/' + c.name + '/design.md'),
badge('tasks', c.artifacts.tasks, 'changes/' + c.name + '/tasks.md'),
].join('');
let progress = '';
if (c.progress.total > 0) {
progress = '<div class="progress-container">' +
'<div class="progress-bar"><div class="progress-fill ' + cls + '" style="width:' + pct + '%"></div></div>' +
'<span>' + c.progress.completed + '/' + c.progress.total + ' (' + pct + '%)</span></div>';
}
return '<div class="change-card"><div class="name">' + esc(c.name) + '</div>' +
'<div class="artifacts">' + artifacts + '</div>' + progress + '</div>';
}
function badge(label, present, path) {
if (present) {
return '<span class="artifact-badge present" onclick="viewArtifact(\\'' + path + '\\',\\'' + label + '\\')">' + label + '</span>';
}
return '<span class="artifact-badge missing">' + label + '</span>';
}
async function loadSpecs() {
const groups = await api('/api/specs');
const panel = document.getElementById('main-panel');
if (groups.length === 0) {
panel.innerHTML = '<div class="empty-state">No specifications found.</div>';
return;
}
let html = '';
groups.forEach(g => {
html += '<div class="domain-group"><div class="domain-label">' + esc(g.domain) + '</div>';
g.specs.forEach(s => {
const reqLabel = s.requirementCount === 1 ? 'requirement' : 'requirements';
html += '<div class="spec-item" onclick="viewArtifact(\\'specs/' + s.name + '/spec.md\\',\\'' + esc(s.name) + '\\')">' +
'<span>' + esc(s.name) + '</span>' +
'<span class="req-count">' + s.requirementCount + ' ' + reqLabel + '</span></div>';
});
html += '</div>';
});
panel.innerHTML = html;
}
async function loadArchive() {
const data = await api('/api/archive?limit=50&offset=' + archiveOffset);
archiveTotal = data.total;
archiveEntries = archiveEntries.concat(data.entries);
archiveOffset += data.entries.length;
const panel = document.getElementById('main-panel');
if (archiveEntries.length === 0) {
panel.innerHTML = '<div class="empty-state">No archived changes found.</div>';
return;
}
let html = '<div class="section-title">Archive (' + archiveTotal + ' total)</div>';
archiveEntries.forEach(e => {
html += '<div class="archive-item" onclick="viewArtifact(\\'changes/archive/' + e.name + '/proposal.md\\',\\'' + esc(e.changeName) + '\\')">' +
'<span class="archive-date">' + esc(e.date || 'N/A') + '</span>' +
'<span>' + esc(e.changeName) + '</span></div>';
});
if (archiveOffset < archiveTotal) {
html += '<button class="load-more" onclick="loadArchive()">Load more</button>';
}
panel.innerHTML = html;
}
async function viewArtifact(path, title) {
const dp = document.getElementById('detail-panel');
const dt = document.getElementById('detail-title');
const dc = document.getElementById('detail-content');
dp.classList.add('visible');
dt.textContent = title;
dc.innerHTML = '<div class="empty-state">Loading...</div>';
try {
const res = await fetch('/api/artifact?path=' + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
dc.innerHTML = '<div class="empty-state">' + esc(data.error) + '</div>';
} else {
dc.innerHTML = data.html;
}
} catch (err) {
dc.innerHTML = '<div class="empty-state">Failed to load artifact.</div>';
}
}
function closeDetail() {
document.getElementById('detail-panel').classList.remove('visible');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
loadSummary();
loadTab('changes');
</script>
</body>
</html>`;
}
export interface ServerOptions {
port: number;
openspecDir: string;
noOpen: boolean;
}
export async function startServer(options: ServerOptions): Promise<http.Server> {
const { port, openspecDir } = options;
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://localhost:${port}`);
const pathname = url.pathname;
try {
if (pathname === '/') {
sendHtml(res, getDashboardHtml());
return;
}
if (pathname === '/api/summary') {
const data = await getSummary(openspecDir);
sendJson(res, data);
return;
}
if (pathname === '/api/changes') {
const data = await getChangesData(openspecDir);
sendJson(res, data);
return;
}
if (pathname === '/api/specs') {
const data = await getSpecsData(openspecDir);
sendJson(res, data);
return;
}
if (pathname === '/api/archive') {
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const data = await getArchiveData(openspecDir, limit, offset);
sendJson(res, data);
return;
}
if (pathname === '/api/artifact') {
const artifactPath = url.searchParams.get('path');
if (!artifactPath) {
sendJson(res, { error: 'Missing path parameter' }, 400);
return;
}
const result = await getArtifactContent(openspecDir, artifactPath);
if ('error' in result) {
sendJson(res, { error: result.error }, result.status);
} else {
sendJson(res, result);
}
return;
}
// 404 for everything else
sendJson(res, { error: 'Not found' }, 404);
} catch (error) {
sendJson(res, { error: 'Internal server error' }, 500);
}
});
return server;
}
export async function findAvailablePort(startPort: number, maxPort: number): Promise<number> {
for (let port = startPort; port <= maxPort; port++) {
const available = await isPortAvailable(port);
if (available) return port;
}
throw new Error(
`No available port found between ${startPort} and ${maxPort}. Use --port to specify a different port.`
);
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = http.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
export function openBrowser(url: string): void {
const platform = process.platform;
let command: string;
if (platform === 'darwin') {
command = `open "${url}"`;
} else if (platform === 'win32') {
command = `start "" "${url}"`;
} else {
command = `xdg-open "${url}"`;
}
exec(command, (error) => {
if (error) {
console.log(`Could not open browser automatically. Visit: ${url}`);
}
});
}
@@ -107,7 +107,7 @@ describe('command-generation/adapters', () => {
it('should generate correct file path', () => {
const filePath = windsurfAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.windsurf', 'commands', 'opsx', 'explore.md'));
expect(filePath).toBe(path.join('.windsurf', 'workflows', 'opsx-explore.md'));
});
it('should format file similar to Claude format', () => {
@@ -495,7 +495,7 @@ describe('command-generation/adapters', () => {
it('Windsurf adapter uses path.join for paths', () => {
const filePath = windsurfAdapter.getFilePath('test');
expect(filePath.split(path.sep)).toEqual(['.windsurf', 'commands', 'opsx', 'test.md']);
expect(filePath.split(path.sep)).toEqual(['.windsurf', 'workflows', 'opsx-test.md']);
});
it('All adapters use path.join for paths', () => {
+254
View File
@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import {
getChangesData,
getSpecsData,
getArchiveData,
getSummary,
getArtifactContent,
} from '../../../src/core/dashboard/data.js';
describe('Dashboard data module', () => {
let tempDir: string;
let openspecDir: string;
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `openspec-dashboard-data-test-${Date.now()}`);
openspecDir = path.join(tempDir, 'openspec');
await fs.mkdir(openspecDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('getChangesData', () => {
it('returns empty array when changes directory is missing', async () => {
const result = await getChangesData(openspecDir);
expect(result).toEqual([]);
});
it('categorizes changes by task status', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(changesDir, { recursive: true });
// Draft: no tasks.md
await fs.mkdir(path.join(changesDir, 'draft-change'));
// Active: incomplete tasks
await fs.mkdir(path.join(changesDir, 'active-change'));
await fs.writeFile(
path.join(changesDir, 'active-change', 'tasks.md'),
'- [x] Done\n- [ ] Not done\n'
);
// Completed: all tasks done
await fs.mkdir(path.join(changesDir, 'completed-change'));
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Done\n- [x] Also done\n'
);
const result = await getChangesData(openspecDir);
const draft = result.filter((c) => c.status === 'draft');
const active = result.filter((c) => c.status === 'active');
const completed = result.filter((c) => c.status === 'completed');
expect(draft).toHaveLength(1);
expect(draft[0].name).toBe('draft-change');
expect(active).toHaveLength(1);
expect(active[0].name).toBe('active-change');
expect(active[0].progress).toEqual({ total: 2, completed: 1 });
expect(completed).toHaveLength(1);
expect(completed[0].name).toBe('completed-change');
});
it('detects artifact existence', async () => {
const changesDir = path.join(openspecDir, 'changes');
const changeDir = path.join(changesDir, 'test-change');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Proposal');
await fs.mkdir(path.join(changeDir, 'specs'));
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design');
// No tasks.md
const result = await getChangesData(openspecDir);
expect(result[0].artifacts).toEqual({
proposal: true,
specs: true,
design: true,
tasks: false,
});
});
it('skips archive directory and hidden directories', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true });
await fs.mkdir(path.join(changesDir, '.hidden'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'real-change'), { recursive: true });
const result = await getChangesData(openspecDir);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('real-change');
});
});
describe('getSpecsData', () => {
it('returns empty array when specs directory is missing', async () => {
const result = await getSpecsData(openspecDir);
expect(result).toEqual([]);
});
it('groups specs by domain prefix', async () => {
const specsDir = path.join(openspecDir, 'specs');
// Create specs with domain prefixes
for (const name of ['cli-init', 'cli-view', 'docs-format', 'standalone']) {
const specDir = path.join(specsDir, name);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
'## Purpose\nTest spec\n\n## Requirements\n\n### Requirement: Test\nSome requirement\n'
);
}
const result = await getSpecsData(openspecDir);
expect(result).toHaveLength(3); // cli, docs, standalone
expect(result.map((g) => g.domain)).toEqual(['cli', 'docs', 'standalone']);
const cliGroup = result.find((g) => g.domain === 'cli')!;
expect(cliGroup.specs).toHaveLength(2);
expect(cliGroup.specs.map((s) => s.name)).toEqual(['cli-init', 'cli-view']);
});
it('sorts domains and specs alphabetically', async () => {
const specsDir = path.join(openspecDir, 'specs');
for (const name of ['z-spec', 'a-spec', 'a-other']) {
const specDir = path.join(specsDir, name);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(
path.join(specDir, 'spec.md'),
'## Purpose\nTest\n\n## Requirements\n\n### Requirement: R1\nReq\n'
);
}
const result = await getSpecsData(openspecDir);
expect(result.map((g) => g.domain)).toEqual(['a', 'z']);
const aGroup = result[0];
expect(aGroup.specs.map((s) => s.name)).toEqual(['a-other', 'a-spec']);
});
});
describe('getArchiveData', () => {
it('returns empty when archive directory is missing', async () => {
const result = await getArchiveData(openspecDir);
expect(result).toEqual({ entries: [], total: 0 });
});
it('parses dates from YYYY-MM-DD-name format', async () => {
const archiveDir = path.join(openspecDir, 'changes', 'archive');
await fs.mkdir(path.join(archiveDir, '2024-06-15-auth-refactor'), { recursive: true });
await fs.mkdir(path.join(archiveDir, '2024-07-01-new-feature'), { recursive: true });
const result = await getArchiveData(openspecDir);
expect(result.total).toBe(2);
// Reverse chronological order
expect(result.entries[0].date).toBe('2024-07-01');
expect(result.entries[0].changeName).toBe('new-feature');
expect(result.entries[1].date).toBe('2024-06-15');
expect(result.entries[1].changeName).toBe('auth-refactor');
});
it('supports pagination with limit and offset', async () => {
const archiveDir = path.join(openspecDir, 'changes', 'archive');
for (let i = 1; i <= 5; i++) {
await fs.mkdir(path.join(archiveDir, `2024-01-0${i}-change-${i}`), { recursive: true });
}
const page1 = await getArchiveData(openspecDir, 2, 0);
expect(page1.total).toBe(5);
expect(page1.entries).toHaveLength(2);
const page2 = await getArchiveData(openspecDir, 2, 2);
expect(page2.entries).toHaveLength(2);
const page3 = await getArchiveData(openspecDir, 2, 4);
expect(page3.entries).toHaveLength(1);
});
});
describe('getArtifactContent', () => {
it('returns rendered HTML for markdown files', async () => {
const changesDir = path.join(openspecDir, 'changes', 'test');
await fs.mkdir(changesDir, { recursive: true });
await fs.writeFile(path.join(changesDir, 'proposal.md'), '# Hello\n\nWorld');
const result = await getArtifactContent(openspecDir, 'changes/test/proposal.md');
expect('html' in result).toBe(true);
if ('html' in result) {
expect(result.html).toContain('<h1>Hello</h1>');
expect(result.html).toContain('<p>World</p>');
}
});
it('blocks path traversal attempts', async () => {
const result = await getArtifactContent(openspecDir, '../../../etc/passwd');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.status).toBe(403);
}
});
it('blocks non-markdown file requests', async () => {
const result = await getArtifactContent(openspecDir, 'changes/test/secret.json');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.status).toBe(403);
}
});
it('returns 404 for missing files', async () => {
const result = await getArtifactContent(openspecDir, 'changes/nonexistent/proposal.md');
expect('error' in result).toBe(true);
if ('error' in result) {
expect(result.status).toBe(404);
}
});
it('allows .yaml files', async () => {
await fs.writeFile(path.join(openspecDir, 'config.yaml'), 'key: value');
const result = await getArtifactContent(openspecDir, 'config.yaml');
expect('html' in result).toBe(true);
if ('html' in result) {
expect(result.html).toContain('key: value');
}
});
});
describe('getSummary', () => {
it('aggregates counts correctly', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(changesDir, { recursive: true });
// 1 draft, 1 active, 1 completed
await fs.mkdir(path.join(changesDir, 'draft'));
await fs.mkdir(path.join(changesDir, 'active'));
await fs.writeFile(path.join(changesDir, 'active', 'tasks.md'), '- [ ] Todo\n');
await fs.mkdir(path.join(changesDir, 'done'));
await fs.writeFile(path.join(changesDir, 'done', 'tasks.md'), '- [x] Done\n');
const summary = await getSummary(openspecDir);
expect(summary.changes.draft).toBe(1);
expect(summary.changes.active).toBe(1);
expect(summary.changes.completed).toBe(1);
expect(summary.changes.total).toBe(3);
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../../../src/core/dashboard/markdown.js';
describe('renderMarkdown', () => {
it('renders headings h1-h6', () => {
expect(renderMarkdown('# Title')).toContain('<h1>Title</h1>');
expect(renderMarkdown('## Subtitle')).toContain('<h2>Subtitle</h2>');
expect(renderMarkdown('### H3')).toContain('<h3>H3</h3>');
expect(renderMarkdown('###### H6')).toContain('<h6>H6</h6>');
});
it('renders paragraphs', () => {
expect(renderMarkdown('Hello world')).toContain('<p>Hello world</p>');
});
it('renders bold text', () => {
const html = renderMarkdown('This is **bold** text');
expect(html).toContain('<strong>bold</strong>');
});
it('renders italic text', () => {
const html = renderMarkdown('This is *italic* text');
expect(html).toContain('<em>italic</em>');
});
it('renders bold+italic text', () => {
const html = renderMarkdown('This is ***both*** styled');
expect(html).toContain('<strong><em>both</em></strong>');
});
it('renders inline code', () => {
const html = renderMarkdown('Use `npm install` to install');
expect(html).toContain('<code>npm install</code>');
});
it('renders fenced code blocks', () => {
const md = '```\nconst x = 1;\nconst y = 2;\n```';
const html = renderMarkdown(md);
expect(html).toContain('<pre><code>');
expect(html).toContain('const x = 1;');
expect(html).toContain('const y = 2;');
});
it('renders unordered lists', () => {
const md = '- Item 1\n- Item 2\n- Item 3';
const html = renderMarkdown(md);
expect(html).toContain('<ul>');
expect(html).toContain('<li>Item 1</li>');
expect(html).toContain('<li>Item 2</li>');
expect(html).toContain('<li>Item 3</li>');
expect(html).toContain('</ul>');
});
it('renders ordered lists', () => {
const md = '1. First\n2. Second\n3. Third';
const html = renderMarkdown(md);
expect(html).toContain('<ol>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('</ol>');
});
it('renders checkboxes', () => {
const md = '- [x] Done task\n- [ ] Pending task';
const html = renderMarkdown(md);
expect(html).toContain('class="checklist"');
expect(html).toContain('checked disabled');
expect(html).toContain('Done task');
expect(html).toContain('Pending task');
});
it('renders blockquotes', () => {
const md = '> This is a quote';
const html = renderMarkdown(md);
expect(html).toContain('<blockquote>');
expect(html).toContain('This is a quote');
});
it('renders horizontal rules', () => {
expect(renderMarkdown('---')).toContain('<hr>');
expect(renderMarkdown('***')).toContain('<hr>');
expect(renderMarkdown('___')).toContain('<hr>');
});
it('renders links', () => {
const md = 'Visit [OpenSpec](https://example.com) for more';
const html = renderMarkdown(md);
expect(html).toContain('<a href="https://example.com"');
expect(html).toContain('>OpenSpec</a>');
});
it('escapes HTML entities', () => {
const md = 'Use <div> and &amp; in text';
const html = renderMarkdown(md);
expect(html).toContain('&lt;div&gt;');
expect(html).toContain('&amp;amp;');
});
it('handles mixed content', () => {
const md = [
'# Title',
'',
'A paragraph with **bold** and *italic*.',
'',
'- List item 1',
'- List item 2',
'',
'```',
'code block',
'```',
].join('\n');
const html = renderMarkdown(md);
expect(html).toContain('<h1>Title</h1>');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
expect(html).toContain('<ul>');
expect(html).toContain('<pre><code>');
});
it('handles empty input', () => {
expect(renderMarkdown('')).toBe('');
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
import { startServer, findAvailablePort } from '../../../src/core/dashboard/server.js';
function fetch(url: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () =>
resolve({ status: res.statusCode || 0, body, headers: res.headers })
);
}).on('error', reject);
});
}
describe('Dashboard server', () => {
let tempDir: string;
let openspecDir: string;
let server: http.Server;
let port: number;
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `openspec-dashboard-server-test-${Date.now()}`);
openspecDir = path.join(tempDir, 'openspec');
await fs.mkdir(openspecDir, { recursive: true });
port = await findAvailablePort(9100, 9200);
server = await startServer({ port, openspecDir, noOpen: true });
await new Promise<void>((resolve) => {
server.listen(port, '127.0.0.1', () => resolve());
});
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await fs.rm(tempDir, { recursive: true, force: true });
});
it('serves dashboard HTML at /', async () => {
const res = await fetch(`http://127.0.0.1:${port}/`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.body).toContain('OpenSpec Dashboard');
});
it('serves summary API', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/summary`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data).toHaveProperty('changes');
expect(data).toHaveProperty('specs');
expect(data).toHaveProperty('archive');
});
it('serves changes API', async () => {
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true });
const res = await fetch(`http://127.0.0.1:${port}/api/changes`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data).toHaveLength(1);
expect(data[0].name).toBe('test-change');
});
it('serves specs API', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/specs`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(Array.isArray(data)).toBe(true);
});
it('serves archive API with pagination', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/archive?limit=10&offset=0`);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data).toHaveProperty('entries');
expect(data).toHaveProperty('total');
});
it('serves artifact content', async () => {
const changesDir = path.join(openspecDir, 'changes', 'test');
await fs.mkdir(changesDir, { recursive: true });
await fs.writeFile(path.join(changesDir, 'proposal.md'), '# Hello');
const res = await fetch(
`http://127.0.0.1:${port}/api/artifact?path=${encodeURIComponent('changes/test/proposal.md')}`
);
expect(res.status).toBe(200);
const data = JSON.parse(res.body);
expect(data.html).toContain('<h1>Hello</h1>');
});
it('blocks path traversal in artifact API', async () => {
const res = await fetch(
`http://127.0.0.1:${port}/api/artifact?path=${encodeURIComponent('../../../etc/passwd')}`
);
expect(res.status).toBe(403);
});
it('returns 400 for missing artifact path', async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/artifact`);
expect(res.status).toBe(400);
});
it('returns 404 for unknown routes', async () => {
const res = await fetch(`http://127.0.0.1:${port}/unknown`);
expect(res.status).toBe(404);
});
});
describe('findAvailablePort', () => {
it('returns first available port', async () => {
const port = await findAvailablePort(9300, 9310);
expect(port).toBeGreaterThanOrEqual(9300);
expect(port).toBeLessThanOrEqual(9310);
});
it('throws when no port is available', async () => {
// Create servers on a small range
const servers: http.Server[] = [];
const startPort = 9400;
const endPort = 9402;
for (let p = startPort; p <= endPort; p++) {
const s = http.createServer();
await new Promise<void>((resolve) => s.listen(p, '127.0.0.1', () => resolve()));
servers.push(s);
}
try {
await expect(findAvailablePort(startPort, endPort)).rejects.toThrow('No available port');
} finally {
await Promise.all(servers.map((s) => new Promise<void>((r) => s.close(() => r()))));
}
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ describe('InitCommand', () => {
await fs.mkdir(testDir, { recursive: true });
// Mock console.log to suppress output during tests
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => { });
});
afterEach(async () => {
@@ -375,7 +375,7 @@ describe('InitCommand', () => {
const initCommand = new InitCommand({ tools: 'windsurf', force: true });
await initCommand.execute(testDir);
const cmdFile = path.join(testDir, '.windsurf', 'commands', 'opsx', 'explore.md');
const cmdFile = path.join(testDir, '.windsurf', 'workflows', 'opsx-explore.md');
expect(await fileExists(cmdFile)).toBe(true);
});
+2 -3
View File
@@ -301,9 +301,8 @@ Old instructions content
const windsurfCmd = path.join(
testDir,
'.windsurf',
'commands',
'opsx',
'explore.md'
'workflows',
'opsx-explore.md'
);
const exists = await FileSystemUtils.fileExists(windsurfCmd);
expect(exists).toBe(true);