mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-14 05:19:53 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4772d71b4e | ||
|
|
ddbfa529f4 | ||
|
|
d3c3d66e67 | ||
|
|
277be194ef | ||
|
|
f45ba73a5f | ||
|
|
d59a0b347a | ||
|
|
04dc6deb5d | ||
|
|
41305753b4 | ||
|
|
86d2e04cae |
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
+18
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')}</code></pre>` };
|
||||
} catch {
|
||||
return { error: 'File not found', status: 404 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 & in text';
|
||||
const html = renderMarkdown(md);
|
||||
expect(html).toContain('<div>');
|
||||
expect(html).toContain('&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('');
|
||||
});
|
||||
});
|
||||
@@ -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()))));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user