diff --git a/.husky/pre-commit b/.husky/pre-commit index e03f6257..14f2a0be 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,8 +1,8 @@ -# Generate index.md files for ai/ and aidd-custom/ folders +# Generate index.md files for ai/ and aidd-custom/ folders, and sync AGENTS.md / CLAUDE.md node bin/aidd.js --index "$(git rev-parse --show-toplevel)" -# Stage any updated index.md files -git add 'ai/**/index.md' 'aidd-custom/**/index.md' 2>/dev/null || true +# Stage any updated index.md files and synced agent files +git add 'ai/**/index.md' 'aidd-custom/**/index.md' AGENTS.md CLAUDE.md 2>/dev/null || true # Generate ToC for README.md npm run toc @@ -10,4 +10,7 @@ npm run toc # Stage updated README.md if ToC was regenerated git add README.md 2>/dev/null || true +# E2E tests are intentionally excluded here — they run real npm installs and +# can take up to 180 s, which exceeds the release pipeline's hook timeout. +# AI agents should run `npm run test:e2e` manually before committing. npm run test:unit diff --git a/AGENTS.md b/AGENTS.md index 8f170e40..01eb8d60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,15 @@ and read `aidd-custom/config.yml` to load configuration into context. ## Task Index fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (`npm run test:unit`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +```sh +npm run test:e2e +``` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..01eb8d60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# AI Agent Guidelines + +This project uses AI-assisted development with structured guidance in the `ai/` directory. + +## Directory Structure + +Agents should examine the `ai/*` directory listings to understand the available commands, rules, and workflows. + +## Index Files + +Each folder in the `ai/` directory contains an `index.md` file that describes the purpose and contents of that folder. Agents can read these index files to learn the function of files in each folder without needing to read every file. + +**Important:** The `ai/**/index.md` files are auto-generated from frontmatter. Do not create or edit these files manually—they will be overwritten by the pre-commit hook. + +## Progressive Discovery + +Agents should only consume the root index until they need subfolder contents. For example: +- If the project is Python, there is no need to read JavaScript-specific folders +- If working on backend logic, frontend UI folders can be skipped +- Only drill into subfolders when the task requires that specific domain knowledge + +This approach minimizes context consumption and keeps agent responses focused. + +## Vision Document Requirement + +**Before creating or running any task, agents must first read the vision document (`vision.md`) in the project root.** + +The vision document serves as the source of truth for: +- Project goals and objectives +- Key constraints and non-negotiables +- Architectural decisions and rationale +- User experience principles +- Success criteria + +## Conflict Resolution + +If any conflicts are detected between a requested task and the vision document, agents must: + +1. Stop and identify the specific conflict +2. Explain how the task conflicts with the stated vision +3. Ask the user to clarify how to resolve the conflict before proceeding + +Never proceed with a task that contradicts the vision without explicit user approval. + +## Custom Skills and Configuration + +Project-specific customization lives in `aidd-custom/`. Before starting work, +read `aidd-custom/index.md` to discover available project-specific skills, +and read `aidd-custom/config.yml` to load configuration into context. + +## Task Index + +fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (`npm run test:unit`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +```sh +npm run test:e2e +``` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. diff --git a/README.md b/README.md index ab73080d..c86fea26 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ Includes: - [Installation & Usage](#installation--usage) - [Command Options](#command-options) - [Examples](#examples) + - [`npx aidd create` — Scaffold a new project](#npx-aidd-create--scaffold-a-new-project) +- [⚙️ Customizing aidd Framework for your Project](#-customizing-aidd-framework-for-your-project) + - [`aidd-custom/config.yml`](#aidd-customconfigyml) + - [`aidd-custom/AGENTS.md`](#aidd-customagentsmd) - [📁 AI System Structure](#-ai-system-structure) - [Key Components](#key-components) - [🎯 AI Integration](#-ai-integration) @@ -418,6 +422,77 @@ npx aidd frontend-app npx aidd backend-api ``` +### `npx aidd create` — Scaffold a new project + +Use the `create` subcommand to scaffold a new project from a manifest-driven scaffold extension: + +```bash +npx aidd create my-project # built-in next-shadcn scaffold +npx aidd create scaffold-example my-project # named scaffold bundled in aidd +npx aidd create https://github.com/org/repo my-project # remote GitHub repo (latest release) +npx aidd create file:///path/to/scaffold my-project # local scaffold directory +``` + +For full documentation on authoring your own scaffolds, see [docs/scaffold-authoring.md](./docs/scaffold-authoring.md). + +To avoid passing the URI on every invocation, save it to your user config with `set create-uri`. It is read automatically whenever you run `npx aidd create`: + +```bash +npx aidd set create-uri https://github.com/org/scaffold +npx aidd set create-uri file:///path/to/my-scaffold +``` + +The value is saved to `~/.aidd/config.yml`. You can also hand-edit it directly: + +```yaml +# ~/.aidd/config.yml +create-uri: https://github.com/your-org/my-scaffold +``` + +The lookup priority is: + +``` +CLI arg > AIDD_CUSTOM_CREATE_URI env var > ~/.aidd/config.yml > default (next-shadcn) +``` + +## ⚙️ Customizing aidd Framework for your Project + +After installing the aidd system, create an `aidd-custom/` directory at your project root to extend or override the defaults without touching the built-in `ai/` files. Changes in `aidd-custom/` supersede the project root in case of any conflict. + +``` +your-project/ +├── ai/ # built-in aidd framework files (don't edit) +├── aidd-custom/ # your project-specific customizations +│ ├── config.yml # project-level aidd settings +│ └── AGENTS.md # project-specific agent instructions +└── ... +``` + +### `aidd-custom/config.yml` + +Store project-level aidd settings as YAML (token-friendly for AI context injection): + +```yaml +# aidd-custom/config.yml +stack: next-shadcn +team: my-org +``` + +### `aidd-custom/AGENTS.md` + +Project-specific instructions for AI agents. Write rules, constraints, and context that apply only to your project. AI agents are instructed to read `aidd-custom/AGENTS.md` after `AGENTS.md` — directives here override the defaults. + +```markdown +# Project Agent Instructions + +## Stack +We use Next.js 15 App Router with Tailwind CSS and shadcn/ui. + +## Conventions +- All server actions live in `lib/actions/` +- Use `createRoute` from `aidd/server` for API routes +``` + ## 📁 AI System Structure After running the CLI, you'll have a complete `ai/` folder: diff --git a/activity-log.md b/activity-log.md index 53b949d1..fba06d7d 100644 --- a/activity-log.md +++ b/activity-log.md @@ -1,3 +1,13 @@ +## February 18, 2026 + +- 🚀 - `npx aidd create` - New `create [type|URI] ` subcommand with manifest-driven scaffolding (run/prompt steps, --agent flag, remote code warning) +- 🚀 - `npx aidd scaffold-cleanup` - New cleanup subcommand removes `.aidd/` working directory +- 🔧 - Extension resolver - Supports named scaffolds, `file://`, and `https://`; remote scaffolds require HTTPS (`http://` URIs are rejected) with confirmation prompt for remote code +- 🔧 - SCAFFOLD-MANIFEST.yml runner - Executes run/prompt steps sequentially; prompt steps use array spawn to prevent shell injection +- 📦 - `scaffold-example` scaffold - Minimal E2E fixture: `npm init`, vitest test script, installs riteway/vitest/playwright/error-causes/@paralleldrive/cuid2 +- 📦 - `next-shadcn` scaffold stub - Default named scaffold with placeholder manifest for future Next.js + shadcn/ui implementation +- 🧪 - 44 new tests - Unit tests for resolver, runner, cleanup; E2E tests covering full scaffold lifecycle + ## October 20, 2025 - 📱 - Help command clarity - AI workflow commands context diff --git a/ai/commands/index.md b/ai/commands/index.md index 7a2431d5..23e07f21 100644 --- a/ai/commands/index.md +++ b/ai/commands/index.md @@ -8,65 +8,43 @@ This index provides an overview of the contents in this directory. **File:** `aidd-fix.md` -*No description available* - ### Commit **File:** `commit.md` -*No description available* - ### discover **File:** `discover.md` -*No description available* - ### execute **File:** `execute.md` -*No description available* - ### help **File:** `help.md` -*No description available* - ### log **File:** `log.md` -*No description available* - ### plan **File:** `plan.md` -*No description available* - ### 🔬 Code Review **File:** `review.md` -*No description available* - ### run-test **File:** `run-test.md` -*No description available* - ### task **File:** `task.md` -*No description available* - ### user-test **File:** `user-test.md` -*No description available* - diff --git a/ai/index.md b/ai/index.md index 6654e0d0..38411fb7 100644 --- a/ai/index.md +++ b/ai/index.md @@ -12,6 +12,10 @@ See [`commands/index.md`](./commands/index.md) for contents. See [`rules/index.md`](./rules/index.md) for contents. +### 📁 scaffolds/ + +See [`scaffolds/index.md`](./scaffolds/index.md) for contents. + ### 📁 skills/ See [`skills/index.md`](./skills/index.md) for contents. diff --git a/ai/rules/index.md b/ai/rules/index.md index 6001f82e..dd13d01e 100644 --- a/ai/rules/index.md +++ b/ai/rules/index.md @@ -60,8 +60,6 @@ When writing functional requirements for a user story, use this guide for functi **File:** `review-example.md` -*No description available* - ### 🔬 Code Review **File:** `review.mdc` @@ -84,8 +82,6 @@ when the user asks you to complete a task, use this guide for systematic task/ep **File:** `tdd.mdc` -*No description available* - ### UI/UX Engineer **File:** `ui.mdc` diff --git a/ai/rules/javascript/index.md b/ai/rules/javascript/index.md index e4cc0a42..1972fe9b 100644 --- a/ai/rules/javascript/index.md +++ b/ai/rules/javascript/index.md @@ -8,8 +8,6 @@ This index provides an overview of the contents in this directory. **File:** `error-causes.mdc` -*No description available* - ### JavaScript IO Guide **File:** `javascript-io-network-effects.mdc` @@ -20,5 +18,3 @@ When you need to make network requests or invoke side-effects, use this guide fo **File:** `javascript.mdc` -*No description available* - diff --git a/ai/rules/please.mdc b/ai/rules/please.mdc index f0dd893e..81cdde8d 100644 --- a/ai/rules/please.mdc +++ b/ai/rules/please.mdc @@ -41,9 +41,9 @@ Commands { ✅ /task - use the task creator to plan and execute a task epic ⚙️ /execute - use the task creator to execute a task epic 🔬 /review - conduct a thorough code review focusing on code quality, best practices, and adherence to project standards + 🐛 /aidd-fix - fix a bug or implement review feedback following the full AIDD fix process 🧪 /user-test - use user-testing.mdc to generate human and AI agent test scripts from user journeys 🤖 /run-test - execute AI agent test script in real browser with screenshots - 🐛 /aidd-fix - fix a bug or implement review feedback following the full AIDD fix process } Constraints { diff --git a/ai/scaffolds/index.md b/ai/scaffolds/index.md new file mode 100644 index 00000000..5b24c5ff --- /dev/null +++ b/ai/scaffolds/index.md @@ -0,0 +1,14 @@ +# scaffolds + +This index provides an overview of the contents in this directory. + +## Subdirectories + +### 📁 next-shadcn/ + +See [`next-shadcn/index.md`](./next-shadcn/index.md) for contents. + +### 📁 scaffold-example/ + +See [`scaffold-example/index.md`](./scaffold-example/index.md) for contents. + diff --git a/ai/scaffolds/next-shadcn/README.md b/ai/scaffolds/next-shadcn/README.md new file mode 100644 index 00000000..77c46569 --- /dev/null +++ b/ai/scaffolds/next-shadcn/README.md @@ -0,0 +1,28 @@ +# next-shadcn + +The default AIDD scaffold for bootstrapping a Next.js project with shadcn/ui. + +## What this scaffold sets up + +- **Next.js** — React framework with App Router +- **shadcn/ui** — accessible, composable component library +- **Tailwind CSS** — utility-first CSS framework +- **TypeScript** — static type checking +- **Vitest** — fast unit test runner +- **Playwright** — end-to-end browser testing + +## Usage + +```sh +npx aidd create next-shadcn my-app +``` + +Or simply (next-shadcn is the default): + +```sh +npx aidd create my-app +``` + +## Status + +This scaffold is a stub. Full implementation is planned for a future release. diff --git a/ai/scaffolds/next-shadcn/SCAFFOLD-MANIFEST.yml b/ai/scaffolds/next-shadcn/SCAFFOLD-MANIFEST.yml new file mode 100644 index 00000000..84a55976 --- /dev/null +++ b/ai/scaffolds/next-shadcn/SCAFFOLD-MANIFEST.yml @@ -0,0 +1,2 @@ +steps: + - prompt: "This scaffold is a placeholder. Full next-shadcn scaffolding is coming soon. For now, please manually set up your Next.js project using: npx create-next-app@latest" diff --git a/ai/scaffolds/next-shadcn/index.md b/ai/scaffolds/next-shadcn/index.md new file mode 100644 index 00000000..8adc9159 --- /dev/null +++ b/ai/scaffolds/next-shadcn/index.md @@ -0,0 +1,10 @@ +# next-shadcn + +This index provides an overview of the contents in this directory. + +## Files + +### next-shadcn + +**File:** `README.md` + diff --git a/ai/scaffolds/scaffold-example/README.md b/ai/scaffolds/scaffold-example/README.md new file mode 100644 index 00000000..333e0ee9 --- /dev/null +++ b/ai/scaffolds/scaffold-example/README.md @@ -0,0 +1,35 @@ +# scaffold-example + +A minimal scaffold used as the end-to-end test fixture for `npx aidd create`. + +## What this scaffold does + +1. Initializes a new npm project (`npm init -y`) +2. Configures a `test` script using Vitest +3. Configures a `release` script using `release-it` for cutting tagged GitHub releases +4. Installs the AIDD-standard dependencies at `@latest`: + - `riteway` — functional assertion library + - `vitest` — fast test runner + - `@playwright/test` — browser automation testing + - `error-causes` — structured error chaining + - `@paralleldrive/cuid2` — collision-resistant unique IDs + - `release-it` — automated GitHub release publishing + +## Usage + +```sh +npx aidd create scaffold-example my-project +``` + +After scaffolding, run your tests: + +```sh +cd my-project +npm test +``` + +To cut a tagged GitHub release: + +```sh +npm run release +``` diff --git a/ai/scaffolds/scaffold-example/SCAFFOLD-MANIFEST.yml b/ai/scaffolds/scaffold-example/SCAFFOLD-MANIFEST.yml new file mode 100644 index 00000000..0af97a8d --- /dev/null +++ b/ai/scaffolds/scaffold-example/SCAFFOLD-MANIFEST.yml @@ -0,0 +1,5 @@ +steps: + - run: npm init -y + - run: npm pkg set scripts.test="vitest run" + - run: npm pkg set scripts.release="release-it" + - run: npm install --save-dev riteway@latest vitest@latest @playwright/test@latest error-causes@latest @paralleldrive/cuid2@latest release-it@latest diff --git a/ai/scaffolds/scaffold-example/bin/index.md b/ai/scaffolds/scaffold-example/bin/index.md new file mode 100644 index 00000000..5df82d70 --- /dev/null +++ b/ai/scaffolds/scaffold-example/bin/index.md @@ -0,0 +1,5 @@ +# bin + +This index provides an overview of the contents in this directory. + +*This directory is empty.* diff --git a/ai/scaffolds/scaffold-example/index.md b/ai/scaffolds/scaffold-example/index.md new file mode 100644 index 00000000..6fc61d03 --- /dev/null +++ b/ai/scaffolds/scaffold-example/index.md @@ -0,0 +1,16 @@ +# scaffold-example + +This index provides an overview of the contents in this directory. + +## Subdirectories + +### 📁 bin/ + +See [`bin/index.md`](./bin/index.md) for contents. + +## Files + +### scaffold-example + +**File:** `README.md` + diff --git a/ai/scaffolds/scaffold-example/package.json b/ai/scaffolds/scaffold-example/package.json new file mode 100644 index 00000000..a01f545a --- /dev/null +++ b/ai/scaffolds/scaffold-example/package.json @@ -0,0 +1,18 @@ +{ + "description": "Minimal AIDD scaffold — npm project with Vitest and Riteway", + "devDependencies": { + "release-it": "latest" + }, + "files": [ + "SCAFFOLD-MANIFEST.yml", + "README.md", + "bin/**/*" + ], + "license": "MIT", + "name": "aidd-scaffold-example", + "scripts": { + "release": "release-it" + }, + "type": "module", + "version": "1.0.0" +} diff --git a/ai/skills/aidd-ecs/index.md b/ai/skills/aidd-ecs/index.md index b47a2955..27d7ebbd 100644 --- a/ai/skills/aidd-ecs/index.md +++ b/ai/skills/aidd-ecs/index.md @@ -14,5 +14,3 @@ Enforces @adobe/data/ecs best practices. Use this whenever @adobe/data/ecs is im **File:** `data-modeling.md` -*No description available* - diff --git a/ai/skills/aidd-layout/references/index.md b/ai/skills/aidd-layout/references/index.md index f2ee671e..27bac6cb 100644 --- a/ai/skills/aidd-layout/references/index.md +++ b/ai/skills/aidd-layout/references/index.md @@ -8,5 +8,3 @@ This index provides an overview of the contents in this directory. **File:** `design-tokens.md` -*No description available* - diff --git a/ai/skills/aidd-namespace/index.md b/ai/skills/aidd-namespace/index.md index 325915ab..0f60e144 100644 --- a/ai/skills/aidd-namespace/index.md +++ b/ai/skills/aidd-namespace/index.md @@ -8,8 +8,6 @@ This index provides an overview of the contents in this directory. **File:** `README.md` -*No description available* - ### Type namespace pattern **File:** `SKILL.md` diff --git a/ai/skills/fix/SKILL.md b/ai/skills/fix/SKILL.md new file mode 100644 index 00000000..7da521b8 --- /dev/null +++ b/ai/skills/fix/SKILL.md @@ -0,0 +1,94 @@ +--- +name: aidd-fix +description: Fix a bug or apply review feedback. +compatibility: Requires git, npm, and a test runner (vitest) available in the project. +--- + +# 🐛 aidd-fix + +Fix a bug or implement review feedback following the structured AIDD fix process. + +Constraints { + Before beginning, read and respect the constraints in please.mdc. + Do ONE step at a time. Do not skip steps or reorder them. + Never commit without running e2e tests first. + Never implement before writing a failing test. + Never write a test after implementing — that is not TDD. +} + +## Step 1 — Gain context and validate + +Given a bug report or review feedback: + +1. Read the relevant source file(s) and colocated test file(s) +2. Read the task epic in `$projectRoot/tasks/` that covers this area +3. Reason through or reproduce the issue to confirm it exists +4. If **no change is needed**: summarize findings and stop — do not modify any files + +## Step 2 — Document the requirement in the epic + +If a fix is required: + +1. Locate the existing task epic that covers this area of the codebase +2. If no matching epic exists, create one at `$projectRoot/tasks/-epic.md` using `task-creator.mdc` +3. Add a requirement bullet in **"Given X, should Y"** format that precisely describes the correct behavior +4. The epic update is its own discrete step — commit it separately or include it in the fix commit with a clear message + +epicConstraints { + Requirements must follow "Given X, should Y" format exactly. + No implementation detail in the requirement — describe observable behavior only. +} + +## Step 3 — TDD: write a failing test first + +Using `tdd.mdc`: + +1. Write a test that captures the new requirement +2. Run the unit test runner and confirm the test **fails** + + ```sh + npm run test:unit + ``` + +3. If the test passes without any implementation change: the bug may already be fixed or the test is wrong — stop and reassess before proceeding +4. Do not write implementation code until the test is confirmed failing + +## Step 4 — Implement the fix + +1. Write the minimum code needed to make the failing test pass +2. Run the unit test runner — **fail → fix bug → repeat; pass → continue** + + ```sh + npm run test:unit + ``` + +3. Implement ONLY what makes the test pass — do not over-engineer or clean up unrelated code + +## Step 5 — Review and run e2e tests + +Using `review.mdc`: + +1. Run the full e2e suite and confirm all tests pass + + ```sh + npm run test:e2e + ``` + +2. Self-review all changes using `/review` +3. Resolve any issues found before moving to the next step + +## Step 6 — Commit and push + +Using `commit.md`: + +1. Stage only the files changed by this fix +2. Write a conventional commit message: `fix(): ` +3. Push to the PR branch + + ```sh + git push -u origin + ``` + +Commands { + 🐛 /aidd-fix - fix a bug or review feedback following the full AIDD fix process +} diff --git a/ai/skills/fix/index.md b/ai/skills/fix/index.md new file mode 100644 index 00000000..58011b14 --- /dev/null +++ b/ai/skills/fix/index.md @@ -0,0 +1,12 @@ +# fix + +This index provides an overview of the contents in this directory. + +## Files + +### 🐛 aidd-fix + +**File:** `SKILL.md` + +Fix a bug or apply review feedback. + diff --git a/ai/skills/index.md b/ai/skills/index.md index eece29e8..1ac48681 100644 --- a/ai/skills/index.md +++ b/ai/skills/index.md @@ -40,3 +40,7 @@ See [`aidd-service/index.md`](./aidd-service/index.md) for contents. See [`aidd-structure/index.md`](./aidd-structure/index.md) for contents. +### 📁 fix/ + +See [`fix/index.md`](./fix/index.md) for contents. + diff --git a/bin/aidd.js b/bin/aidd.js index d9a00acd..e70c0786 100755 --- a/bin/aidd.js +++ b/bin/aidd.js @@ -7,8 +7,14 @@ import { fileURLToPath } from "url"; import chalk from "chalk"; import { Command } from "commander"; +import { syncRootAgentFiles } from "../lib/agents-md.js"; +import { writeConfig } from "../lib/aidd-config.js"; import { executeClone, handleCliErrors } from "../lib/cli-core.js"; import { generateAllIndexes } from "../lib/index-generator.js"; +import { scaffoldCleanup } from "../lib/scaffold-cleanup.js"; +import { resolveCreateArgs, runCreate } from "../lib/scaffold-create.js"; +import { handleScaffoldErrors } from "../lib/scaffold-errors.js"; +import { runVerifyScaffold } from "../lib/scaffold-verify-cmd.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,7 +25,7 @@ const packageJson = JSON.parse( const createCli = () => { const program = new Command(); - return program + program .name("aidd") .description("AI Driven Development - Install the AIDD Framework") .version(packageJson.version) @@ -35,6 +41,7 @@ const createCli = () => { "-c, --cursor", "create .cursor symlink for Cursor editor integration", ) + .option("--claude", "create .claude symlink for Claude Code integration") .option( "-i, --index", "generate index.md files from frontmatter in ai/ subfolders", @@ -85,7 +92,15 @@ To install for Cursor: npx aidd --cursor -Install without Cursor integration: +To install for Claude Code: + + npx aidd --claude + +To install for both: + + npx aidd --cursor --claude + +Install without editor integration: npx aidd my-project `, @@ -99,7 +114,10 @@ https://paralleldrive.com `, ) .action( - async (targetDirectory, { force, dryRun, verbose, cursor, index }) => { + async ( + targetDirectory, + { force, dryRun, verbose, cursor, claude, index }, + ) => { // Handle --index option separately if (index) { const targetPath = path.resolve(process.cwd(), targetDirectory); @@ -125,15 +143,24 @@ https://paralleldrive.com console.log(chalk.gray(` - ${idx.path}`)); }); } - process.exit(0); } else { console.error(chalk.red(`❌ ${result.error.message}`)); process.exit(1); } + + const syncResults = await syncRootAgentFiles(targetPath); + syncResults + .filter(({ action }) => action !== "unchanged") + .forEach(({ file, action }) => { + console.log(chalk.green(`✅ ${action} ${file}`)); + }); + + process.exit(0); return; } const result = await executeClone({ + claude, cursor, dryRun, force, @@ -142,14 +169,6 @@ https://paralleldrive.com }); if (!result.success) { - // Create a proper error with cause for handleErrors - const error = new Error(result.error.message, { - cause: result.error.cause || { - code: result.error.code || "UNEXPECTED_ERROR", - }, - }); - - // Use handleErrors instead of manual switching try { handleCliErrors({ CloneError: ({ message, cause }) => { @@ -176,11 +195,11 @@ https://paralleldrive.com "💡 Try using --force to overwrite existing files", ); }, - })(error); + })(result.error); } catch { - // Fallback for unexpected errors - console.error(`❌ Unexpected Error: ${result.error.message}`); - if (verbose && result.error.cause) { + // Fallback for unexpected errors (e.g. an error without a cause code) + console.error(`❌ Unexpected Error: ${result.error?.message}`); + if (verbose && result.error?.cause) { console.error("🔍 Caused by:", result.error.cause); } } @@ -189,7 +208,226 @@ https://paralleldrive.com process.exit(result.success ? 0 : 1); }, ); + + // create subcommand + // + // Argument parsing rationale: + // create scaffold-example my-project → typeOrFolder="scaffold-example", folder="my-project" + // create my-project → typeOrFolder="my-project", folder=undefined + // create → typeOrFolder=undefined → manual error + // + // Commander cannot parse `[type] ` correctly when type is omitted and + // only the folder is given — it would assign the single value to `type` and + // report folder as missing. Using two optional args and validating manually + // handles all three cases cleanly. + program + .command("create [typeOrFolder] [folder]") + .description( + "Scaffold a new app using a manifest-driven extension (default: next-shadcn)", + ) + // Override the auto-generated usage so help shows the intended calling + // convention rather than the internal [typeOrFolder] [folder] names. + .usage("[options] [type] ") + .addHelpText( + "after", + ` +Arguments: + (required) directory to create the new project in + [type] scaffold name, file:// URI, or https:// URL + defaults to AIDD_CUSTOM_CREATE_URI env var, then "next-shadcn" + +Examples: + $ npx aidd create my-project + $ npx aidd create scaffold-example my-project + $ npx aidd create https://github.com/org/scaffold my-project + $ npx aidd create file:///path/to/scaffold my-project +`, + ) + .option("--agent ", "agent CLI to use for prompt steps", "claude") + .action(async (typeOrFolder, folder, { agent }) => { + const args = resolveCreateArgs(typeOrFolder, folder); + if (!args) { + console.error("error: missing required argument 'folder'"); + process.exit(1); + return; + } + + const { type, folderPath } = args; + console.log(chalk.blue(`\nScaffolding new project in ${folderPath}...`)); + + try { + const result = await runCreate({ + agent, + folder: folderPath, + packageRoot: __dirname, + type, + }); + + console.log(chalk.green("\n✅ Scaffold complete!")); + if (result.cleanupTip) { + console.log( + chalk.yellow( + `\n💡 Tip: Run \`${result.cleanupTip}\` to remove the downloaded extension files.`, + ), + ); + } + process.exit(0); + } catch (err) { + try { + handleScaffoldErrors({ + ScaffoldCancelledError: ({ message }) => { + console.log(chalk.yellow(`\nℹ️ ${message}`)); + process.exit(0); // graceful cancellation — not an error + }, + ScaffoldNetworkError: ({ message, cause }) => { + console.error(chalk.red(`\n❌ Network Error: ${message}`)); + console.error( + chalk.yellow("💡 Check your internet connection and try again"), + ); + if (cause?.cause) { + console.error( + chalk.gray(` Caused by: ${cause.cause.message}`), + ); + } + }, + ScaffoldStepError: ({ message }) => { + console.error(chalk.red(`\n❌ Step failed: ${message}`)); + console.error( + chalk.yellow( + "💡 Check the scaffold manifest steps and try again", + ), + ); + }, + ScaffoldValidationError: ({ message }) => { + console.error(chalk.red(`\n❌ Invalid scaffold: ${message}`)); + console.error( + chalk.yellow( + "💡 Run `npx aidd verify-scaffold` to diagnose the manifest", + ), + ); + }, + })(err); + } catch { + console.error(chalk.red(`\n❌ Scaffold failed: ${err.message}`)); + } + process.exit(1); + } + }); + + // verify-scaffold subcommand + program + .command("verify-scaffold [type]") + .description( + "Validate a scaffold manifest before running it (named, file://, or HTTP/HTTPS)", + ) + .action(async (type) => { + try { + const result = await runVerifyScaffold({ + packageRoot: __dirname, + type, + }); + + if (result.valid) { + console.log(chalk.green("✅ Scaffold is valid")); + process.exit(0); + } else { + console.error(chalk.red("❌ Scaffold validation failed:")); + for (const err of result.errors) { + console.error(chalk.red(` • ${err}`)); + } + process.exit(1); + } + } catch (err) { + try { + handleScaffoldErrors({ + ScaffoldCancelledError: ({ message }) => { + console.log(chalk.yellow(`\nℹ️ ${message}`)); + process.exit(0); // graceful cancellation — not an error + }, + ScaffoldNetworkError: ({ message }) => { + console.error(chalk.red(`\n❌ Network Error: ${message}`)); + }, + ScaffoldStepError: ({ message }) => { + console.error(chalk.red(`\n❌ Step failed: ${message}`)); + }, + ScaffoldValidationError: ({ message }) => { + console.error(chalk.red(`\n❌ Invalid scaffold: ${message}`)); + }, + })(err); + } catch { + console.error(chalk.red(`\n❌ Verification failed: ${err.message}`)); + } + process.exit(1); + } + }); + + // scaffold-cleanup subcommand + program + .command("scaffold-cleanup [folder]") + .description( + "Remove the .aidd/ working directory created during scaffolding", + ) + .action(async (folder) => { + const folderPath = folder + ? path.resolve(process.cwd(), folder) + : process.cwd(); + + try { + const result = await scaffoldCleanup({ folder: folderPath }); + + if (result.action === "removed") { + console.log(chalk.green(`✅ ${result.message}`)); + } else { + console.log(chalk.yellow(`ℹ️ ${result.message}`)); + } + process.exit(0); + } catch (err) { + console.error(chalk.red(`❌ Cleanup failed: ${err.message}`)); + process.exit(1); + } + }); + + // set subcommand — persist a value to ~/.aidd/config.yml + program + .command("set ") + .description("Save a user-level configuration value to ~/.aidd/config.yml") + .addHelpText( + "after", + ` +Valid keys: + create-uri Default scaffold URI used by \`npx aidd create\`. + Priority: CLI arg > AIDD_CUSTOM_CREATE_URI env var > ~/.aidd/config.yml + +Examples: + $ npx aidd set create-uri https://github.com/org/scaffold + $ npx aidd set create-uri file:///path/to/my-scaffold +`, + ) + .action(async (key, value) => { + const VALID_KEYS = ["create-uri"]; + if (!VALID_KEYS.includes(key)) { + console.error( + chalk.red( + `❌ Unknown setting: "${key}". Valid settings: ${VALID_KEYS.join(", ")}`, + ), + ); + process.exit(1); + return; + } + + try { + await writeConfig({ updates: { [key]: value } }); + console.log( + chalk.green(`✅ ${key} saved to ~/.aidd/config.yml: ${value}`), + ); + process.exit(0); + } catch (err) { + console.error(chalk.red(`❌ Failed to write config: ${err.message}`)); + process.exit(1); + } + }); + + return program; }; -// Execute CLI createCli().parse(); diff --git a/bin/cli-help-e2e.test.js b/bin/cli-help-e2e.test.js index 3029c8e7..843334f5 100644 --- a/bin/cli-help-e2e.test.js +++ b/bin/cli-help-e2e.test.js @@ -147,11 +147,12 @@ describe("CLI help command", () => { assert({ given: "CLI help command is run", - should: "show Quick Start section with README format", + should: "show Quick Start section with Cursor and Claude Code options", actual: stdout.includes("Quick Start") && stdout.includes("To install for Cursor:") && - stdout.includes("Install without Cursor integration:"), + stdout.includes("To install for Claude Code:") && + stdout.includes("Install without editor integration:"), expected: true, }); }); diff --git a/bin/create-e2e.test.js b/bin/create-e2e.test.js new file mode 100644 index 00000000..61421588 --- /dev/null +++ b/bin/create-e2e.test.js @@ -0,0 +1,317 @@ +import { exec } from "child_process"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import { promisify } from "util"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + test, +} from "vitest"; + +const execAsync = promisify(exec); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, "./aidd.js"); + +// Shared state for the scaffold-example create tests (runs npm install once) +const scaffoldExampleCtx = { + tempDir: null, + projectDir: null, + stdout: null, +}; + +describe("aidd create scaffold-example", () => { + beforeAll(async () => { + scaffoldExampleCtx.tempDir = path.join( + os.tmpdir(), + `aidd-e2e-create-${Date.now()}`, + ); + await fs.ensureDir(scaffoldExampleCtx.tempDir); + scaffoldExampleCtx.projectDir = path.join( + scaffoldExampleCtx.tempDir, + "test-project", + ); + + const { stdout } = await execAsync( + `node ${cliPath} create scaffold-example test-project`, + { cwd: scaffoldExampleCtx.tempDir, timeout: 180_000 }, + ); + scaffoldExampleCtx.stdout = stdout; + }, 180_000); + + afterAll(async () => { + await fs.remove(scaffoldExampleCtx.tempDir); + }); + + test("creates the target directory", async () => { + const exists = await fs.pathExists(scaffoldExampleCtx.projectDir); + + assert({ + given: "aidd create scaffold-example test-project", + should: "create the test-project directory", + actual: exists, + expected: true, + }); + }); + + test("initializes a package.json in the project", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const exists = await fs.pathExists(pkgPath); + + assert({ + given: "scaffold-example scaffold runs", + should: "create a package.json in the project directory", + actual: exists, + expected: true, + }); + }); + + test("installs riteway as a dev dependency", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + const devDeps = pkg.devDependencies || {}; + + assert({ + given: "scaffold-example scaffold runs", + should: "install riteway as a dev dependency", + actual: "riteway" in devDeps, + expected: true, + }); + }); + + test("installs vitest as a dev dependency", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + const devDeps = pkg.devDependencies || {}; + + assert({ + given: "scaffold-example scaffold runs", + should: "install vitest as a dev dependency", + actual: "vitest" in devDeps, + expected: true, + }); + }); + + test("installs @playwright/test as a dev dependency", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + const devDeps = pkg.devDependencies || {}; + + assert({ + given: "scaffold-example scaffold runs", + should: "install @playwright/test as a dev dependency", + actual: "@playwright/test" in devDeps, + expected: true, + }); + }); + + test("installs error-causes as a dev dependency", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + const devDeps = pkg.devDependencies || {}; + + assert({ + given: "scaffold-example scaffold runs", + should: "install error-causes as a dev dependency", + actual: "error-causes" in devDeps, + expected: true, + }); + }); + + test("installs @paralleldrive/cuid2 as a dev dependency", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + const devDeps = pkg.devDependencies || {}; + + assert({ + given: "scaffold-example scaffold runs", + should: "install @paralleldrive/cuid2 as a dev dependency", + actual: "@paralleldrive/cuid2" in devDeps, + expected: true, + }); + }); + + test("installs release-it as a dev dependency", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + const devDeps = pkg.devDependencies || {}; + + assert({ + given: "scaffold-example scaffold runs", + should: "install release-it as a dev dependency", + actual: "release-it" in devDeps, + expected: true, + }); + }); + + test("configures scripts.release as release-it", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + + assert({ + given: "scaffold-example scaffold runs", + should: "configure scripts.release as release-it", + actual: pkg.scripts?.release, + expected: "release-it", + }); + }); + + test("sets up a test script in package.json", async () => { + const pkgPath = path.join(scaffoldExampleCtx.projectDir, "package.json"); + const pkg = await fs.readJson(pkgPath); + + assert({ + given: "scaffold-example scaffold runs", + should: "configure a test script", + actual: typeof pkg.scripts?.test === "string", + expected: true, + }); + }); + + test("does not suggest scaffold-cleanup for named (local) scaffolds", () => { + assert({ + given: "scaffold-example (named scaffold) completes successfully", + should: "not suggest scaffold-cleanup because no tarball was downloaded", + actual: scaffoldExampleCtx.stdout.includes("scaffold-cleanup"), + expected: false, + }); + }); +}); + +describe("aidd create with AIDD_CUSTOM_CREATE_URI env var", () => { + const envCtx = { tempDir: null, projectDir: null }; + + beforeAll(async () => { + envCtx.tempDir = path.join(os.tmpdir(), `aidd-e2e-env-${Date.now()}`); + await fs.ensureDir(envCtx.tempDir); + envCtx.projectDir = path.join(envCtx.tempDir, "env-project"); + + const scaffoldExamplePath = path.resolve( + __dirname, + "../ai/scaffolds/scaffold-example", + ); + const uri = `file://${scaffoldExamplePath}`; + + await execAsync(`node ${cliPath} create env-project`, { + cwd: envCtx.tempDir, + env: { ...process.env, AIDD_CUSTOM_CREATE_URI: uri }, + timeout: 180_000, + }); + }, 180_000); + + afterAll(async () => { + await fs.remove(envCtx.tempDir); + }); + + test("uses file:// URI from AIDD_CUSTOM_CREATE_URI over default", async () => { + const pkgPath = path.join(envCtx.projectDir, "package.json"); + const exists = await fs.pathExists(pkgPath); + + assert({ + given: "AIDD_CUSTOM_CREATE_URI set to a file:// URI", + should: "use that URI as the extension source and scaffold the project", + actual: exists, + expected: true, + }); + }); +}); + +describe("aidd scaffold-cleanup", () => { + let tempDir; + let projectDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-e2e-cleanup-${Date.now()}`); + await fs.ensureDir(tempDir); + projectDir = path.join(tempDir, "test-project"); + await fs.ensureDir(path.join(projectDir, ".aidd/scaffold")); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("removes .aidd/ directory from the given folder", async () => { + await execAsync(`node ${cliPath} scaffold-cleanup test-project`, { + cwd: tempDir, + }); + + const aiddExists = await fs.pathExists(path.join(projectDir, ".aidd")); + + assert({ + given: "aidd scaffold-cleanup test-project", + should: "remove test-project/.aidd/ directory", + actual: aiddExists, + expected: false, + }); + }); + + test("reports nothing to clean up when .aidd/ does not exist", async () => { + const cleanDir = path.join(tempDir, "clean-project"); + await fs.ensureDir(cleanDir); + + const { stdout } = await execAsync( + `node ${cliPath} scaffold-cleanup clean-project`, + { cwd: tempDir }, + ); + + assert({ + given: "scaffold-cleanup on directory without .aidd/", + should: "report nothing to clean up", + actual: stdout.toLowerCase().includes("nothing"), + expected: true, + }); + }); +}); + +describe("aidd create --agent flag", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-e2e-agent-${Date.now()}`); + await fs.ensureDir(tempDir); + + // Create a minimal scaffold fixture with a prompt step + // Use 'echo' as a fake agent so prompt step completes without real AI + const scaffoldDir = path.join(tempDir, "agent-test-scaffold"); + await fs.ensureDir(scaffoldDir); + await fs.writeFile( + path.join(scaffoldDir, "README.md"), + "# Agent Test Scaffold", + ); + await fs.writeFile( + path.join(scaffoldDir, "SCAFFOLD-MANIFEST.yml"), + "steps:\n - prompt: hello world\n", + ); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("passes the agent name to prompt step invocations", async () => { + const scaffoldUri = `file://${path.join(tempDir, "agent-test-scaffold")}`; + + // Use 'echo' as the agent: it just prints the prompt and exits successfully + await execAsync( + `node ${cliPath} create --agent echo "${scaffoldUri}" agent-project`, + { cwd: tempDir, timeout: 30_000 }, + ); + + // The project directory should be created + const dirExists = await fs.pathExists(path.join(tempDir, "agent-project")); + + assert({ + given: "--agent echo flag with a scaffold containing a prompt step", + should: "run the prompt step using the specified agent (echo)", + actual: dirExists, + expected: true, + }); + }, 30_000); +}); diff --git a/docs/scaffold-authoring.md b/docs/scaffold-authoring.md new file mode 100644 index 00000000..60128e45 --- /dev/null +++ b/docs/scaffold-authoring.md @@ -0,0 +1,142 @@ +# Scaffold Authoring Guide + +A scaffold is a small repository that teaches `npx aidd create` how to bootstrap a new project. This guide covers the file layout, manifest format, how to validate your scaffold locally, and how to publish it as a GitHub release so consumers can reference it by URL. + +--- + +## File layout + +``` +my-scaffold/ +├── SCAFFOLD-MANIFEST.yml # required — list of steps to execute +├── README.md # optional — displayed to the user before steps run +└── package.json # required for publishing — see below +``` + +--- + +## SCAFFOLD-MANIFEST.yml + +The manifest is a YAML file with a single `steps` key containing an ordered list of step objects. Each step must have exactly one of: + +| Key | Type | Description | +|-----|------|-------------| +| `run` | string | Shell command executed in the project directory | +| `prompt` | string | Sent to the configured AI agent CLI (default: `claude`) | + +```yaml +steps: + - run: npm init -y + - run: npm pkg set scripts.test="vitest run" + - run: npm pkg set scripts.release="release-it" + - run: npm install --save-dev vitest@latest release-it@latest + - prompt: Set up a basic project structure with src/ and tests/ +``` + +### Validation rules + +`npx aidd create` validates the manifest before executing any steps. Your manifest will be rejected with a clear error if: + +- `steps` is not an array (e.g. a string or plain object) +- Any step is not a plain object (e.g. a bare string or `null`) +- Any step has no recognized keys (`run` or `prompt`) +- Any step has both `run` and `prompt` keys (ambiguous — use two separate steps instead) + +Run `npx aidd verify-scaffold ` at any time to check your manifest without executing it: + +```bash +# Verify a named built-in scaffold +npx aidd verify-scaffold scaffold-example + +# Verify a local scaffold by file URI +npx aidd verify-scaffold file:///path/to/my-scaffold +``` + +--- + +## The `package.json` `files` array vs GitHub release assets + +These two concepts are independent: + +### `files` in `package.json` → controls npm publishing + +When you run `npm publish`, npm reads the `files` array to decide which paths are included in the package tarball uploaded to the npm registry. Files not listed here are excluded from `npm install`. + +```json +{ + "files": [ + "SCAFFOLD-MANIFEST.yml", + "README.md" + ] +} +``` + +### GitHub release assets → controlled by git + release workflow + +A GitHub release contains: + +1. **Auto-generated source tarballs** (`Source code (zip)` / `Source code (tar.gz)`) — these are snapshots of everything in the git repository at the tagged commit. The `files` array in `package.json` has **no effect** on this. +2. **Manually uploaded release assets** — anything you explicitly upload via the GitHub UI or a release workflow step. + +For scaffold distribution, consumers download the source tarball from a GitHub release. This means every file you commit to the repository at the release tag will be available. The `files` array only matters if you also publish the scaffold to npm. + +--- + +## Adding a release command to your scaffold's `package.json` + +Include `release-it` as a dev dependency and wire up a `release` script: + +```json +{ + "name": "my-aidd-scaffold", + "version": "1.0.0", + "type": "module", + "scripts": { + "release": "release-it" + }, + "files": [ + "SCAFFOLD-MANIFEST.yml", + "README.md" + ], + "devDependencies": { + "release-it": "latest" + } +} +``` + +Running `npm run release` will: + +1. Bump the version +2. Create a git tag (`v1.0.0`) +3. Push the tag to GitHub +4. Create a GitHub release with auto-generated release notes + +Scaffold consumers reference your scaffold by its bare GitHub repo URL — `npx aidd create` automatically resolves it to the latest release tarball via the GitHub API: + +```bash +npx aidd create https://github.com/your-org/my-scaffold my-project +``` + +--- + +## Distributing via GitHub releases (recommended) vs git clone + +| | GitHub release tarball | git clone | +|---|---|---| +| **Versioned** | Yes — pinned to a tag | No — always HEAD | +| **Reproducible** | Yes | No | +| **Download size** | Small — source only | Large — includes git history | +| **No git required on consumer** | Yes (HTTP download) | No (requires git) | + +The AIDD resolver will download and extract the release tarball rather than cloning the repository, giving users a fast, versioned, reproducible scaffold install. + +--- + +## Testing your scaffold locally + +Use a `file://` URI to test your scaffold without publishing: + +```bash +npx aidd verify-scaffold file:///path/to/my-scaffold +npx aidd create file:///path/to/my-scaffold my-test-project +``` diff --git a/lib/agents-index-e2e.test.js b/lib/agents-index-e2e.test.js index 91a9c9f6..6d894732 100644 --- a/lib/agents-index-e2e.test.js +++ b/lib/agents-index-e2e.test.js @@ -67,6 +67,7 @@ Read index.md files (auto-generated from frontmatter). Only use root index until needed. Always read the vision document first. Report conflict resolution to the user. +Run npm run test:e2e before committing. Check aidd-custom/ for project-specific skills and configuration. `; await fs.writeFile(agentsPath, customContent); diff --git a/lib/agents-md.d.ts b/lib/agents-md.d.ts index e2aa0c85..89975288 100644 --- a/lib/agents-md.d.ts +++ b/lib/agents-md.d.ts @@ -95,3 +95,35 @@ export function appendDirectives( * // result.action: "created" | "appended" | "unchanged" */ export function ensureAgentsMd(targetBase: string): Promise; + +/** + * Ensure CLAUDE.md exists and references AGENTS.md. + * - Not present → create with agentsMdContent. + * - Present but missing AGENTS.md reference and incomplete directives → append pointer. + * - Present with all directives or AGENTS.md reference → leave unchanged. + * + * @param targetBase - Base directory for CLAUDE.md + * @returns Result indicating action taken + */ +export function ensureClaudeMd(targetBase: string): Promise; + +/** Result of a single file sync in syncRootAgentFiles */ +export interface SyncFileResult { + /** Filename that was processed (e.g. "AGENTS.md" or "CLAUDE.md") */ + file: string; + /** Action taken: "created", "updated", or "unchanged" */ + action: "created" | "updated" | "unchanged"; +} + +/** + * Overwrite AGENTS.md and CLAUDE.md with the current agentsMdContent template + * if either file is missing or its content differs from the template. + * Used by the pre-commit hook to keep committed copies in sync whenever + * agentsMdContent is updated. + * + * @param targetBase - Base directory containing AGENTS.md and CLAUDE.md + * @returns Array of results for each file processed + */ +export function syncRootAgentFiles( + targetBase: string, +): Promise; diff --git a/lib/agents-md.js b/lib/agents-md.js index a61e325f..be7da4e8 100644 --- a/lib/agents-md.js +++ b/lib/agents-md.js @@ -20,6 +20,7 @@ const requiredDirectives = [ "vision", // Vision document requirement "conflict", // Conflict resolution "generated", // Auto-generated files warning + "test:e2e", // E2E test instruction before committing ]; // The content for AGENTS.md @@ -72,6 +73,22 @@ Never proceed with a task that contradicts the vision without explicit user appr Project-specific customization lives in \`aidd-custom/\`. Before starting work, read \`aidd-custom/index.md\` to discover available project-specific skills, and read \`aidd-custom/config.yml\` to load configuration into context. + +## Task Index + +fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (\`npm run test:unit\`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +\`\`\`sh +npm run test:e2e +\`\`\` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. `; /** @@ -181,6 +198,16 @@ read \`aidd-custom/index.md\` to discover available project-specific skills, and read \`aidd-custom/config.yml\` to load configuration into context.`, keywords: ["aidd-custom"], }, + { + content: `### Testing + +The pre-commit hook runs unit tests only (\`npm run test:unit\`). Run the full E2E suite manually before committing: + +\`\`\`sh +npm run test:e2e +\`\`\``, + keywords: ["test:e2e"], + }, ]; /** @@ -248,15 +275,112 @@ const ensureAgentsMd = async (targetBase) => { }; }; +/** + * Ensure CLAUDE.md exists and references AGENTS.md. + * - Not present → create with full AGENTS.md content. + * - Present but missing AGENTS.md reference → append a short pointer. + * - Present and already references AGENTS.md → leave unchanged. + */ +const ensureClaudeMd = async (targetBase) => { + const claudePath = path.join(targetBase, "CLAUDE.md"); + const exists = await fs.pathExists(claudePath); + + if (!exists) { + try { + await fs.writeFile(claudePath, agentsMdContent, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to write CLAUDE.md: ${claudePath}`, + }); + } + return { + action: "created", + message: "Created CLAUDE.md with AI agent guidelines", + }; + } + + // File exists — check if it already references AGENTS.md + let existingContent; + try { + existingContent = await fs.readFile(claudePath, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to read CLAUDE.md: ${claudePath}`, + }); + } + // Treat the file as complete when it already has the full agent directives + // (e.g. created by a previous install) OR explicitly references AGENTS.md. + if ( + hasAllDirectives(existingContent) || + existingContent.includes("AGENTS.md") + ) { + return { + action: "unchanged", + message: "CLAUDE.md already contains agent guidelines", + }; + } + + // Append a short pointer so agents find the directives + const appendLine = + "\n\n> **AI agents**: see [AGENTS.md](./AGENTS.md) for project-specific agent guidelines.\n"; + try { + await fs.writeFile(claudePath, existingContent + appendLine, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to append to CLAUDE.md: ${claudePath}`, + }); + } + return { + action: "appended", + message: "Appended AGENTS.md reference to existing CLAUDE.md", + }; +}; + +/** + * Overwrite AGENTS.md and CLAUDE.md with the current agentsMdContent template + * if either file is missing or its content differs from the template. + * Used by the pre-commit hook to keep committed copies in sync whenever + * agentsMdContent is updated, exactly as ai\/**\/index.md files are regenerated. + * Returns an array of { file, action } objects where action is one of + * 'created' | 'updated' | 'unchanged'. + */ +const syncRootAgentFiles = async (targetBase) => { + const files = ["AGENTS.md", "CLAUDE.md"]; + return Promise.all( + files.map(async (filename) => { + const filePath = path.join(targetBase, filename); + const exists = await fs.pathExists(filePath); + if (!exists) { + await fs.writeFile(filePath, agentsMdContent, "utf-8"); + return { action: "created", file: filename }; + } + const existing = await fs.readFile(filePath, "utf-8"); + if (existing === agentsMdContent) { + return { action: "unchanged", file: filename }; + } + await fs.writeFile(filePath, agentsMdContent, "utf-8"); + return { action: "updated", file: filename }; + }), + ); +}; + export { - ensureAgentsMd, agentsFileExists, - readAgentsFile, - hasAllDirectives, - getMissingDirectives, - writeAgentsFile, - appendDirectives, agentsMdContent, - requiredDirectives, + appendDirectives, directiveAppendSections, + ensureAgentsMd, + ensureClaudeMd, + getMissingDirectives, + hasAllDirectives, + readAgentsFile, + requiredDirectives, + syncRootAgentFiles, + writeAgentsFile, }; diff --git a/lib/agents-md.test.js b/lib/agents-md.test.js index b33c8131..4d5dc57c 100644 --- a/lib/agents-md.test.js +++ b/lib/agents-md.test.js @@ -3,7 +3,7 @@ import os from "os"; import path from "path"; import fs from "fs-extra"; import { assert } from "riteway/vitest"; -import { afterEach, beforeEach, describe, test } from "vitest"; +import { afterEach, beforeEach, describe, test, vi } from "vitest"; import { agentsFileExists, @@ -11,11 +11,15 @@ import { appendDirectives, directiveAppendSections, ensureAgentsMd, + ensureClaudeMd, getMissingDirectives, hasAllDirectives, requiredDirectives, + syncRootAgentFiles, } from "./agents-md.js"; +/** @typedef {import('./agents-md.js').SyncFileResult} SyncFileResult */ + describe("agents-md", () => { let tempDir = ""; @@ -58,6 +62,7 @@ describe("agents-md", () => { Root Index from base VISION document requirement CONFLICT resolution + Run NPM RUN TEST:E2E before committing AIDD-CUSTOM folder customization `; @@ -291,6 +296,7 @@ Read index.md files (auto-generated). Only use root index until you need more. Always read the vision document first. Report conflict resolution to the user. +Run npm run test:e2e before committing. Check aidd-custom/ for project-specific skills and configuration. `; await fs.writeFile(path.join(tempDir, "AGENTS.md"), customContent); @@ -443,5 +449,288 @@ Check aidd-custom/ for project-specific skills and configuration. expected: true, }); }); + + test("includes the e2e test directive keyword", () => { + assert({ + given: "requiredDirectives constant", + should: + "include test:e2e directive so agents know to run E2E tests before committing", + actual: requiredDirectives.includes("test:e2e"), + expected: true, + }); + }); + }); + + describe("agentsMdContent e2e instruction", () => { + test("instructs agents to run npm run test:e2e before committing", () => { + assert({ + given: "the standard AGENTS.md template", + should: "include the npm run test:e2e command", + actual: agentsMdContent.includes("npm run test:e2e"), + expected: true, + }); + }); + + test("explains that E2E tests are excluded from the pre-commit hook", () => { + assert({ + given: "the standard AGENTS.md template", + should: "mention that E2E tests are not run by the pre-commit hook", + actual: + agentsMdContent.includes("pre-commit") || + agentsMdContent.includes("commit hook"), + expected: true, + }); + }); + }); +}); + +describe("syncRootAgentFiles", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-sync-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates both files when neither exists", async () => { + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "a directory with no AGENTS.md or CLAUDE.md", + should: "return created action for AGENTS.md", + actual: results.find((r) => r.file === "AGENTS.md")?.action, + expected: "created", + }); + + assert({ + given: "a directory with no AGENTS.md or CLAUDE.md", + should: "return created action for CLAUDE.md", + actual: results.find((r) => r.file === "CLAUDE.md")?.action, + expected: "created", + }); + + const agentsContent = await fs.readFile( + path.join(tempDir, "AGENTS.md"), + "utf-8", + ); + const claudeContent = await fs.readFile( + path.join(tempDir, "CLAUDE.md"), + "utf-8", + ); + + assert({ + given: "newly created AGENTS.md", + should: "contain agentsMdContent exactly", + actual: agentsContent, + expected: agentsMdContent, + }); + + assert({ + given: "newly created CLAUDE.md", + should: "contain agentsMdContent exactly", + actual: claudeContent, + expected: agentsMdContent, + }); + }); + + test("returns unchanged when both files already match the template", async () => { + await fs.writeFile(path.join(tempDir, "AGENTS.md"), agentsMdContent); + await fs.writeFile(path.join(tempDir, "CLAUDE.md"), agentsMdContent); + + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "AGENTS.md already matching agentsMdContent", + should: "return unchanged for AGENTS.md", + actual: results.find((r) => r.file === "AGENTS.md")?.action, + expected: "unchanged", + }); + + assert({ + given: "CLAUDE.md already matching agentsMdContent", + should: "return unchanged for CLAUDE.md", + actual: results.find((r) => r.file === "CLAUDE.md")?.action, + expected: "unchanged", + }); + }); + + test("overwrites a stale file whose content differs from the current template", async () => { + const staleContent = + "# AI Agent Guidelines\n\nOld content that is outdated."; + await fs.writeFile(path.join(tempDir, "AGENTS.md"), staleContent); + await fs.writeFile(path.join(tempDir, "CLAUDE.md"), agentsMdContent); + + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "AGENTS.md with stale content differing from agentsMdContent", + should: "return updated action for AGENTS.md", + actual: results.find((r) => r.file === "AGENTS.md")?.action, + expected: "updated", + }); + + assert({ + given: "CLAUDE.md already matching agentsMdContent", + should: "return unchanged for CLAUDE.md", + actual: results.find((r) => r.file === "CLAUDE.md")?.action, + expected: "unchanged", + }); + + const agentsContent = await fs.readFile( + path.join(tempDir, "AGENTS.md"), + "utf-8", + ); + + assert({ + given: "AGENTS.md after sync", + should: "contain the current agentsMdContent exactly", + actual: agentsContent, + expected: agentsMdContent, + }); + }); +}); + +describe("ensureClaudeMd", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-claude-md-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates CLAUDE.md with AGENTS.md content when no CLAUDE.md exists", async () => { + const result = await ensureClaudeMd(tempDir); + + assert({ + given: "a directory with no CLAUDE.md", + should: "return created action", + actual: result.action, + expected: "created", + }); + + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); + + assert({ + given: "newly created CLAUDE.md", + should: "contain the same content as the AGENTS.md template", + actual: content, + expected: agentsMdContent, + }); + }); + + test("appends AGENTS.md reference when CLAUDE.md exists without it", async () => { + const existingContent = "# My existing CLAUDE.md\n\nCustom instructions."; + await fs.writeFile(path.join(tempDir, "CLAUDE.md"), existingContent); + + const result = await ensureClaudeMd(tempDir); + + assert({ + given: "an existing CLAUDE.md that has no AGENTS.md reference", + should: "return appended action", + actual: result.action, + expected: "appended", + }); + + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); + + assert({ + given: "an existing CLAUDE.md after appending", + should: "start with the original content", + actual: content.startsWith(existingContent), + expected: true, + }); + + assert({ + given: "an existing CLAUDE.md after appending", + should: "include a reference to AGENTS.md", + actual: content.includes("AGENTS.md"), + expected: true, + }); + }); + + test("throws AgentsFileError when CLAUDE.md exists but cannot be read", async () => { + await fs.writeFile(path.join(tempDir, "CLAUDE.md"), "# existing content"); + + // Simulate an I/O read failure + const ioError = Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + const spy = vi.spyOn(fs, "readFile").mockRejectedValueOnce(ioError); + + /** @type {any} */ + let error = null; + try { + await ensureClaudeMd(tempDir); + } catch (err) { + error = err; + } finally { + spy.mockRestore(); + } + + assert({ + given: "CLAUDE.md exists but readFile throws a permission error", + should: "throw AgentsFileError wrapping the original error", + actual: error?.cause?.code, + expected: "AGENTS_FILE_ERROR", + }); + }); + + test("leaves CLAUDE.md unchanged when it already references AGENTS.md", async () => { + const existingContent = + "# CLAUDE.md\n\nSee [AGENTS.md](./AGENTS.md) for guidelines."; + await fs.writeFile(path.join(tempDir, "CLAUDE.md"), existingContent); + + const result = await ensureClaudeMd(tempDir); + + assert({ + given: "a CLAUDE.md that already references AGENTS.md", + should: "return unchanged action", + actual: result.action, + expected: "unchanged", + }); + + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); + + assert({ + given: "a CLAUDE.md that already references AGENTS.md", + should: "preserve the original content exactly", + actual: content, + expected: existingContent, + }); + }); + + test("leaves CLAUDE.md unchanged when created by a previous install", async () => { + // First install creates CLAUDE.md with agentsMdContent, which has all + // required directives but no literal "AGENTS.md" substring — the previous + // existingContent.includes("AGENTS.md") check returned false here, + // causing the pointer line to be appended on every second install. + await fs.writeFile(path.join(tempDir, "CLAUDE.md"), agentsMdContent); + + const result = await ensureClaudeMd(tempDir); + + assert({ + given: + "CLAUDE.md created by a previous install containing all agent directives", + should: "return unchanged without modifying the file", + actual: result.action, + expected: "unchanged", + }); + + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); + + assert({ + given: "CLAUDE.md created by a previous install", + should: "preserve the original content exactly", + actual: content, + expected: agentsMdContent, + }); }); }); diff --git a/lib/aidd-config.js b/lib/aidd-config.js new file mode 100644 index 00000000..c9e224bf --- /dev/null +++ b/lib/aidd-config.js @@ -0,0 +1,34 @@ +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import yaml from "js-yaml"; + +const AIDD_HOME = path.join(os.homedir(), ".aidd"); +const CONFIG_FILE = path.join(AIDD_HOME, "config.yml"); + +/** + * Read ~/.aidd/config.yml (or a custom path for testing). + * Returns {} if the file does not exist or cannot be parsed. + */ +const readConfig = async ({ configFile = CONFIG_FILE } = {}) => { + try { + const content = await fs.readFile(configFile, "utf-8"); + return yaml.load(content, { schema: yaml.JSON_SCHEMA }) ?? {}; + } catch { + return {}; + } +}; + +/** + * Merge `updates` into the existing config and write back to disk as YAML. + * Returns the merged config object. + */ +const writeConfig = async ({ updates = {}, configFile = CONFIG_FILE } = {}) => { + await fs.ensureDir(path.dirname(configFile)); + const existing = await readConfig({ configFile }); + const merged = { ...existing, ...updates }; + await fs.writeFile(configFile, yaml.dump(merged), "utf-8"); + return merged; +}; + +export { AIDD_HOME, CONFIG_FILE, readConfig, writeConfig }; diff --git a/lib/aidd-config.test.js b/lib/aidd-config.test.js new file mode 100644 index 00000000..7da0d8cd --- /dev/null +++ b/lib/aidd-config.test.js @@ -0,0 +1,193 @@ +// @ts-nocheck +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import yaml from "js-yaml"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { readConfig, writeConfig } from "./aidd-config.js"; + +describe("readConfig", () => { + let tempDir; + let configFile; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-config-test-${Date.now()}`); + await fs.ensureDir(tempDir); + configFile = path.join(tempDir, "config.yml"); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("returns empty object when config file does not exist", async () => { + const result = await readConfig({ configFile }); + + assert({ + given: "no config.yml at the given path", + should: "return an empty object", + actual: result, + expected: {}, + }); + }); + + test("returns parsed YAML when config file exists", async () => { + await fs.writeFile( + configFile, + yaml.dump({ "create-uri": "https://github.com/org/scaffold" }), + "utf-8", + ); + + const result = await readConfig({ configFile }); + + assert({ + given: "a valid config.yml", + should: "return the parsed config object", + actual: result["create-uri"], + expected: "https://github.com/org/scaffold", + }); + }); + + test("returns empty object when config file contains invalid YAML", async () => { + await fs.writeFile(configFile, "key: [unclosed bracket", "utf-8"); + + const result = await readConfig({ configFile }); + + assert({ + given: "a malformed config.yml", + should: "return an empty object without throwing", + actual: result, + expected: {}, + }); + }); + + test("returns empty object when config file contains a YAML-specific tag", async () => { + await fs.writeFile(configFile, 'key: !!binary "aGVsbG8="', "utf-8"); + + const result = await readConfig({ configFile }); + + assert({ + given: "a config.yml with a YAML-specific tag (!!binary)", + should: "return an empty object without throwing", + actual: result, + expected: {}, + }); + }); +}); + +describe("writeConfig", () => { + let tempDir; + let configFile; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-config-test-${Date.now()}`); + await fs.ensureDir(tempDir); + configFile = path.join(tempDir, "config.yml"); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates config file with given updates as YAML", async () => { + await writeConfig({ + configFile, + updates: { "create-uri": "https://github.com/org/scaffold" }, + }); + + const content = await fs.readFile(configFile, "utf-8"); + const parsed = yaml.load(content); + + assert({ + given: "writeConfig called with create-uri", + should: "write the value as YAML to the config file", + actual: parsed["create-uri"], + expected: "https://github.com/org/scaffold", + }); + }); + + test("creates parent directory if it does not exist", async () => { + const nestedConfig = path.join(tempDir, "nested", "config.yml"); + + await writeConfig({ + configFile: nestedConfig, + updates: { "create-uri": "https://github.com/org/scaffold" }, + }); + + const exists = await fs.pathExists(nestedConfig); + + assert({ + given: "writeConfig with a configFile in a non-existent directory", + should: "create the directory and write the file", + actual: exists, + expected: true, + }); + }); + + test("merges new updates into existing config without losing other keys", async () => { + await fs.writeFile( + configFile, + yaml.dump({ "other-key": "keep-me" }), + "utf-8", + ); + + await writeConfig({ + configFile, + updates: { "create-uri": "https://github.com/org/scaffold" }, + }); + + const content = await fs.readFile(configFile, "utf-8"); + const parsed = yaml.load(content); + + assert({ + given: "existing config with other-key and writeConfig adding create-uri", + should: "preserve other-key", + actual: parsed["other-key"], + expected: "keep-me", + }); + + assert({ + given: "existing config with other-key and writeConfig adding create-uri", + should: "include the new create-uri value", + actual: parsed["create-uri"], + expected: "https://github.com/org/scaffold", + }); + }); + + test("returns the merged config object", async () => { + const result = await writeConfig({ + configFile, + updates: { "create-uri": "https://github.com/org/scaffold" }, + }); + + assert({ + given: "writeConfig called", + should: "return the merged config", + actual: result["create-uri"], + expected: "https://github.com/org/scaffold", + }); + }); + + test("overwrites existing value for the same key", async () => { + await writeConfig({ + configFile, + updates: { "create-uri": "https://github.com/org/old" }, + }); + await writeConfig({ + configFile, + updates: { "create-uri": "https://github.com/org/new" }, + }); + + const content = await fs.readFile(configFile, "utf-8"); + const parsed = yaml.load(content); + + assert({ + given: "writeConfig called twice with the same key", + should: "use the most recent value", + actual: parsed["create-uri"], + expected: "https://github.com/org/new", + }); + }); +}); diff --git a/lib/cli-core.d.ts b/lib/cli-core.d.ts index 7c675066..c0b3c437 100644 --- a/lib/cli-core.d.ts +++ b/lib/cli-core.d.ts @@ -90,6 +90,7 @@ export function executeClone(options?: { dryRun?: boolean; verbose?: boolean; cursor?: boolean; + claude?: boolean; }): Promise; /** diff --git a/lib/cli-core.js b/lib/cli-core.js index f58eeae3..9661329b 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -5,8 +5,9 @@ import chalk from "chalk"; import { createError, errorCauses } from "error-causes"; import fs from "fs-extra"; -import { ensureAgentsMd } from "./agents-md.js"; +import { ensureAgentsMd, ensureClaudeMd } from "./agents-md.js"; import { generateIndexRecursive } from "./index-generator.js"; +import { createSymlink } from "./symlinks.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -160,43 +161,6 @@ const createAiddCustomConfig = } }; -const createCursorSymlink = - ({ targetBase, force = false }) => - async () => { - const cursorPath = path.join(targetBase, ".cursor"); - const aiRelativePath = "ai"; - - try { - // Check if .cursor already exists - const cursorExists = await fs.pathExists(cursorPath); - - if (cursorExists) { - if (!force) { - throw createError({ - ...ValidationError, - message: ".cursor already exists (use --force to overwrite)", - }); - } - // Remove existing .cursor (file or symlink) - await fs.remove(cursorPath); - } - - // Create symlink - await fs.symlink(aiRelativePath, cursorPath); - } catch (originalError) { - // If it's already our validation error, re-throw - if (originalError.cause?.code === "VALIDATION_ERROR") { - throw originalError; - } - - throw createError({ - ...FileSystemError, - cause: originalError, - message: `Failed to create .cursor symlink: ${originalError.message}`, - }); - } - }; - // Output functions const createLogger = ({ verbose = false, dryRun = false } = {}) => ({ cyan: (msg) => console.log(chalk.cyan(msg)), @@ -246,6 +210,7 @@ const executeClone = async ({ dryRun = false, verbose = false, cursor = false, + claude = false, } = {}) => { try { const logger = createLogger({ dryRun, verbose }); @@ -288,6 +253,12 @@ const executeClone = async ({ const agentsResult = await ensureAgentsMd(paths.targetBase); verbose && logger.verbose(`AGENTS.md: ${agentsResult.message}`); + // Ensure CLAUDE.md exists (Claude Code reads this for project guidelines). + // Never overwrites an existing CLAUDE.md. + verbose && logger.info("Setting up CLAUDE.md..."); + const claudeResult = await ensureClaudeMd(paths.targetBase); + verbose && logger.verbose(`CLAUDE.md: ${claudeResult.message}`); + // Create aidd-custom/config.yml with project defaults. // Never force-overwrite: config.yml holds user preferences, not framework code. verbose && logger.info("Creating aidd-custom/config.yml..."); @@ -302,10 +273,22 @@ const executeClone = async ({ const customDir = path.join(paths.targetBase, "aidd-custom"); await generateIndexRecursive(customDir); - // Create cursor symlink if requested + // Create editor symlinks if requested if (cursor) { verbose && logger.info("Creating .cursor symlink..."); - await createCursorSymlink({ force, targetBase: paths.targetBase })(); + await createSymlink({ + force, + name: ".cursor", + targetBase: paths.targetBase, + })(); + } + if (claude) { + verbose && logger.info("Creating .claude symlink..."); + await createSymlink({ + force, + name: ".claude", + targetBase: paths.targetBase, + })(); } // Success output @@ -313,27 +296,9 @@ const executeClone = async ({ return { paths, success: true }; } catch (error) { - // Structured error handling using error causes - if (error.cause) { - return { - error: { - cause: error.cause, - code: error.cause?.code, - message: error.message, - }, - success: false, - }; - } - - // Handle unexpected errors - return { - error: { - cause: error.cause, - message: error.message, - type: "unexpected", - }, - success: false, - }; + // Return the original Error object so callers can pass it directly to + // handleCliErrors() without reconstructing a wrapper Error. + return { error, success: false }; } }; diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index f9ebe080..bc20982e 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -128,6 +128,46 @@ describe("createAiddCustomConfig", () => { }); }); +describe("executeClone error result shape", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-cli-core-test-${Date.now()}`); + await fs.ensureDir(tempDir); + // Pre-create an ai/ folder so the ai/ already-exists validation fires + await fs.ensureDir(path.join(tempDir, "ai")); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("returns result.error as an Error instance (not a plain object) when clone fails", async () => { + // ai/ already exists without force — triggers ValidationError + /** @type {any} */ + const result = await executeClone({ targetDirectory: tempDir }); + + assert({ + given: "a clone that fails validation", + should: "return result.error as an instanceof Error", + actual: result.success === false && result.error instanceof Error, + expected: true, + }); + }); + + test("result.error has a cause.code identifying the error type", async () => { + /** @type {any} */ + const result = await executeClone({ targetDirectory: tempDir }); + + assert({ + given: "a clone that fails with a validation error", + should: "have result.error.cause.code equal to VALIDATION_ERROR", + actual: result.error?.cause?.code, + expected: "VALIDATION_ERROR", + }); + }); +}); + describe("createLogger", () => { test("logger initialization with defaults", () => { const logger = createLogger(); diff --git a/lib/exports.test.js b/lib/exports.test.js index 66bb008e..65e2efe8 100644 --- a/lib/exports.test.js +++ b/lib/exports.test.js @@ -127,4 +127,17 @@ describe("package.json exports configuration", () => { expected: true, }); }); + + test("declares Node.js engine requirement", async () => { + const pkg = await import("../package.json", { with: { type: "json" } }); + const nodeEngine = pkg.default.engines?.node; + + assert({ + given: "a user on Node < 18", + should: + "declare engines.node to surface a clear version error at install time", + actual: nodeEngine, + expected: ">=18", + }); + }); }); diff --git a/lib/index-generator.js b/lib/index-generator.js index 7399ccaa..6b3a329f 100644 --- a/lib/index-generator.js +++ b/lib/index-generator.js @@ -122,19 +122,15 @@ const generateFileEntry = async (dirPath, filename) => { let entry = `### ${escapeMarkdownLink(title)}\n\n`; entry += `**File:** \`${escapedFilename}\`\n\n`; - if (frontmatter) { - if (frontmatter.description) { - entry += `${frontmatter.description}\n\n`; - } - if (frontmatter.globs) { - entry += `**Applies to:** \`${frontmatter.globs}\`\n\n`; - } - if (frontmatter.alwaysApply === true) { - entry += `**Always active**\n\n`; - } - } else { - // No frontmatter - just note the file exists - entry += `*No description available*\n\n`; + if (frontmatter?.description) { + entry += `${frontmatter.description}\n\n`; + } + + if (frontmatter?.globs) { + entry += `**Applies to:** \`${frontmatter.globs}\`\n\n`; + } + if (frontmatter?.alwaysApply === true) { + entry += `**Always active**\n\n`; } return entry; diff --git a/lib/index-generator.test.js b/lib/index-generator.test.js index 8de96d83..5e0aa0ac 100644 --- a/lib/index-generator.test.js +++ b/lib/index-generator.test.js @@ -203,8 +203,10 @@ Content here.`; assert({ given: "file without frontmatter", - should: "note no description available", - actual: content.includes("No description available"), + should: "include the file title without a description placeholder", + actual: + content.includes("Plain File") && + !content.includes("No description available"), expected: true, }); }); diff --git a/lib/scaffold-cleanup.js b/lib/scaffold-cleanup.js new file mode 100644 index 00000000..1b0b7058 --- /dev/null +++ b/lib/scaffold-cleanup.js @@ -0,0 +1,19 @@ +import path from "path"; +import fs from "fs-extra"; + +const scaffoldCleanup = async ({ folder = process.cwd() } = {}) => { + const aiddDir = path.join(folder, ".aidd"); + const exists = await fs.pathExists(aiddDir); + + if (!exists) { + return { + action: "not-found", + message: "Nothing to clean up: .aidd/ does not exist.", + }; + } + + await fs.remove(aiddDir); + return { action: "removed", message: `Removed ${aiddDir}` }; +}; + +export { scaffoldCleanup }; diff --git a/lib/scaffold-cleanup.test.js b/lib/scaffold-cleanup.test.js new file mode 100644 index 00000000..e539690e --- /dev/null +++ b/lib/scaffold-cleanup.test.js @@ -0,0 +1,71 @@ +// @ts-nocheck +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test, vi } from "vitest"; + +import { scaffoldCleanup } from "./scaffold-cleanup.js"; + +describe("scaffoldCleanup", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-cleanup-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("deletes .aidd/ directory when it exists", async () => { + const aiddDir = path.join(tempDir, ".aidd"); + await fs.ensureDir(path.join(aiddDir, "scaffold")); + + const result = await scaffoldCleanup({ folder: tempDir }); + + const exists = await fs.pathExists(aiddDir); + + assert({ + given: ".aidd/ directory exists", + should: "delete it", + actual: exists, + expected: false, + }); + + assert({ + given: ".aidd/ directory exists", + should: "return removed action", + actual: result.action, + expected: "removed", + }); + }); + + test("reports nothing to clean up when .aidd/ does not exist", async () => { + const result = await scaffoldCleanup({ folder: tempDir }); + + assert({ + given: ".aidd/ directory does not exist", + should: "return not-found action", + actual: result.action, + expected: "not-found", + }); + }); + + test("uses current working directory when no folder is given", async () => { + // Point process.cwd() to tempDir (which has no .aidd) via spy + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tempDir); + + const result = await scaffoldCleanup({}); + + cwdSpy.mockRestore(); + + assert({ + given: "no folder argument and no .aidd/ in the effective cwd", + should: "return not-found action", + actual: result.action, + expected: "not-found", + }); + }); +}); diff --git a/lib/scaffold-create.js b/lib/scaffold-create.js new file mode 100644 index 00000000..9ee7fc95 --- /dev/null +++ b/lib/scaffold-create.js @@ -0,0 +1,81 @@ +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; + +import { resolveExtension as defaultResolveExtension } from "./scaffold-resolver.js"; +import { runManifest as defaultRunManifest } from "./scaffold-runner.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Resolve the Commander two-optional-arg pattern into { type, resolvedFolder, folderPath }. + * Returns null when no folder was supplied at all. + * + * create scaffold-example my-project → type="scaffold-example", folder="my-project" + * create my-project → type=undefined, folder="my-project" + * create → null (caller should error) + * + * @param {string | undefined} typeOrFolder + * @param {string | undefined} folder + */ +const resolveCreateArgs = (typeOrFolder, folder) => { + if (!typeOrFolder) return null; + + // If no folder was given and the sole argument looks like a URI, the user + // most likely forgot the folder argument. Return null so the caller emits + // "missing required argument 'folder'" rather than creating a directory + // with a mangled URL path. + if (folder === undefined && /^(https?|file):\/\//i.test(typeOrFolder)) { + return null; + } + + const type = folder !== undefined ? typeOrFolder : undefined; + const resolvedFolder = folder !== undefined ? folder : typeOrFolder; + const folderPath = path.resolve(process.cwd(), resolvedFolder); + + return { folderPath, resolvedFolder, type }; +}; + +/** + * Execute the scaffold create flow. + * All IO dependencies are injectable for unit testing. + * + * Returns { success: true, folderPath, cleanupTip } on success. + * Throws on failure (caller handles error display and process.exit). + * + * @param {{ type?: string, folder?: string, agent?: string, packageRoot?: string, resolveExtensionFn?: Function, runManifestFn?: Function, ensureDirFn?: Function }} [options] + */ +const runCreate = async ({ + type, + folder, + agent = "claude", + packageRoot = __dirname, + resolveExtensionFn = defaultResolveExtension, + runManifestFn = defaultRunManifest, + ensureDirFn = fs.ensureDir, +} = {}) => { + const paths = await resolveExtensionFn({ + folder, + packageRoot, + type, + }); + + await ensureDirFn(folder); + + await runManifestFn({ + agent, + folder, + manifestPath: paths.manifestPath, + }); + + return { + ...(paths.downloaded + ? { cleanupTip: `npx aidd scaffold-cleanup "${folder}"` } + : {}), + folderPath: folder, + success: true, + }; +}; + +export { resolveCreateArgs, runCreate }; diff --git a/lib/scaffold-create.test.js b/lib/scaffold-create.test.js new file mode 100644 index 00000000..2c9a0d0d --- /dev/null +++ b/lib/scaffold-create.test.js @@ -0,0 +1,367 @@ +// @ts-nocheck +import path from "path"; +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +import { resolveCreateArgs, runCreate } from "./scaffold-create.js"; + +describe("resolveCreateArgs", () => { + test("returns null when typeOrFolder is absent", () => { + assert({ + given: "no arguments", + should: "return null to signal missing folder", + actual: resolveCreateArgs(undefined, undefined), + expected: null, + }); + }); + + test("returns null when single arg is an https:// URI (folder omitted)", () => { + assert({ + given: "only an https:// URL with no folder", + should: "return null to signal missing folder", + actual: resolveCreateArgs("https://github.com/org/repo", undefined), + expected: null, + }); + }); + + test("returns null when single arg is a file:// URI (folder omitted)", () => { + assert({ + given: "only a file:// URI with no folder", + should: "return null to signal missing folder", + actual: resolveCreateArgs("file:///path/to/scaffold", undefined), + expected: null, + }); + }); + + test("returns null when single arg is an http:// URI (folder omitted)", () => { + assert({ + given: "only an http:// URL with no folder", + should: "return null to signal missing folder", + actual: resolveCreateArgs("http://example.com/scaffold", undefined), + expected: null, + }); + }); + + test("two-arg: https:// URL as type with explicit folder is accepted", () => { + const result = + /** @type {NonNullable>} */ ( + resolveCreateArgs("https://github.com/org/repo", "my-project") + ); + assert({ + given: "an https:// URL as type and an explicit folder", + should: "set type to the URL", + actual: result.type, + expected: "https://github.com/org/repo", + }); + assert({ + given: "an https:// URL as type and an explicit folder", + should: "set resolvedFolder to the folder argument", + actual: result.resolvedFolder, + expected: "my-project", + }); + }); + + test("one-arg: treats single value as folder, type is undefined", () => { + const result = + /** @type {NonNullable>} */ ( + resolveCreateArgs("my-project", undefined) + ); + + assert({ + given: "only a folder argument", + should: "set type to undefined", + actual: result.type, + expected: undefined, + }); + + assert({ + given: "only a folder argument", + should: "set resolvedFolder to the given value", + actual: result.resolvedFolder, + expected: "my-project", + }); + }); + + test("one-arg: resolves absolute folderPath from cwd", () => { + const result = + /** @type {NonNullable>} */ ( + resolveCreateArgs("my-project", undefined) + ); + + assert({ + given: "only a folder argument", + should: "resolve folderPath to an absolute path", + actual: path.isAbsolute(result.folderPath), + expected: true, + }); + + assert({ + given: "only a folder argument", + should: "folderPath ends with the given folder name", + actual: result.folderPath.endsWith("my-project"), + expected: true, + }); + }); + + test("two-arg: first arg is type, second is folder", () => { + const result = + /** @type {NonNullable>} */ ( + resolveCreateArgs("scaffold-example", "my-project") + ); + + assert({ + given: "type and folder arguments", + should: "set type to the first argument", + actual: result.type, + expected: "scaffold-example", + }); + + assert({ + given: "type and folder arguments", + should: "set resolvedFolder to the second argument", + actual: result.resolvedFolder, + expected: "my-project", + }); + }); + + test("two-arg: folderPath is absolute path to the second argument", () => { + const result = + /** @type {NonNullable>} */ ( + resolveCreateArgs("scaffold-example", "my-project") + ); + + assert({ + given: "type and folder arguments", + should: "resolve folderPath to absolute path ending with folder name", + actual: + path.isAbsolute(result.folderPath) && + result.folderPath.endsWith("my-project"), + expected: true, + }); + }); +}); + +describe("runCreate", () => { + const noopEnsureDir = async () => {}; + const noopResolveExtension = async () => ({ + manifestPath: "/fake/SCAFFOLD-MANIFEST.yml", + readmePath: "/fake/README.md", + downloaded: true, + }); + const noopRunManifest = async () => {}; + + test("uses absolute folderPath in the cleanup tip", async () => { + const result = await runCreate({ + type: undefined, + folder: "/absolute/path/to/my-project", + agent: "claude", + resolveExtensionFn: noopResolveExtension, + runManifestFn: noopRunManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "a resolved absolute folder path", + should: "return a cleanup tip containing the absolute path", + actual: result.cleanupTip?.includes("/absolute/path/to/my-project"), + expected: true, + }); + }); + + test("cleanup tip does not contain a relative folder name", async () => { + const result = await runCreate({ + type: undefined, + folder: path.resolve(process.cwd(), "relative-project"), + agent: "claude", + resolveExtensionFn: noopResolveExtension, + runManifestFn: noopRunManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "a folder resolved to an absolute path", + should: + "return a cleanup tip with the absolute path, not the relative name", + actual: path.isAbsolute( + (result.cleanupTip ?? "") + .replace("npx aidd scaffold-cleanup ", "") + .replace(/^"|"$/g, ""), + ), + expected: true, + }); + }); + + test("passes type and folder to resolveExtension", async () => { + const calls = /** @type {Array<{type?: string, folder?: string}>} */ ([]); + const trackingResolve = async ( + /** @type {{type?: string, folder?: string}} */ { type, folder }, + ) => { + calls.push({ type, folder }); + return { + manifestPath: "/fake/SCAFFOLD-MANIFEST.yml", + readmePath: "/fake/README.md", + }; + }; + + await runCreate({ + type: "scaffold-example", + folder: "/abs/my-project", + agent: "claude", + resolveExtensionFn: trackingResolve, + runManifestFn: noopRunManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "type and folder supplied", + should: "pass type to resolveExtension", + actual: calls[0]?.type, + expected: "scaffold-example", + }); + + assert({ + given: "type and folder supplied", + should: "pass the folder path to resolveExtension", + actual: calls[0]?.folder, + expected: "/abs/my-project", + }); + }); + + test("does not include cleanupTip when resolveExtension returns downloaded:false", async () => { + const localResolve = async () => ({ + manifestPath: "/fake/SCAFFOLD-MANIFEST.yml", + readmePath: "/fake/README.md", + downloaded: false, + }); + + const result = await runCreate({ + type: undefined, + folder: "/abs/my-project", + agent: "claude", + resolveExtensionFn: localResolve, + runManifestFn: noopRunManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "a named or file:// scaffold (downloaded:false)", + should: "not include a cleanupTip in the result", + actual: result.cleanupTip, + expected: undefined, + }); + }); + + test("includes cleanupTip when resolveExtension returns downloaded:true", async () => { + const remoteResolve = async () => ({ + manifestPath: "/fake/SCAFFOLD-MANIFEST.yml", + readmePath: "/fake/README.md", + downloaded: true, + }); + + const result = await runCreate({ + type: undefined, + folder: "/abs/remote-project", + agent: "claude", + resolveExtensionFn: remoteResolve, + runManifestFn: noopRunManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "an HTTP/HTTPS scaffold (downloaded:true)", + should: "include a cleanupTip with the absolute folder path", + actual: + typeof result.cleanupTip === "string" && + result.cleanupTip.includes("/abs/remote-project"), + expected: true, + }); + }); + + test("does not create the project directory when resolveExtension rejects", async () => { + const ensureDirCalls = []; + const trackingEnsureDir = async (folder) => { + ensureDirCalls.push(folder); + }; + const rejectingResolve = async () => { + throw new Error("User cancelled"); + }; + + let error; + try { + await runCreate({ + type: "https://github.com/org/repo", + folder: "/abs/my-project", + agent: "claude", + resolveExtensionFn: rejectingResolve, + runManifestFn: noopRunManifest, + ensureDirFn: trackingEnsureDir, + }); + } catch (e) { + error = e; + } + + assert({ + given: + "resolveExtension rejects (e.g. user cancels remote code confirmation)", + should: "not create the project directory on disk", + actual: ensureDirCalls.length, + expected: 0, + }); + + assert({ + given: "resolveExtension rejects", + should: "propagate the error to the caller", + actual: error?.message, + expected: "User cancelled", + }); + }); + + test("cleanup tip wraps a folder path containing spaces in double quotes", async () => { + const remoteResolve = async () => ({ + manifestPath: "/fake/SCAFFOLD-MANIFEST.yml", + readmePath: "/fake/README.md", + downloaded: true, + }); + + const result = await runCreate({ + type: undefined, + folder: "/home/user/my project", + agent: "claude", + resolveExtensionFn: remoteResolve, + runManifestFn: noopRunManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "a folder path that contains spaces", + should: "wrap the path in double quotes in the cleanup tip", + actual: result.cleanupTip, + expected: 'npx aidd scaffold-cleanup "/home/user/my project"', + }); + }); + + test("passes agent and folder to runManifest", async () => { + const calls = /** @type {Array<{agent?: string, folder?: string}>} */ ([]); + const trackingManifest = async ( + /** @type {{agent?: string, folder?: string}} */ { agent, folder }, + ) => { + calls.push({ agent, folder }); + }; + + await runCreate({ + type: undefined, + folder: "/abs/my-project", + agent: "aider", + resolveExtensionFn: noopResolveExtension, + runManifestFn: trackingManifest, + ensureDirFn: noopEnsureDir, + }); + + assert({ + given: "an agent name and folder", + should: "pass the agent to runManifest", + actual: calls[0]?.agent, + expected: "aider", + }); + }); +}); diff --git a/lib/scaffold-errors.js b/lib/scaffold-errors.js new file mode 100644 index 00000000..05416e12 --- /dev/null +++ b/lib/scaffold-errors.js @@ -0,0 +1,38 @@ +import { errorCauses } from "error-causes"; + +// Typed error causes for scaffold operations. +// handleScaffoldErrors enforces exhaustive handling at call-site: +// every key defined here MUST have a corresponding handler when called. +const [scaffoldErrors, handleScaffoldErrors] = errorCauses({ + ScaffoldCancelledError: { + code: "SCAFFOLD_CANCELLED", + message: "Scaffold operation cancelled", + }, + ScaffoldNetworkError: { + code: "SCAFFOLD_NETWORK_ERROR", + message: "Failed to fetch remote scaffold", + }, + ScaffoldStepError: { + code: "SCAFFOLD_STEP_ERROR", + message: "Scaffold step execution failed", + }, + ScaffoldValidationError: { + code: "SCAFFOLD_VALIDATION_ERROR", + message: "Scaffold manifest is invalid", + }, +}); + +const { + ScaffoldCancelledError, + ScaffoldNetworkError, + ScaffoldStepError, + ScaffoldValidationError, +} = scaffoldErrors; + +export { + ScaffoldCancelledError, + ScaffoldNetworkError, + ScaffoldStepError, + ScaffoldValidationError, + handleScaffoldErrors, +}; diff --git a/lib/scaffold-example-readme.test.js b/lib/scaffold-example-readme.test.js new file mode 100644 index 00000000..74b151d9 --- /dev/null +++ b/lib/scaffold-example-readme.test.js @@ -0,0 +1,35 @@ +// @ts-nocheck +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const readmePath = path.resolve( + __dirname, + "../ai/scaffolds/scaffold-example/README.md", +); +const readme = fs.readFileSync(readmePath, "utf8"); + +describe("scaffold-example README", () => { + test("lists release-it as a dependency", () => { + assert({ + given: "the scaffold-example README", + should: "list release-it as an installed dependency", + actual: readme.includes("release-it"), + expected: true, + }); + }); + + test("mentions the npm run release script", () => { + assert({ + given: "the scaffold-example README", + should: "mention the configured npm run release script", + actual: readme.includes("release"), + expected: true, + }); + }); +}); diff --git a/lib/scaffold-resolver.js b/lib/scaffold-resolver.js new file mode 100644 index 00000000..d316e0bd --- /dev/null +++ b/lib/scaffold-resolver.js @@ -0,0 +1,291 @@ +// @ts-nocheck +import { spawn } from "child_process"; +import path from "path"; +import readline from "readline"; +import { fileURLToPath } from "url"; +import { createError } from "error-causes"; +import fs from "fs-extra"; + +import { readConfig } from "./aidd-config.js"; +import { + ScaffoldCancelledError, + ScaffoldNetworkError, + ScaffoldValidationError, +} from "./scaffold-errors.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DEFAULT_SCAFFOLD_TYPE = "next-shadcn"; + +const isHttpUrl = (url) => + url.startsWith("http://") || url.startsWith("https://"); +const isInsecureHttpUrl = (url) => url.startsWith("http://"); +const isFileUrl = (url) => url.startsWith("file://"); + +// Returns true for bare https://github.com/owner/repo URLs (no extra path segments). +const isGitHubRepoUrl = (url) => { + if (!url.startsWith("https://github.com/")) return false; + const parts = new URL(url).pathname.split("/").filter(Boolean); + return parts.length === 2; +}; + +// Fetches the latest release tarball URL from the GitHub API. +// Set GITHUB_TOKEN in the environment for private repos and higher rate limits +// (5,000 req/hr authenticated vs. 60 req/hr unauthenticated). +const defaultResolveRelease = async (repoUrl) => { + const { pathname } = new URL(repoUrl); + const [, owner, repo] = pathname.split("/"); + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + const headers = { + Accept: "application/vnd.github+json", + "User-Agent": "aidd-cli", + }; + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + const response = await fetch(apiUrl, { headers }); + if (!response.ok) { + if (response.status === 403) { + throw new Error( + `GitHub API rate limit exceeded for ${repoUrl}. Set GITHUB_TOKEN for 5,000 req/hr.`, + ); + } + const tokenHint = process.env.GITHUB_TOKEN + ? "" + : " If the repo is private, set GITHUB_TOKEN to authenticate."; + throw new Error( + `GitHub API returned ${response.status} for ${repoUrl} — no releases found or repo is private.${tokenHint}`, + ); + } + const release = await response.json(); + if (!release.tarball_url) { + throw new Error(`No tarball URL in latest release of ${repoUrl}`); + } + return release.tarball_url; +}; + +// Downloads a tarball from url and extracts it into destPath. +// Uses --strip-components=1 to drop the single root directory that GitHub +// release archives include (e.g. org-repo-sha1234/). +// fetch() is used for the download because it follows redirects automatically; +// the system tar binary handles extraction without additional npm dependencies. +// GITHUB_TOKEN is forwarded only for GitHub hostnames to avoid leaking it to +// third-party servers that happen to host a scaffold tarball. +const GITHUB_DOWNLOAD_HOSTNAMES = new Set([ + "api.github.com", + "github.com", + "codeload.github.com", +]); +const defaultDownloadAndExtract = async (url, destPath) => { + const headers = {}; + if (process.env.GITHUB_TOKEN) { + try { + const { hostname } = new URL(url); + if (GITHUB_DOWNLOAD_HOSTNAMES.has(hostname)) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + } catch { + // Malformed URL — skip auth header; fetch will fail with its own error + } + } + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} downloading scaffold from ${url}`); + } + + // Buffer the body — scaffold tarballs are small, so memory usage is fine. + const buffer = Buffer.from(await response.arrayBuffer()); + + await new Promise((resolve, reject) => { + const child = spawn( + "tar", + ["-xz", "--strip-components=1", "-C", destPath], + { stdio: ["pipe", "inherit", "inherit"] }, + ); + child.on("close", (code) => { + if (code !== 0) + reject(new Error(`tar exited with code ${code} extracting ${url}`)); + else resolve(); + }); + child.on("error", reject); + // Guard against EPIPE if tar exits before consuming all stdin. + // Without this listener the unhandled 'error' event crashes Node. + child.stdin.on("error", reject); + child.stdin.write(buffer); + child.stdin.end(); + }); +}; + +const defaultConfirm = async (message) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve, reject) => { + // Reject if stdin closes or errors before the user answers — avoids hanging + const onClose = () => + reject( + Object.assign(new Error("stdin closed before answer"), { + type: "close", + }), + ); + const onError = (err) => reject(Object.assign(err, { type: "error" })); + rl.on("close", onClose); + rl.on("error", onError); + rl.question(message, (answer) => { + rl.removeListener("close", onClose); + rl.removeListener("error", onError); + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); +}; + +const defaultLog = (msg) => console.log(msg); + +const resolveNamed = ({ type, packageRoot }) => { + const scaffoldsRoot = path.resolve(packageRoot, "../ai/scaffolds"); + const typeDir = path.resolve(scaffoldsRoot, type); + + // Reject path traversal and invalid names using path.relative(): + // - relative starts with ".." → resolved outside scaffoldsRoot + // - relative is absolute → path.resolve produced an absolute path (shouldn't happen, but guard anyway) + // - relative is "" → type is "." or resolves to scaffoldsRoot itself (not a valid scaffold name) + const relative = path.relative(scaffoldsRoot, typeDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + const reason = !relative + ? `"${type}" resolves to the scaffolds root directory, not a specific scaffold` + : `"${type}" resolves outside the scaffolds directory`; + throw createError({ + ...ScaffoldValidationError, + message: `Invalid scaffold type: ${reason}. Use a simple scaffold name like "next-shadcn".`, + }); + } + + return { + manifestPath: path.join(typeDir, "SCAFFOLD-MANIFEST.yml"), + readmePath: path.join(typeDir, "README.md"), + }; +}; + +const resolveFileUri = ({ uri }) => { + const localPath = fileURLToPath(uri); + return { + manifestPath: path.join(localPath, "SCAFFOLD-MANIFEST.yml"), + readmePath: path.join(localPath, "README.md"), + }; +}; + +const downloadExtension = async ({ uri, folder, download }) => { + const scaffoldDir = path.join(folder, ".aidd/scaffold"); + // Remove any prior download so we start clean + await fs.remove(scaffoldDir); + await fs.ensureDir(scaffoldDir); + await download(uri, scaffoldDir); + return { + manifestPath: path.join(scaffoldDir, "SCAFFOLD-MANIFEST.yml"), + readmePath: path.join(scaffoldDir, "README.md"), + }; +}; + +const resolveExtension = async ({ + type, + folder, + packageRoot = __dirname, + confirm = defaultConfirm, + download = defaultDownloadAndExtract, + resolveRelease = defaultResolveRelease, + log = defaultLog, + readConfigFn = readConfig, +} = {}) => { + const config = await readConfigFn(); + const effectiveType = + type || + process.env.AIDD_CUSTOM_CREATE_URI || + config["create-uri"] || + DEFAULT_SCAFFOLD_TYPE; + + let paths; + + if (isInsecureHttpUrl(effectiveType)) { + throw createError({ + ...ScaffoldValidationError, + message: `Scaffold URI must use https://, not http://. Rejected: ${effectiveType}. Change the URI to https:// and try again.`, + }); + } + + if (isHttpUrl(effectiveType)) { + let confirmed; + try { + confirmed = await confirm( + `\n⚠️ Warning: You are about to download and execute code from a remote URI:\n ${effectiveType}\n\nThis code will run on your machine. Only proceed if you trust the source.\nContinue? (y/N): `, + ); + } catch { + // stdin closed or errored before the user could answer — treat as cancellation + throw createError({ + ...ScaffoldCancelledError, + message: "Remote extension download cancelled (stdin closed or error).", + }); + } + if (!confirmed) { + throw createError({ + ...ScaffoldCancelledError, + message: "Remote extension download cancelled by user.", + }); + } + await fs.ensureDir(folder); + // Resolve bare GitHub repo URLs to the latest release tarball + let downloadUri = effectiveType; + if (isGitHubRepoUrl(effectiveType)) { + try { + downloadUri = await resolveRelease(effectiveType); + } catch (originalError) { + throw createError({ + ...ScaffoldNetworkError, + cause: originalError, + message: `Failed to resolve latest release for ${effectiveType}: ${originalError.message}`, + }); + } + } + try { + paths = { + ...(await downloadExtension({ download, folder, uri: downloadUri })), + downloaded: true, + }; + } catch (originalError) { + throw createError({ + ...ScaffoldNetworkError, + cause: originalError, + message: `Failed to download scaffold from ${effectiveType}: ${originalError.message}`, + }); + } + } else if (isFileUrl(effectiveType)) { + paths = { ...resolveFileUri({ uri: effectiveType }), downloaded: false }; + } else { + paths = { + ...resolveNamed({ packageRoot, type: effectiveType }), + downloaded: false, + }; + } + + // Fail fast with a clear error if the manifest is missing so callers don't + // receive a path that silently produces a raw ENOENT later in runManifest. + const manifestExists = await fs.pathExists(paths.manifestPath); + if (!manifestExists) { + throw createError({ + ...ScaffoldValidationError, + message: `SCAFFOLD-MANIFEST.yml not found at ${paths.manifestPath}. Check that the scaffold source (${effectiveType}) contains a SCAFFOLD-MANIFEST.yml.`, + }); + } + + const readmeExists = await fs.pathExists(paths.readmePath); + if (readmeExists) { + const readme = await fs.readFile(paths.readmePath, "utf-8"); + log(`\n${readme}`); + } + + return paths; +}; + +export { defaultDownloadAndExtract, defaultResolveRelease, resolveExtension }; diff --git a/lib/scaffold-resolver.test.js b/lib/scaffold-resolver.test.js new file mode 100644 index 00000000..645bf35f --- /dev/null +++ b/lib/scaffold-resolver.test.js @@ -0,0 +1,1144 @@ +// @ts-nocheck +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test, vi } from "vitest"; + +import { + defaultDownloadAndExtract, + defaultResolveRelease, + resolveExtension, +} from "./scaffold-resolver.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const noLog = () => {}; +const noConfirm = async () => true; + +// mockDownload simulates a successful tarball download + extraction by writing +// the minimum files a scaffold needs into destPath. +const mockDownload = async (_url, destPath) => { + await fs.ensureDir(path.join(destPath, "bin")); + await fs.writeFile(path.join(destPath, "README.md"), "# Remote Scaffold"); + await fs.writeFile( + path.join(destPath, "SCAFFOLD-MANIFEST.yml"), + "steps:\n - run: echo hello\n", + ); +}; + +describe("resolveExtension - named scaffold", () => { + test("resolves README path to ai/scaffolds//README.md", async () => { + const paths = await resolveExtension({ + type: "scaffold-example", + folder: "/tmp/test-named", + packageRoot: __dirname, + log: noLog, + }); + + assert({ + given: "a named scaffold type", + should: "resolve readmePath to ai/scaffolds//README.md", + actual: paths.readmePath.endsWith( + path.join("ai", "scaffolds", "scaffold-example", "README.md"), + ), + expected: true, + }); + }); + + test("resolves manifest path to ai/scaffolds//SCAFFOLD-MANIFEST.yml", async () => { + const paths = await resolveExtension({ + type: "scaffold-example", + folder: "/tmp/test-named", + packageRoot: __dirname, + log: noLog, + }); + + assert({ + given: "a named scaffold type", + should: + "resolve manifestPath to ai/scaffolds//SCAFFOLD-MANIFEST.yml", + actual: paths.manifestPath.endsWith( + path.join( + "ai", + "scaffolds", + "scaffold-example", + "SCAFFOLD-MANIFEST.yml", + ), + ), + expected: true, + }); + }); + + test("displays README contents when README exists", async () => { + const logged = []; + await resolveExtension({ + type: "scaffold-example", + folder: "/tmp/test-named", + packageRoot: __dirname, + log: (msg) => logged.push(msg), + }); + + assert({ + given: "a named scaffold with a README.md", + should: "display README contents to the user", + actual: logged.length > 0, + expected: true, + }); + }); + + test("returns downloaded:false for named scaffolds", async () => { + const paths = await resolveExtension({ + type: "scaffold-example", + folder: "/tmp/test-named", + packageRoot: __dirname, + log: noLog, + }); + + assert({ + given: "a named scaffold", + should: "return downloaded:false", + actual: paths.downloaded, + expected: false, + }); + }); +}); + +describe("resolveExtension - file:// URI", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-file-uri-test-${Date.now()}`); + await fs.ensureDir(tempDir); + await fs.writeFile(path.join(tempDir, "README.md"), "# Test Scaffold"); + await fs.writeFile( + path.join(tempDir, "SCAFFOLD-MANIFEST.yml"), + "steps:\n - run: echo hello\n", + ); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("resolves paths from local file:// URI without copying", async () => { + const uri = `file://${tempDir}`; + const paths = await resolveExtension({ + type: uri, + folder: "/tmp/test-file-uri", + log: noLog, + }); + + assert({ + given: "a file:// URI", + should: "resolve readmePath to the local path README.md", + actual: paths.readmePath, + expected: path.join(tempDir, "README.md"), + }); + }); + + test("resolves manifest path from file:// URI", async () => { + const uri = `file://${tempDir}`; + const paths = await resolveExtension({ + type: uri, + folder: "/tmp/test-file-uri", + log: noLog, + }); + + assert({ + given: "a file:// URI", + should: "resolve manifestPath to the local SCAFFOLD-MANIFEST.yml", + actual: paths.manifestPath, + expected: path.join(tempDir, "SCAFFOLD-MANIFEST.yml"), + }); + }); + + test("displays README contents from file:// URI", async () => { + const uri = `file://${tempDir}`; + const logged = []; + await resolveExtension({ + type: uri, + folder: "/tmp/test-file-uri", + log: (msg) => logged.push(msg), + }); + + assert({ + given: "a file:// URI scaffold with README.md", + should: "display README contents to the user", + actual: logged.join("").includes("Test Scaffold"), + expected: true, + }); + }); + + test("returns downloaded:false for file:// scaffolds", async () => { + const uri = `file://${tempDir}`; + const paths = await resolveExtension({ + type: uri, + folder: "/tmp/test-file-uri", + log: noLog, + }); + + assert({ + given: "a file:// scaffold URI", + should: "return downloaded:false", + actual: paths.downloaded, + expected: false, + }); + }); +}); + +describe("resolveExtension - HTTP/HTTPS URI", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-http-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("warns user about remote code before downloading", async () => { + const warnings = []; + const mockConfirm = async (msg) => { + warnings.push(msg); + return true; + }; + + await resolveExtension({ + type: "https://example.com/scaffold", + folder: tempDir, + confirm: mockConfirm, + download: mockDownload, + log: noLog, + }); + + assert({ + given: "an HTTPS URI", + should: "show a warning before downloading remote code", + actual: warnings.length > 0 && warnings[0].includes("Warning"), + expected: true, + }); + }); + + test("includes the URI in the warning", async () => { + const warnings = []; + const mockConfirm = async (msg) => { + warnings.push(msg); + return true; + }; + + await resolveExtension({ + type: "https://example.com/my-scaffold", + folder: tempDir, + confirm: mockConfirm, + download: mockDownload, + log: noLog, + }); + + assert({ + given: "an HTTPS URI", + should: "include the URI in the warning message", + actual: warnings[0].includes("https://example.com/my-scaffold"), + expected: true, + }); + }); + + test("cancels if user does not confirm", async () => { + const mockConfirm = async () => false; + + let errorThrown = null; + try { + await resolveExtension({ + type: "https://example.com/scaffold", + folder: tempDir, + confirm: mockConfirm, + download: mockDownload, + log: noLog, + }); + } catch (err) { + errorThrown = err; + } + + assert({ + given: "user refuses remote code confirmation", + should: "throw an error cancelling the operation", + actual: errorThrown?.message.includes("cancelled"), + expected: true, + }); + }); + + test("downloads tarball into /.aidd/scaffold/", async () => { + const downloaded = []; + const trackingDownload = async (url, destPath) => { + downloaded.push({ destPath, url }); + await mockDownload(url, destPath); + }; + + const scaffoldDir = path.join(tempDir, ".aidd/scaffold"); + + await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: trackingDownload, + log: noLog, + }); + + assert({ + given: "an HTTPS URI", + should: "call download with the tarball URL", + actual: downloaded[0]?.url, + expected: "https://example.com/scaffold.tar.gz", + }); + + assert({ + given: "an HTTPS URI", + should: "extract into /.aidd/scaffold/", + actual: downloaded[0]?.destPath, + expected: scaffoldDir, + }); + }); + + test("returns paths rooted at /.aidd/scaffold/", async () => { + const paths = await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + log: noLog, + }); + + const scaffoldDir = path.join(tempDir, ".aidd/scaffold"); + + assert({ + given: "an HTTPS URI with a downloaded tarball", + should: "return readmePath rooted at .aidd/scaffold/", + actual: paths.readmePath.startsWith(scaffoldDir), + expected: true, + }); + }); + + test("leaves extracted scaffold in place after resolving", async () => { + const paths = await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + log: noLog, + }); + + const readmeExists = await fs.pathExists(paths.readmePath); + + assert({ + given: "a downloaded extension", + should: "leave extracted files in place at /.aidd/scaffold/", + actual: readmeExists, + expected: true, + }); + }); +}); + +describe("resolveExtension - tar stdin error handling", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-tar-error-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("rejects promise when download function throws (simulates pipe break)", async () => { + const failingDownload = async () => { + throw new Error("EPIPE: broken pipe"); + }; + + let error = null; + try { + await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: failingDownload, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "the download function throws a pipe error", + should: "reject with ScaffoldNetworkError rather than crashing Node", + actual: error?.cause?.code, + expected: "SCAFFOLD_NETWORK_ERROR", + }); + }); +}); + +describe("resolveExtension - readline confirm robustness", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-readline-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("rejects with ScaffoldCancelledError when confirm rejects (stdin closed)", async () => { + const closedConfirm = async () => { + throw Object.assign(new Error("stdin was closed"), { type: "close" }); + }; + + let error = null; + try { + await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: closedConfirm, + download: mockDownload, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "confirm rejects because stdin is closed", + should: "propagate as ScaffoldCancelledError", + actual: error?.cause?.code, + expected: "SCAFFOLD_CANCELLED", + }); + }); +}); + +describe("resolveExtension - default scaffold resolution", () => { + const noConfig = async () => ({}); + + test("uses next-shadcn when no type, no env var, and no user config", async () => { + const originalEnv = process.env.AIDD_CUSTOM_CREATE_URI; + delete process.env.AIDD_CUSTOM_CREATE_URI; + + let paths; + try { + paths = await resolveExtension({ + folder: "/tmp/test-default", + packageRoot: __dirname, + log: noLog, + readConfigFn: noConfig, + }); + } finally { + if (originalEnv === undefined) { + delete process.env.AIDD_CUSTOM_CREATE_URI; + } else { + process.env.AIDD_CUSTOM_CREATE_URI = originalEnv; + } + } + + assert({ + given: "no type, no AIDD_CUSTOM_CREATE_URI env var, no user config", + should: "resolve to next-shadcn named scaffold", + actual: paths.readmePath.includes("next-shadcn"), + expected: true, + }); + }); + + test("uses AIDD_CUSTOM_CREATE_URI env var when set and no type given", async () => { + let tempDir; + try { + tempDir = path.join(os.tmpdir(), `aidd-env-test-${Date.now()}`); + await fs.ensureDir(tempDir); + await fs.writeFile(path.join(tempDir, "README.md"), "# Env Scaffold"); + await fs.writeFile( + path.join(tempDir, "SCAFFOLD-MANIFEST.yml"), + "steps:\n", + ); + + process.env.AIDD_CUSTOM_CREATE_URI = `file://${tempDir}`; + + const paths = await resolveExtension({ + folder: "/tmp/test-env", + log: noLog, + readConfigFn: noConfig, + }); + + assert({ + given: "AIDD_CUSTOM_CREATE_URI set to a file:// URI", + should: "use the env var URI as the extension source", + actual: paths.readmePath, + expected: path.join(tempDir, "README.md"), + }); + } finally { + delete process.env.AIDD_CUSTOM_CREATE_URI; + if (tempDir) await fs.remove(tempDir); + } + }); + + test("uses user config create-uri when no type and no env var", async () => { + let tempDir; + const originalEnv = process.env.AIDD_CUSTOM_CREATE_URI; + delete process.env.AIDD_CUSTOM_CREATE_URI; + + try { + tempDir = path.join(os.tmpdir(), `aidd-config-test-${Date.now()}`); + await fs.ensureDir(tempDir); + await fs.writeFile(path.join(tempDir, "README.md"), "# Config Scaffold"); + await fs.writeFile( + path.join(tempDir, "SCAFFOLD-MANIFEST.yml"), + "steps:\n", + ); + + const paths = await resolveExtension({ + folder: "/tmp/test-config", + log: noLog, + readConfigFn: async () => ({ "create-uri": `file://${tempDir}` }), + }); + + assert({ + given: "~/.aidd/config.yml has create-uri and no env var or type", + should: "use the user config URI as the extension source", + actual: paths.readmePath, + expected: path.join(tempDir, "README.md"), + }); + } finally { + if (originalEnv === undefined) { + delete process.env.AIDD_CUSTOM_CREATE_URI; + } else { + process.env.AIDD_CUSTOM_CREATE_URI = originalEnv; + } + if (tempDir) await fs.remove(tempDir); + } + }); + + test("AIDD_CUSTOM_CREATE_URI is absent after tests that ran without it set", () => { + assert({ + given: "AIDD_CUSTOM_CREATE_URI was not set before the describe block ran", + should: + "be absent from process.env after test cleanup (not coerced to the string 'undefined')", + actual: process.env.AIDD_CUSTOM_CREATE_URI, + expected: undefined, + }); + }); + + test("env var takes precedence over user config create-uri", async () => { + let envDir; + let configDir; + try { + envDir = path.join(os.tmpdir(), `aidd-env-prec-${Date.now()}`); + configDir = path.join(os.tmpdir(), `aidd-cfg-prec-${Date.now()}`); + for (const dir of [envDir, configDir]) { + await fs.ensureDir(dir); + await fs.writeFile(path.join(dir, "README.md"), `# Scaffold`); + await fs.writeFile(path.join(dir, "SCAFFOLD-MANIFEST.yml"), "steps:\n"); + } + + process.env.AIDD_CUSTOM_CREATE_URI = `file://${envDir}`; + + const paths = await resolveExtension({ + folder: "/tmp/test-prec", + log: noLog, + readConfigFn: async () => ({ "create-uri": `file://${configDir}` }), + }); + + assert({ + given: + "both AIDD_CUSTOM_CREATE_URI env var and user config create-uri are set", + should: "use the env var (higher priority than user config)", + actual: paths.readmePath, + expected: path.join(envDir, "README.md"), + }); + } finally { + delete process.env.AIDD_CUSTOM_CREATE_URI; + if (envDir) await fs.remove(envDir); + if (configDir) await fs.remove(configDir); + } + }); +}); + +describe("resolveExtension - named scaffold path traversal", () => { + test("throws ScaffoldValidationError when type contains path traversal segments", async () => { + let error = null; + try { + await resolveExtension({ + type: "../../etc/passwd", + folder: "/tmp/test", + packageRoot: __dirname, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "a scaffold type with path traversal segments (../../etc/passwd)", + should: "throw ScaffoldValidationError before accessing the filesystem", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + + // The error must be about the invalid type, not about a missing manifest. + // If it mentions "SCAFFOLD-MANIFEST.yml not found", the check fires too late. + assert({ + given: "a scaffold type with path traversal segments", + should: "mention the invalid type in the error, not 'not found'", + actual: + typeof error?.message === "string" && + !error.message.includes("not found"), + expected: true, + }); + }); + + test('throws ScaffoldValidationError for type "."', async () => { + let error = null; + try { + await resolveExtension({ + type: ".", + folder: "/tmp/test", + packageRoot: __dirname, + log: noLog, + readConfigFn: async () => ({}), + }); + } catch (err) { + error = err; + } + + assert({ + given: 'type = "."', + should: "throw ScaffoldValidationError", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + + assert({ + given: 'type = "."', + should: + "not describe the error as 'outside the scaffolds directory' (it resolves to the root, not outside)", + actual: + typeof error?.message === "string" && + !error.message.includes("outside"), + expected: true, + }); + }); + + test("valid named type resolves without error", async () => { + let error = null; + try { + await resolveExtension({ + type: "scaffold-example", + folder: "/tmp/test", + packageRoot: __dirname, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "a valid named scaffold type", + should: "not throw a path traversal error", + actual: error?.cause?.code, + expected: undefined, + }); + }); +}); + +describe("resolveExtension - GitHub repo URL auto-resolution", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-gh-resolve-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + const mockResolveRelease = async () => + "https://api.github.com/repos/org/repo/tarball/v1.0.0"; + + test("calls resolveRelease for bare https://github.com/owner/repo URLs", async () => { + const resolved = []; + const trackingResolve = async (url) => { + resolved.push(url); + return "https://api.github.com/repos/org/repo/tarball/v1.0.0"; + }; + + await resolveExtension({ + type: "https://github.com/org/repo", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + resolveRelease: trackingResolve, + log: noLog, + }); + + assert({ + given: "a bare https://github.com/owner/repo URL", + should: "call resolveRelease with the repo URL", + actual: resolved[0], + expected: "https://github.com/org/repo", + }); + }); + + test("downloads the resolved tarball URL, not the bare repo URL", async () => { + const downloaded = []; + const trackingDownload = async (url, dest) => { + downloaded.push(url); + await mockDownload(url, dest); + }; + + await resolveExtension({ + type: "https://github.com/org/repo", + folder: tempDir, + confirm: noConfirm, + download: trackingDownload, + resolveRelease: mockResolveRelease, + log: noLog, + }); + + assert({ + given: "a bare GitHub repo URL with a resolved tarball", + should: "download the tarball URL returned by resolveRelease", + actual: downloaded[0], + expected: "https://api.github.com/repos/org/repo/tarball/v1.0.0", + }); + }); + + test("does not call resolveRelease for direct tarball URLs", async () => { + const resolved = []; + const trackingResolve = async (url) => { + resolved.push(url); + return url; + }; + + await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + resolveRelease: trackingResolve, + log: noLog, + }); + + assert({ + given: "a direct tarball URL (not a bare GitHub repo URL)", + should: "not call resolveRelease", + actual: resolved.length, + expected: 0, + }); + }); + + test("throws ScaffoldNetworkError when resolveRelease throws", async () => { + const failingResolve = async () => { + throw new Error("No releases found"); + }; + + let error = null; + try { + await resolveExtension({ + type: "https://github.com/org/repo", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + resolveRelease: failingResolve, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "a GitHub repo URL with no releases", + should: "throw ScaffoldNetworkError", + actual: error?.cause?.code, + expected: "SCAFFOLD_NETWORK_ERROR", + }); + }); +}); + +describe("resolveExtension - HTTPS enforcement", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-https-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("throws ScaffoldValidationError for plain http:// URIs", async () => { + let error = null; + try { + await resolveExtension({ + type: "http://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "a plain http:// scaffold URI", + should: "throw ScaffoldValidationError before any network request", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("includes the URI and https:// instruction in the error message for http://", async () => { + let error = null; + try { + await resolveExtension({ + type: "http://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "a plain http:// scaffold URI", + should: "name the rejected URI and instruct to use https://", + actual: + error?.message.includes("http://example.com/scaffold.tar.gz") && + error?.message.includes("https://"), + expected: true, + }); + }); + + test("does not throw for https:// URIs", async () => { + let error = null; + try { + await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "an https:// scaffold URI", + should: "not throw a validation error", + actual: error?.cause?.code, + expected: undefined, + }); + }); + + test("returns downloaded:true for https:// scaffolds", async () => { + const paths = await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: mockDownload, + log: noLog, + }); + + assert({ + given: "an https:// scaffold URI", + should: "return downloaded:true", + actual: paths.downloaded, + expected: true, + }); + }); +}); + +describe("resolveExtension - manifest existence validation", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-manifest-check-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("throws ScaffoldValidationError when file:// scaffold has no SCAFFOLD-MANIFEST.yml", async () => { + // tempDir has no SCAFFOLD-MANIFEST.yml + const uri = `file://${tempDir}`; + + let error = null; + try { + await resolveExtension({ type: uri, folder: "/tmp/test", log: noLog }); + } catch (err) { + error = err; + } + + assert({ + given: "a file:// scaffold directory with no SCAFFOLD-MANIFEST.yml", + should: "throw ScaffoldValidationError before returning paths", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("includes the missing path in the error message for file:// scaffolds", async () => { + const uri = `file://${tempDir}`; + + let error = null; + try { + await resolveExtension({ type: uri, folder: "/tmp/test", log: noLog }); + } catch (err) { + error = err; + } + + assert({ + given: "a file:// scaffold directory with no SCAFFOLD-MANIFEST.yml", + should: "include SCAFFOLD-MANIFEST.yml in the error message", + actual: + typeof error?.message === "string" && + error.message.includes("SCAFFOLD-MANIFEST.yml"), + expected: true, + }); + }); + + test("throws ScaffoldValidationError when downloaded HTTP scaffold has no SCAFFOLD-MANIFEST.yml", async () => { + // mockDownload that extracts NO manifest file + const downloadWithoutManifest = async (_url, destPath) => { + await fs.ensureDir(destPath); + await fs.writeFile(path.join(destPath, "README.md"), "# No Manifest"); + }; + + let error = null; + try { + await resolveExtension({ + type: "https://example.com/scaffold.tar.gz", + folder: tempDir, + confirm: noConfirm, + download: downloadWithoutManifest, + log: noLog, + }); + } catch (err) { + error = err; + } + + assert({ + given: "an HTTP scaffold download that contains no SCAFFOLD-MANIFEST.yml", + should: "throw ScaffoldValidationError mentioning the URI", + actual: + error?.cause?.code === "SCAFFOLD_VALIDATION_ERROR" && + typeof error?.message === "string" && + error.message.includes("https://example.com/scaffold"), + expected: true, + }); + }); +}); + +describe("defaultResolveRelease - GITHUB_TOKEN auth and error messages", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.GITHUB_TOKEN; + vi.restoreAllMocks(); + }); + + test("includes Authorization header when GITHUB_TOKEN is set", async () => { + process.env.GITHUB_TOKEN = "test-token-123"; + const capturedHeaders = {}; + globalThis.fetch = async (_url, opts = {}) => { + Object.assign(capturedHeaders, opts.headers || {}); + return { + ok: true, + json: async () => ({ + tarball_url: "https://api.github.com/repos/org/repo/tarball/v1.0.0", + }), + }; + }; + + await defaultResolveRelease("https://github.com/org/repo"); + + assert({ + given: "GITHUB_TOKEN is set in the environment", + should: "include Authorization: Bearer header in the GitHub API request", + actual: capturedHeaders.Authorization, + expected: "Bearer test-token-123", + }); + }); + + test("does not include Authorization header when GITHUB_TOKEN is not set", async () => { + delete process.env.GITHUB_TOKEN; + const capturedHeaders = {}; + globalThis.fetch = async (_url, opts = {}) => { + Object.assign(capturedHeaders, opts.headers || {}); + return { + ok: true, + json: async () => ({ + tarball_url: "https://api.github.com/repos/org/repo/tarball/v1.0.0", + }), + }; + }; + + await defaultResolveRelease("https://github.com/org/repo"); + + assert({ + given: "GITHUB_TOKEN is not set", + should: "not include Authorization header", + actual: "Authorization" in capturedHeaders, + expected: false, + }); + }); + + test("error for 403 mentions rate limit and GITHUB_TOKEN", async () => { + globalThis.fetch = async () => ({ ok: false, status: 403 }); + + let error = null; + try { + await defaultResolveRelease("https://github.com/org/repo"); + } catch (err) { + error = err; + } + + assert({ + given: "GitHub API returns 403", + should: "mention rate limit in the error", + actual: + typeof error?.message === "string" && + error.message.toLowerCase().includes("rate limit"), + expected: true, + }); + + assert({ + given: "GitHub API returns 403", + should: "mention GITHUB_TOKEN in the error", + actual: + typeof error?.message === "string" && + error.message.includes("GITHUB_TOKEN"), + expected: true, + }); + }); + + test("error for 404 without GITHUB_TOKEN hints to set the token", async () => { + delete process.env.GITHUB_TOKEN; + globalThis.fetch = async () => ({ ok: false, status: 404 }); + + let error = null; + try { + await defaultResolveRelease("https://github.com/org/repo"); + } catch (err) { + error = err; + } + + assert({ + given: "GitHub API returns 404 and GITHUB_TOKEN is not set", + should: "include GITHUB_TOKEN hint in the error", + actual: + typeof error?.message === "string" && + error.message.includes("GITHUB_TOKEN"), + expected: true, + }); + }); + + test("error for 404 with GITHUB_TOKEN set does not include token hint", async () => { + process.env.GITHUB_TOKEN = "test-token"; + globalThis.fetch = async () => ({ ok: false, status: 404 }); + + let error = null; + try { + await defaultResolveRelease("https://github.com/org/repo"); + } catch (err) { + error = err; + } + + assert({ + given: "GitHub API returns 404 and GITHUB_TOKEN is set", + should: + "not include set-GITHUB_TOKEN hint (token is already set; repo simply does not exist or has no releases)", + actual: + typeof error?.message === "string" && + !error.message.includes("set GITHUB_TOKEN"), + expected: true, + }); + }); +}); + +describe("defaultDownloadAndExtract - GITHUB_TOKEN auth", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.GITHUB_TOKEN; + }); + + test("includes Authorization header for api.github.com URLs when GITHUB_TOKEN is set", async () => { + process.env.GITHUB_TOKEN = "test-token-123"; + const capturedHeaders = {}; + // Empty buffer — tar will fail, but we only care that fetch was called with auth + globalThis.fetch = async (_url, opts = {}) => { + Object.assign(capturedHeaders, opts.headers || {}); + return { ok: true, arrayBuffer: async () => new ArrayBuffer(0) }; + }; + + try { + await defaultDownloadAndExtract( + "https://api.github.com/repos/org/repo/tarball/v1.0.0", + "/tmp/aidd-test-auth-dest", + ); + } catch { + // tar failure on empty buffer is expected — headers are what we're testing + } + + assert({ + given: "GITHUB_TOKEN is set and URL hostname is api.github.com", + should: "include Authorization: Bearer header in the download request", + actual: capturedHeaders.Authorization, + expected: "Bearer test-token-123", + }); + }); + + test("does not include Authorization header for third-party URLs when GITHUB_TOKEN is set", async () => { + process.env.GITHUB_TOKEN = "test-token-123"; + const capturedHeaders = {}; + globalThis.fetch = async (_url, opts = {}) => { + Object.assign(capturedHeaders, opts.headers || {}); + return { ok: true, arrayBuffer: async () => new ArrayBuffer(0) }; + }; + + try { + await defaultDownloadAndExtract( + "https://example.com/scaffold.tar.gz", + "/tmp/aidd-test-auth-dest", + ); + } catch { + // tar failure expected — headers are what we're testing + } + + assert({ + given: "GITHUB_TOKEN is set and URL is a third-party host", + should: + "not include Authorization header (token must not leak to other servers)", + actual: capturedHeaders.Authorization, + expected: undefined, + }); + }); +}); diff --git a/lib/scaffold-runner.js b/lib/scaffold-runner.js new file mode 100644 index 00000000..2c5d704a --- /dev/null +++ b/lib/scaffold-runner.js @@ -0,0 +1,133 @@ +// @ts-nocheck +import { spawn } from "child_process"; +import { createError } from "error-causes"; +import fs from "fs-extra"; +import yaml from "js-yaml"; + +import { + ScaffoldStepError, + ScaffoldValidationError, +} from "./scaffold-errors.js"; + +// Accepts a string (shell command) or [cmd, ...args] array (no-shell spawn). +// Using an array avoids shell injection for untrusted input such as prompt text. +const defaultExecStep = (commandOrArgs, cwd) => { + const isArray = Array.isArray(commandOrArgs); + const [cmd, ...args] = isArray ? commandOrArgs : [commandOrArgs]; + const display = isArray ? commandOrArgs.join(" ") : commandOrArgs; + + return new Promise((resolve, reject) => { + console.log(`> ${display}`); + const child = spawn(cmd, args, { + cwd, + shell: !isArray, + stdio: "inherit", + }); + child.on("close", (code) => { + if (code !== 0) { + reject( + createError({ + ...ScaffoldStepError, + message: `Command failed with exit code ${code}: ${display}`, + }), + ); + } else { + resolve(); + } + }); + child.on("error", (err) => { + reject( + createError({ + ...ScaffoldStepError, + cause: err, + message: `Failed to spawn command: ${display}`, + }), + ); + }); + }); +}; + +const KNOWN_STEP_KEYS = new Set(["run", "prompt"]); + +const parseManifest = (content) => { + // Use JSON_SCHEMA to restrict parsing to plain JSON types (strings, numbers, + // booleans, null, arrays, objects). This prevents YAML-specific extensions + // like !!binary or !!timestamp from being accepted in untrusted manifests. + const data = yaml.load(content, { schema: yaml.JSON_SCHEMA }); + const steps = data?.steps; + + // No steps key — treat as an empty manifest (backward-compatible default). + if (steps === undefined || steps === null) return []; + + // Validate that steps is an array. A string or plain object means the YAML + // was written incorrectly and would silently iterate unexpected values. + if (!Array.isArray(steps)) { + throw createError({ + ...ScaffoldValidationError, + message: `Manifest 'steps' must be an array, got ${typeof steps}`, + }); + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + if (step === null || typeof step !== "object" || Array.isArray(step)) { + throw createError({ + ...ScaffoldValidationError, + message: `Manifest step ${i + 1} must be an object, got ${ + step === null ? "null" : Array.isArray(step) ? "array" : typeof step + }`, + }); + } + + const knownKeys = Object.keys(step).filter((k) => KNOWN_STEP_KEYS.has(k)); + + if (knownKeys.length === 0) { + const found = Object.keys(step).join(", ") || "(empty)"; + throw createError({ + ...ScaffoldValidationError, + message: `Manifest step ${i + 1} has no recognized keys (run, prompt). Found: ${found}`, + }); + } + + if (knownKeys.length > 1) { + throw createError({ + ...ScaffoldValidationError, + message: `Manifest step ${i + 1} has ambiguous keys: ${knownKeys.join(" and ")}. Each step must have exactly one of: run, prompt`, + }); + } + + const key = knownKeys[0]; + const value = step[key]; + if (typeof value !== "string") { + throw createError({ + ...ScaffoldValidationError, + message: `Manifest step ${i + 1} '${key}' must be a string, got ${Array.isArray(value) ? "array" : typeof value}`, + }); + } + } + + return steps; +}; + +const runManifest = async ({ + manifestPath, + folder, + agent = "claude", + execStep = defaultExecStep, +}) => { + const content = await fs.readFile(manifestPath, "utf-8"); + const steps = parseManifest(content); + + for (const step of steps) { + if (step.run !== undefined) { + // run steps are shell commands written by the scaffold author + await execStep(step.run, folder); + } else if (step.prompt !== undefined) { + // prompt steps pass the text as a separate arg to avoid shell injection + await execStep([agent, step.prompt], folder); + } + } +}; + +export { parseManifest, runManifest }; diff --git a/lib/scaffold-runner.test.js b/lib/scaffold-runner.test.js new file mode 100644 index 00000000..9c25fac7 --- /dev/null +++ b/lib/scaffold-runner.test.js @@ -0,0 +1,535 @@ +// @ts-nocheck +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { parseManifest, runManifest } from "./scaffold-runner.js"; + +describe("parseManifest", () => { + test("parses run steps from YAML", () => { + const yaml = "steps:\n - run: npm init -y\n"; + + const steps = parseManifest(yaml); + + assert({ + given: "YAML content with a run step", + should: "return array with run step", + actual: steps, + expected: [{ run: "npm init -y" }], + }); + }); + + test("parses prompt steps from YAML", () => { + const yaml = "steps:\n - prompt: Set up the project\n"; + + const steps = parseManifest(yaml); + + assert({ + given: "YAML content with a prompt step", + should: "return array with prompt step", + actual: steps, + expected: [{ prompt: "Set up the project" }], + }); + }); + + test("parses multiple mixed steps", () => { + const yaml = + "steps:\n - run: npm init -y\n - prompt: Configure the project\n - run: npm install vitest\n"; + + const steps = parseManifest(yaml); + + assert({ + given: "YAML with multiple mixed steps", + should: "return all steps in order", + actual: steps, + expected: [ + { run: "npm init -y" }, + { prompt: "Configure the project" }, + { run: "npm install vitest" }, + ], + }); + }); + + test("returns empty array when no steps defined", () => { + const yaml = "steps: []\n"; + + const steps = parseManifest(yaml); + + assert({ + given: "YAML with empty steps array", + should: "return empty array", + actual: steps, + expected: [], + }); + }); + + test("returns empty array when steps key is absent", () => { + const yaml = "description: a scaffold\n"; + + const steps = parseManifest(yaml); + + assert({ + given: "YAML without a steps key", + should: "return empty array", + actual: steps, + expected: [], + }); + }); + + test("parses steps when manifest begins with YAML document-start marker", () => { + const yaml = "---\nsteps:\n - run: npm init -y\n"; + + const steps = parseManifest(yaml); + + assert({ + given: "YAML with a leading --- document-start marker", + should: "parse steps correctly without silently dropping them", + actual: steps, + expected: [{ run: "npm init -y" }], + }); + }); + + test("throws ScaffoldValidationError when steps is a string instead of array", () => { + const yaml = "steps: not-an-array\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "YAML where steps is a string", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("throws ScaffoldValidationError when steps is an object instead of array", () => { + const yaml = "steps:\n run: npm init\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "YAML where steps is a plain object (not an array)", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("throws ScaffoldValidationError when a step is a string instead of object", () => { + const yaml = "steps:\n - just-a-string\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "YAML where a step item is a bare string", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("throws ScaffoldValidationError when a step has no recognized keys", () => { + const yaml = "steps:\n - unknown: value\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "YAML where a step has no run or prompt key", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("throws ScaffoldValidationError with a descriptive message when steps is not an array", () => { + const yaml = "steps: 42\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "YAML where steps is a number", + should: "include 'steps' and the actual type in the error message", + actual: + typeof error?.message === "string" && + error.message.includes("steps") && + error.message.includes("number"), + expected: true, + }); + }); + + test("throws ScaffoldValidationError when a step has both run and prompt keys", () => { + const yaml = "steps:\n - run: npm init -y\n prompt: also do this\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a step with both run and prompt keys", + should: "throw ScaffoldValidationError", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("includes both key names in the error message for ambiguous steps", () => { + const yaml = "steps:\n - run: npm init -y\n prompt: also do this\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a step with both run and prompt keys", + should: "name both conflicting keys in the error message", + actual: + typeof error?.message === "string" && + error.message.includes("run") && + error.message.includes("prompt"), + expected: true, + }); + }); + + test("throws a parse error when YAML uses JS-specific type tags", () => { + // !!js/regexp is a YAML extension that executes JS — safe schema must block it + const maliciousYaml = "steps:\n - run: !!js/regexp /evil/gi\n"; + + let error = null; + try { + parseManifest(maliciousYaml); + } catch (err) { + error = err; + } + + assert({ + given: "YAML with a JS-specific !!js/regexp tag", + should: "throw a parse error rather than silently executing it", + actual: error !== null, + expected: true, + }); + }); + + test("throws ScaffoldValidationError when run value is a number", () => { + const yaml = "steps:\n - run: 123\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a step where run is a number", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("throws ScaffoldValidationError when run value is an array", () => { + const yaml = "steps:\n - run:\n - a\n - b\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a step where run is an array", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("throws ScaffoldValidationError when prompt value is not a string", () => { + const yaml = "steps:\n - prompt: 42\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a step where prompt is a number", + should: "throw an error with SCAFFOLD_VALIDATION_ERROR code", + actual: error?.cause?.code, + expected: "SCAFFOLD_VALIDATION_ERROR", + }); + }); + + test("includes step number, key name, and actual type in error message for non-string step value", () => { + const yaml = "steps:\n - run: 123\n"; + + let error = null; + try { + parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a step where run is a number", + should: + "include the step number, key name, and actual type in the error message", + actual: + typeof error?.message === "string" && + error.message.includes("1") && + error.message.includes("run") && + error.message.includes("number"), + expected: true, + }); + }); + + test("parses valid manifests with plain types without error under safe schema", () => { + const yaml = + "steps:\n - run: npm install\n - prompt: Set up the project\n"; + + let error = null; + let steps = null; + try { + steps = parseManifest(yaml); + } catch (err) { + error = err; + } + + assert({ + given: "a valid manifest with only plain string steps", + should: "parse without error under the safe schema", + actual: error === null && Array.isArray(steps) && steps.length === 2, + expected: true, + }); + }); +}); + +describe("runManifest", () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-runner-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("executes run steps as shell commands in folder", async () => { + const executed = []; + const mockExecStep = async (command, cwd) => { + executed.push({ command, cwd }); + }; + + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps:\n - run: npm init -y\n"); + + await runManifest({ + manifestPath, + folder: tempDir, + execStep: mockExecStep, + }); + + assert({ + given: "a manifest with a run step", + should: "execute the run command in the target folder", + actual: executed[0], + expected: { command: "npm init -y", cwd: tempDir }, + }); + }); + + test("executes prompt steps via agent CLI", async () => { + const executed = []; + const mockExecStep = async (command, cwd) => { + executed.push({ command, cwd }); + }; + + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile( + manifestPath, + "steps:\n - prompt: Set up the project\n", + ); + + await runManifest({ + manifestPath, + folder: tempDir, + agent: "claude", + execStep: mockExecStep, + }); + + assert({ + given: "a manifest with a prompt step", + should: "invoke the agent CLI as the first element of the command array", + actual: + Array.isArray(executed[0].command) && + executed[0].command[0] === "claude", + expected: true, + }); + + assert({ + given: "a manifest with a prompt step", + should: + "pass the prompt text as a separate argument (no shell injection)", + actual: + Array.isArray(executed[0].command) && + executed[0].command[1] === "Set up the project", + expected: true, + }); + }); + + test("executes steps in the target folder", async () => { + const executed = []; + const mockExecStep = async (command, cwd) => { + executed.push({ command, cwd }); + }; + + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile( + manifestPath, + "steps:\n - run: echo hello\n - run: echo world\n", + ); + + await runManifest({ + manifestPath, + folder: tempDir, + execStep: mockExecStep, + }); + + assert({ + given: "multiple run steps", + should: "execute all steps in the target folder", + actual: executed.every(({ cwd }) => cwd === tempDir), + expected: true, + }); + }); + + test("executes steps in order", async () => { + const executed = []; + const mockExecStep = async (command, cwd) => { + executed.push({ command, cwd }); + }; + + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile( + manifestPath, + "steps:\n - run: first\n - run: second\n - run: third\n", + ); + + await runManifest({ + manifestPath, + folder: tempDir, + execStep: mockExecStep, + }); + + assert({ + given: "multiple run steps", + should: "execute them in manifest order as shell strings", + actual: executed.map(({ command }) => command), + expected: ["first", "second", "third"], + }); + }); + + test("halts execution when a step fails", async () => { + const executed = []; + const mockExecStep = async (command) => { + const label = Array.isArray(command) ? command.join(" ") : command; + executed.push(label); + if (label === "failing-command") { + throw new Error("Command failed: failing-command"); + } + }; + + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile( + manifestPath, + "steps:\n - run: failing-command\n - run: should-not-run\n", + ); + + let errorThrown = null; + try { + await runManifest({ + manifestPath, + folder: tempDir, + execStep: mockExecStep, + }); + } catch (err) { + errorThrown = err; + } + + assert({ + given: "a step that fails", + should: "throw an error and halt execution", + actual: errorThrown !== null, + expected: true, + }); + + assert({ + given: "a step that fails", + should: "not execute subsequent steps", + actual: executed.includes("should-not-run"), + expected: false, + }); + }); + + test("uses claude as the default agent for prompt steps", async () => { + const executed = []; + const mockExecStep = async (command, cwd) => { + executed.push({ command, cwd }); + }; + + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps:\n - prompt: do something\n"); + + await runManifest({ + manifestPath, + folder: tempDir, + execStep: mockExecStep, + }); + + assert({ + given: "a prompt step with no agent specified", + should: "use claude as the default agent in the command array", + actual: + Array.isArray(executed[0].command) && + executed[0].command[0] === "claude", + expected: true, + }); + }); +}); diff --git a/lib/scaffold-verifier.js b/lib/scaffold-verifier.js new file mode 100644 index 00000000..ab33c0c5 --- /dev/null +++ b/lib/scaffold-verifier.js @@ -0,0 +1,37 @@ +import fs from "fs-extra"; + +import { parseManifest } from "./scaffold-runner.js"; + +// Verifies that a resolved scaffold conforms to all structural requirements +// before any steps are executed. Returns { valid, errors } so callers can +// report all problems at once rather than failing one at a time. +/** @param {{ manifestPath: string }} options */ +const verifyScaffold = async ({ manifestPath }) => { + const errors = []; + + const manifestExists = await fs.pathExists(manifestPath); + if (!manifestExists) { + errors.push(`SCAFFOLD-MANIFEST.yml not found: ${manifestPath}`); + return { errors, valid: false }; + } + + let steps; + try { + const content = await fs.readFile(manifestPath, "utf-8"); + steps = parseManifest(content); + } catch (err) { + errors.push( + `Invalid manifest: ${err instanceof Error ? err.message : String(err)}`, + ); + return { errors, valid: false }; + } + + if (steps.length === 0) { + errors.push("Manifest contains no steps — scaffold would do nothing"); + return { errors, valid: false }; + } + + return { errors: [], valid: true }; +}; + +export { verifyScaffold }; diff --git a/lib/scaffold-verifier.test.js b/lib/scaffold-verifier.test.js new file mode 100644 index 00000000..105a7244 --- /dev/null +++ b/lib/scaffold-verifier.test.js @@ -0,0 +1,152 @@ +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { verifyScaffold } from "./scaffold-verifier.js"; + +describe("verifyScaffold", () => { + /** @type {string} */ + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-verifier-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("returns valid=true for a well-formed manifest", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps:\n - run: npm init -y\n"); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest with one valid run step", + should: "return valid true with no errors", + actual: result, + expected: { valid: true, errors: [] }, + }); + }); + + test("returns valid=false when manifest file does not exist", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a path where no manifest file exists", + should: "return valid false", + actual: result.valid, + expected: false, + }); + + assert({ + given: "a path where no manifest file exists", + should: "include the manifest path in the error message", + actual: result.errors.some((e) => e.includes(manifestPath)), + expected: true, + }); + }); + + test("returns valid=false when steps is not an array", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps: not-an-array\n"); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest where steps is a string", + should: "return valid false", + actual: result.valid, + expected: false, + }); + + assert({ + given: "a manifest where steps is a string", + should: "include an error message mentioning the problem", + actual: result.errors.length > 0, + expected: true, + }); + }); + + test("returns valid=false when a step has no recognized keys", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps:\n - unknown: value\n"); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest with an unrecognized step shape", + should: "return valid false", + actual: result.valid, + expected: false, + }); + }); + + test("returns valid=false when manifest has no steps", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps: []\n"); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest with an empty steps array", + should: "return valid false (scaffold would do nothing)", + actual: result.valid, + expected: false, + }); + }); + + test("returns valid=true for manifest with prompt steps", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile( + manifestPath, + "steps:\n - prompt: Set up the project\n", + ); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest with a valid prompt step", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("returns valid=true for manifest with mixed run and prompt steps", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile( + manifestPath, + "steps:\n - run: npm init -y\n - prompt: Configure the project\n", + ); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest with mixed run and prompt steps", + should: "return valid true", + actual: result.valid, + expected: true, + }); + }); + + test("returns valid=false and includes error message when a step item is a bare string", async () => { + const manifestPath = path.join(tempDir, "SCAFFOLD-MANIFEST.yml"); + await fs.writeFile(manifestPath, "steps:\n - just-a-string\n"); + + const result = await verifyScaffold({ manifestPath }); + + assert({ + given: "a manifest where a step is a bare string", + should: "return valid false with an error message", + actual: result.valid === false && result.errors.length > 0, + expected: true, + }); + }); +}); diff --git a/lib/scaffold-verify-cmd.js b/lib/scaffold-verify-cmd.js new file mode 100644 index 00000000..25040f05 --- /dev/null +++ b/lib/scaffold-verify-cmd.js @@ -0,0 +1,59 @@ +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; + +import { AIDD_HOME } from "./aidd-config.js"; +import { resolveExtension as defaultResolveExtension } from "./scaffold-resolver.js"; +import { verifyScaffold as defaultVerifyScaffold } from "./scaffold-verifier.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const VERIFY_SCAFFOLD_DIR = path.join(AIDD_HOME, "scaffold"); + +/** + * Resolve and verify a scaffold manifest. + * All IO dependencies are injectable for unit testing. + * + * HTTP/HTTPS scaffolds are downloaded to ~/.aidd/scaffold/ and cleaned up + * automatically (in a finally block) whether verification succeeds or fails. + * + * Returns { valid: boolean, errors: string[] }. + * Throws on resolution errors (cancelled, network, etc.) — caller handles display. + * + * @param {object} [options] + * @param {string} [options.type] + * @param {string} [options.packageRoot] + * @param {Function} [options.resolveExtensionFn] + * @param {Function} [options.verifyScaffoldFn] + * @param {Function} [options.cleanupFn] + */ +const runVerifyScaffold = async ({ + type, + packageRoot = __dirname, + resolveExtensionFn = defaultResolveExtension, + verifyScaffoldFn = defaultVerifyScaffold, + cleanupFn = async () => fs.remove(VERIFY_SCAFFOLD_DIR), +} = {}) => { + // Use the user's home directory as the scaffold root so that downloaded + // scaffolds land in ~/.aidd/scaffold/ (not in the current project directory, + // which may not even exist yet at verification time). + const folder = os.homedir(); + + try { + const paths = await resolveExtensionFn({ + folder, + // Suppress README output — only validation feedback is relevant here. + log: () => {}, + packageRoot, + type, + }); + + return await verifyScaffoldFn({ manifestPath: paths.manifestPath }); + } finally { + await cleanupFn(); + } +}; + +export { runVerifyScaffold, VERIFY_SCAFFOLD_DIR }; diff --git a/lib/scaffold-verify-cmd.test.js b/lib/scaffold-verify-cmd.test.js new file mode 100644 index 00000000..053a18fd --- /dev/null +++ b/lib/scaffold-verify-cmd.test.js @@ -0,0 +1,184 @@ +// @ts-nocheck +import os from "os"; +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +import { runVerifyScaffold } from "./scaffold-verify-cmd.js"; + +describe("runVerifyScaffold", () => { + const validManifestPath = "/fake/SCAFFOLD-MANIFEST.yml"; + const noCleanup = async () => {}; + + const mockResolvePaths = async () => ({ + downloaded: false, + manifestPath: validManifestPath, + readmePath: "/fake/README.md", + }); + + const mockVerifyValid = async () => ({ valid: true, errors: [] }); + const mockVerifyInvalid = async () => ({ + valid: false, + errors: ["steps must be an array"], + }); + + test("returns valid result when scaffold passes verification", async () => { + const result = await runVerifyScaffold({ + type: "scaffold-example", + resolveExtensionFn: mockResolvePaths, + verifyScaffoldFn: mockVerifyValid, + cleanupFn: noCleanup, + }); + + assert({ + given: "a scaffold that passes verification", + should: "return { valid: true }", + actual: result.valid, + expected: true, + }); + }); + + test("returns invalid result with errors when scaffold fails verification", async () => { + const result = await runVerifyScaffold({ + type: "scaffold-example", + resolveExtensionFn: mockResolvePaths, + verifyScaffoldFn: mockVerifyInvalid, + cleanupFn: noCleanup, + }); + + assert({ + given: "a scaffold that fails verification", + should: "return { valid: false }", + actual: result.valid, + expected: false, + }); + + assert({ + given: "a scaffold that fails verification", + should: "include error messages", + actual: result.errors.length > 0, + expected: true, + }); + }); + + test("passes manifestPath from resolveExtension to verifyScaffold", async () => { + const calls = []; + const trackingVerify = async ({ manifestPath }) => { + calls.push({ manifestPath }); + return { valid: true, errors: [] }; + }; + + await runVerifyScaffold({ + type: "scaffold-example", + resolveExtensionFn: mockResolvePaths, + verifyScaffoldFn: trackingVerify, + cleanupFn: noCleanup, + }); + + assert({ + given: "a resolved manifest path", + should: "pass it to verifyScaffold", + actual: calls[0]?.manifestPath, + expected: validManifestPath, + }); + }); + + test("passes os.homedir() as folder to resolveExtension", async () => { + const calls = []; + const trackingResolve = async (opts) => { + calls.push(opts); + return { + downloaded: false, + manifestPath: validManifestPath, + readmePath: "/fake/README.md", + }; + }; + + await runVerifyScaffold({ + type: "scaffold-example", + resolveExtensionFn: trackingResolve, + verifyScaffoldFn: mockVerifyValid, + cleanupFn: noCleanup, + }); + + assert({ + given: "verify-scaffold resolving an extension", + should: "use os.homedir() as the folder (not process.cwd())", + actual: calls[0]?.folder, + expected: os.homedir(), + }); + }); + + test("calls cleanupFn after successful verification", async () => { + let cleanupCalled = false; + const trackingCleanup = async () => { + cleanupCalled = true; + }; + + await runVerifyScaffold({ + type: "scaffold-example", + resolveExtensionFn: mockResolvePaths, + verifyScaffoldFn: mockVerifyValid, + cleanupFn: trackingCleanup, + }); + + assert({ + given: "successful verification", + should: "call cleanupFn to remove downloaded scaffold files", + actual: cleanupCalled, + expected: true, + }); + }); + + test("calls cleanupFn even when resolveExtension throws", async () => { + let cleanupCalled = false; + const trackingCleanup = async () => { + cleanupCalled = true; + }; + const failingResolve = async () => { + throw new Error("cancelled by user"); + }; + + try { + await runVerifyScaffold({ + type: "https://bad.example.com/scaffold.tar.gz", + resolveExtensionFn: failingResolve, + verifyScaffoldFn: mockVerifyValid, + cleanupFn: trackingCleanup, + }); + } catch { + // expected — we're only checking cleanup + } + + assert({ + given: "resolveExtension that throws", + should: "still call cleanupFn before propagating the error", + actual: cleanupCalled, + expected: true, + }); + }); + + test("propagates errors thrown by resolveExtension", async () => { + const failingResolve = async () => { + throw new Error("cancelled by user"); + }; + + let error = null; + try { + await runVerifyScaffold({ + type: "https://bad.example.com/scaffold.tar.gz", + resolveExtensionFn: failingResolve, + verifyScaffoldFn: mockVerifyValid, + cleanupFn: noCleanup, + }); + } catch (err) { + error = err; + } + + assert({ + given: "a resolveExtension that throws", + should: "propagate the error to the caller", + actual: error !== null, + expected: true, + }); + }); +}); diff --git a/lib/symlinks.js b/lib/symlinks.js new file mode 100644 index 00000000..afe28337 --- /dev/null +++ b/lib/symlinks.js @@ -0,0 +1,60 @@ +import path from "path"; +import { createError } from "error-causes"; +import fs from "fs-extra"; + +// Reuse the same name/code strings as cli-core.js so handleCliErrors dispatches correctly. +const ValidationError = { + code: "VALIDATION_ERROR", + message: "Input validation failed", + name: "ValidationError", +}; +const FileSystemError = { + code: "FILESYSTEM_ERROR", + message: "File system operation failed", + name: "FileSystemError", +}; + +/** + * Create a symlink at `/` pointing to the relative path `ai`. + * The same function handles `.cursor`, `.claude`, or any future editor symlink. + * + * Returns an async thunk so call-sites can compose it with other thunks: + * await createSymlink({ name: '.claude', targetBase, force })() + * + * @param {{ name: string, targetBase: string, force?: boolean }} options + */ +const createSymlink = + ({ name, targetBase, force = false }) => + async () => { + const symlinkPath = path.join(targetBase, name); + const aiRelativePath = "ai"; + + try { + const exists = await fs.pathExists(symlinkPath); + + if (exists) { + if (!force) { + throw createError({ + ...ValidationError, + message: `${name} already exists (use --force to overwrite)`, + }); + } + await fs.remove(symlinkPath); + } + + await fs.symlink(aiRelativePath, symlinkPath); + } catch (/** @type {any} */ originalError) { + // Validation errors are already structured — re-throw as-is. + if (originalError.cause?.code === "VALIDATION_ERROR") { + throw originalError; + } + + throw createError({ + ...FileSystemError, + cause: originalError, + message: `Failed to create ${name} symlink: ${originalError.message}`, + }); + } + }; + +export { createSymlink }; diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js new file mode 100644 index 00000000..612474bc --- /dev/null +++ b/lib/symlinks.test.js @@ -0,0 +1,315 @@ +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { executeClone } from "./cli-core.js"; +import { createSymlink } from "./symlinks.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ─── error dispatch ─────────────────────────────────────────────────────────── + +describe("createSymlink error dispatch", () => { + const tempTestDir = path.join(__dirname, "temp-symlink-errors-test"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("existing symlink target without force should set cause.name to ValidationError", async () => { + await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); + + try { + await createSymlink({ + name: ".cursor", + targetBase: tempTestDir, + force: false, + })(); + assert({ + given: "a symlink target that already exists and force is false", + should: "throw an error", + actual: false, + expected: true, + }); + } catch (e) { + const error = /** @type {any} */ (e); + assert({ + given: "a symlink target that already exists and force is false", + should: + "set error.cause.name to 'ValidationError' for handleCliErrors dispatch", + actual: error.cause.name, + expected: "ValidationError", + }); + } + }); + + test("existing symlink target without force should set cause.code to VALIDATION_ERROR", async () => { + await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); + + try { + await createSymlink({ + name: ".cursor", + targetBase: tempTestDir, + force: false, + })(); + assert({ + given: "a symlink target that already exists and force is false", + should: "throw an error", + actual: false, + expected: true, + }); + } catch (e) { + const error = /** @type {any} */ (e); + assert({ + given: "a symlink target that already exists and force is false", + should: "set error.cause.code to 'VALIDATION_ERROR'", + actual: error.cause.code, + expected: "VALIDATION_ERROR", + }); + } + }); +}); + +// ─── .cursor symlink ────────────────────────────────────────────────────────── + +describe("cursor symlink functionality", () => { + const tempTestDir = path.join(__dirname, "temp-symlinks-test"); + const cursorPath = path.join(tempTestDir, ".cursor"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("cursor option false should not create symlink", async () => { + await executeClone({ targetDirectory: tempTestDir, cursor: false }); + + assert({ + given: "cursor option is false", + should: "not create .cursor symlink", + actual: await fs.pathExists(cursorPath), + expected: false, + }); + }); + + test("cursor option true should create symlink to ai folder", async () => { + await executeClone({ targetDirectory: tempTestDir, cursor: true }); + + assert({ + given: "cursor option is true", + should: "create .cursor symlink", + actual: await fs.pathExists(cursorPath), + expected: true, + }); + }); + + test("created symlink should point to ai folder", async () => { + await executeClone({ targetDirectory: tempTestDir, cursor: true }); + + const symlinkStat = await fs.lstat(cursorPath); + const symlinkTarget = await fs.readlink(cursorPath); + + assert({ + given: "cursor symlink is created", + should: "be a symbolic link", + actual: symlinkStat.isSymbolicLink(), + expected: true, + }); + + assert({ + given: "cursor symlink is created", + should: "point to ai folder", + actual: symlinkTarget, + expected: "ai", + }); + }); + + test("dry run with cursor should show symlink creation", async () => { + const result = await executeClone({ + targetDirectory: tempTestDir, + cursor: true, + dryRun: true, + }); + + assert({ + given: "dry run mode with cursor option", + should: "not actually create symlink", + actual: await fs.pathExists(cursorPath), + expected: false, + }); + + assert({ + given: "dry run mode with cursor option", + should: "indicate success", + actual: result.success, + expected: true, + }); + }); + + test("existing .cursor file should be handled with force", async () => { + await fs.writeFile(cursorPath, "existing content"); + + await executeClone({ + targetDirectory: tempTestDir, + cursor: true, + force: true, + }); + + assert({ + given: "existing .cursor file with force option", + should: "replace with symlink", + actual: (await fs.lstat(cursorPath)).isSymbolicLink(), + expected: true, + }); + }); +}); + +// ─── .claude symlink ────────────────────────────────────────────────────────── + +describe("claude symlink functionality", () => { + const tempTestDir = path.join(__dirname, "temp-claude-test"); + const claudePath = path.join(tempTestDir, ".claude"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("claude option false should not create symlink", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: false }); + + assert({ + given: "claude option is false", + should: "not create .claude symlink", + actual: await fs.pathExists(claudePath), + expected: false, + }); + }); + + test("claude option true should create symlink to ai folder", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: true }); + + assert({ + given: "claude option is true", + should: "create .claude symlink", + actual: await fs.pathExists(claudePath), + expected: true, + }); + }); + + test("created .claude symlink should point to ai folder", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: true }); + + const symlinkStat = await fs.lstat(claudePath); + const symlinkTarget = await fs.readlink(claudePath); + + assert({ + given: ".claude symlink is created", + should: "be a symbolic link", + actual: symlinkStat.isSymbolicLink(), + expected: true, + }); + + assert({ + given: ".claude symlink is created", + should: "point to ai folder", + actual: symlinkTarget, + expected: "ai", + }); + }); + + test("dry run with claude should not create symlink", async () => { + const result = await executeClone({ + targetDirectory: tempTestDir, + claude: true, + dryRun: true, + }); + + assert({ + given: "dry run mode with claude option", + should: "not actually create symlink", + actual: await fs.pathExists(claudePath), + expected: false, + }); + + assert({ + given: "dry run mode with claude option", + should: "indicate success", + actual: result.success, + expected: true, + }); + }); + + test("existing .claude file should be handled with force", async () => { + await fs.writeFile(claudePath, "existing content"); + + await executeClone({ + targetDirectory: tempTestDir, + claude: true, + force: true, + }); + + assert({ + given: "existing .claude file with force option", + should: "replace with symlink", + actual: (await fs.lstat(claudePath)).isSymbolicLink(), + expected: true, + }); + }); +}); + +// ─── both symlinks together ─────────────────────────────────────────────────── + +describe("cursor and claude symlinks together", () => { + const tempTestDir = path.join(__dirname, "temp-both-symlinks-test"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("cursor and claude true should create both symlinks", async () => { + await executeClone({ + targetDirectory: tempTestDir, + cursor: true, + claude: true, + }); + + const cursorStat = await fs.lstat(path.join(tempTestDir, ".cursor")); + const claudeStat = await fs.lstat(path.join(tempTestDir, ".claude")); + + assert({ + given: "both cursor and claude options are true", + should: "create .cursor symlink", + actual: cursorStat.isSymbolicLink(), + expected: true, + }); + + assert({ + given: "both cursor and claude options are true", + should: "create .claude symlink", + actual: claudeStat.isSymbolicLink(), + expected: true, + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index ae7d52d1..c289a163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "error-causes": "^3.0.2", "fs-extra": "^11.1.1", "gray-matter": "^4.0.3", - "js-sha3": "^0.9.3" + "js-sha3": "^0.9.3", + "js-yaml": "^4.1.1" }, "bin": { "aidd": "bin/aidd.js" @@ -24,6 +25,7 @@ "devDependencies": { "@biomejs/biome": "2.3.12", "@types/fs-extra": "^11.0.4", + "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.3", "@vitest/coverage-v8": "^3.2.4", "doctoc": "^2.2.1", @@ -33,6 +35,9 @@ "typescript": "^5.9.3", "vitest": "^3.2.4" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { "better-auth": "^1.4.5" }, @@ -1935,6 +1940,13 @@ "@types/node": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonfile": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", @@ -2183,6 +2195,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -5074,6 +5092,18 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", diff --git a/package.json b/package.json index 1d6ff4c2..197f21e6 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "error-causes": "^3.0.2", "fs-extra": "^11.1.1", "gray-matter": "^4.0.3", - "js-sha3": "^0.9.3" + "js-sha3": "^0.9.3", + "js-yaml": "^4.1.1" }, "description": "The standard framework for AI Driven Development.", "devDependencies": { "@biomejs/biome": "2.3.12", "@types/fs-extra": "^11.0.4", + "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.3", "@vitest/coverage-v8": "^3.2.4", "doctoc": "^2.2.1", @@ -91,6 +93,9 @@ "toc": "doctoc README.md", "typecheck": "tsc --noEmit && echo 'Type check complete.'" }, + "engines": { + "node": ">=18" + }, "sideEffects": false, "type": "module", "version": "2.6.0" diff --git a/tasks/agents-md-type-declarations-epic.md b/tasks/agents-md-type-declarations-epic.md index fc1579d1..2ba3a46c 100644 --- a/tasks/agents-md-type-declarations-epic.md +++ b/tasks/agents-md-type-declarations-epic.md @@ -52,3 +52,22 @@ signature. entry in `missingDirectives`. - Given `appendDirectives` is called with an empty `missingDirectives` array, should append a block with no section content (i.e. sections string is empty). + +--- + +## Pre-commit sync of AGENTS.md and CLAUDE.md + +Keep `AGENTS.md` and `CLAUDE.md` at the repo root in sync with the current +`agentsMdContent` template so they never drift silently between manual `npx aidd` +runs. + +**Requirements**: +- Given a developer commits to this repo, should regenerate `AGENTS.md` and + `CLAUDE.md` from `agentsMdContent` if either file's content differs from the + template. +- Given `AGENTS.md` or `CLAUDE.md` does not exist, should create it with + `agentsMdContent`. +- Given `AGENTS.md` or `CLAUDE.md` already matches `agentsMdContent` exactly, + should leave it unchanged (no unnecessary write or stage). +- Given the pre-commit hook runs `node bin/aidd.js --index`, should automatically + stage any regenerated `AGENTS.md` or `CLAUDE.md`. diff --git a/tasks/archive/2026-02-19-aidd-create-remediation-epic.md b/tasks/archive/2026-02-19-aidd-create-remediation-epic.md new file mode 100644 index 00000000..415315ca --- /dev/null +++ b/tasks/archive/2026-02-19-aidd-create-remediation-epic.md @@ -0,0 +1,113 @@ +# `npx aidd create` Remediation Epic + +**Status**: ✅ COMPLETED (2026-02-19) +**Goal**: Fix correctness, safety, and maintainability gaps found in the post-implementation review of the `create` subcommand. + +## Overview + +The initial implementation of `npx aidd create` passed all tests and covered the happy paths, but a systematic review surfaced issues ranging from silent incorrect behaviour (ambiguous manifest steps, unvalidated manifest existence) to developer experience gaps (slow pre-commit hook, relative path in cleanup tip, missing docs, CLAUDE.md not created on install). This epic remediates every open finding so the feature is production-quality. + +--- + +## Fix pre-commit hook + +The pre-commit hook runs `npm test`, which includes slow E2E tests (up to 180 s) and times out the release pipeline. + +**Requirements**: +- Given a git commit, the pre-commit hook should run `npm run test:unit` (unit tests + lint + typecheck only, no E2E) +- Given a git commit is made by an AI agent, AGENTS.md should instruct the agent to run `npm run test:e2e` manually before committing + +--- + +## Fix cleanup tip to use absolute path + +The scaffold-complete message suggests `npx aidd scaffold-cleanup `, which breaks if the user changes directory before running it. + +**Requirements**: +- Given scaffold completes successfully, should suggest `npx aidd scaffold-cleanup` with the absolute `folderPath`, not the relative user input +- Given the tip is displayed, the suggested command should work regardless of the user's current working directory + +--- + +## Reject ambiguous manifest step + +A step with both `run` and `prompt` keys silently ignores `prompt`. Manifests that contain both keys should fail fast. + +**Requirements**: +- Given a manifest step has both `run` and `prompt` keys simultaneously, `parseManifest` should throw `ScaffoldValidationError` identifying the step number and both keys found +- Given a manifest step has only `run` or only `prompt`, should parse normally without error + +--- + +## Validate manifest exists before returning from `resolveExtension` + +`resolveExtension` returns computed paths without verifying `SCAFFOLD-MANIFEST.yml` exists, producing a raw ENOENT later in `runManifest`. + +**Requirements**: +- Given a named or `file://` scaffold whose `SCAFFOLD-MANIFEST.yml` does not exist, `resolveExtension` should throw `ScaffoldValidationError` with a message naming the missing file and the scaffold source +- Given an HTTP/HTTPS scaffold where the cloned repo contains no `SCAFFOLD-MANIFEST.yml`, should throw `ScaffoldValidationError` mentioning the URI and required files +- Given a valid scaffold where the manifest exists, should return paths normally + +--- + +## Add e2e-before-commit instruction to AGENTS.md + +Agents need to know to run E2E tests before committing even though the pre-commit hook no longer runs them automatically. + +**Requirements**: +- Given AGENTS.md is created or appended by the installer, should include the instruction to run `npm run test:e2e` before committing +- Given an existing AGENTS.md that lacks the e2e instruction, `ensureAgentsMd` should append it along with any other missing directives + +--- + +## Create CLAUDE.md on `npx aidd` install + +Claude Code reads `CLAUDE.md` for project guidelines. The installer creates `AGENTS.md` but not `CLAUDE.md`, so Claude sessions miss the directives. + +**Requirements**: +- Given `npx aidd` installs into a directory that has no `CLAUDE.md`, should create `CLAUDE.md` with the same content as `AGENTS.md` +- Given `CLAUDE.md` already exists, should leave it unchanged (graceful, no overwrite) +- Given the install completes, `ensureAgentsMd` (or a companion function) should handle both files + +--- + +## Replace `matter.engines.yaml.parse` with `js-yaml` directly + +`matter.engines.yaml.parse()` is an undocumented internal API of `gray-matter`. Parsing pure YAML should use `js-yaml` directly. + +**Requirements**: +- Given a SCAFFOLD-MANIFEST.yml with or without a leading `---` document-start marker, should parse identically to the current behaviour +- Given `js-yaml` is used as the YAML parser, `gray-matter` should no longer be used in `scaffold-runner.js` +- Given `js-yaml` is listed as a direct dependency in `package.json`, the dependency is explicit + +--- + +## Add scaffold authoring documentation + +No documentation exists explaining how to author a scaffold or which files get included. + +**Requirements**: +- Given a developer wants to create a custom scaffold, `ai/scaffolds/index.md` (or a dedicated `SCAFFOLD-AUTHORING.md`) should explain the scaffold directory structure (`SCAFFOLD-MANIFEST.yml`, `bin/extension.js`, `README.md`) +- Given a scaffold author wants to control which files are packaged, the docs should explain that the `files` array in the scaffold's `package.json` works like any npm package to control published contents + +--- + +## Clarify git-clone approach in epic and code + +The original epic requirement said "download the latest GitHub release tarball" but the implementation intentionally uses `git clone` (with a rationale comment) to support arbitrary repo structures. The requirement and implementation must agree. + +**Requirements**: +- Given the epic requirement for HTTP/HTTPS URIs, should be updated to reflect that `git clone` is used (not tarball download) with the documented rationale (carries arbitrary files, works with any git host) +- Given the code comment in `scaffold-resolver.js`, should remain and be expanded to note that the default branch is cloned + +--- + +## Factor `create` and `verify-scaffold` handlers out of `bin/aidd.js` + +The `create` action (~82 lines) and `verify-scaffold` action (~46 lines) are inline in `bin/aidd.js`, making it harder to unit-test argument parsing and error handling independently. + +**Requirements**: +- Given `bin/aidd.js` registers the `create` command, should delegate to a `runCreate({ type, folder, agent })` function exported from `lib/scaffold-create.js` +- Given `bin/aidd.js` registers the `verify-scaffold` command, should delegate to a `runVerifyScaffold({ type })` function exported from `lib/scaffold-verify-cmd.js` +- Given `runCreate` is a plain function, unit tests should cover: one-arg (folder only), two-arg (type + folder), missing folder error, absolute path in cleanup tip +- Given `runVerifyScaffold` is a plain function, unit tests should cover: valid scaffold, invalid scaffold, cancelled remote diff --git a/tasks/archive/2026-02-21-aidd-create-remediation-epic-2.md b/tasks/archive/2026-02-21-aidd-create-remediation-epic-2.md new file mode 100644 index 00000000..f49a563d --- /dev/null +++ b/tasks/archive/2026-02-21-aidd-create-remediation-epic-2.md @@ -0,0 +1,128 @@ +# `npx aidd create` Remediation Epic 2 + +**Status**: ✅ COMPLETED (2026-02-21) +**Goal**: Fix the remaining safety, correctness, and UX gaps found in the second post-implementation review of the `create` subcommand. + +## Overview + +A second systematic review of the `create` implementation surfaced eleven open issues spanning security (HTTPS enforcement, path traversal, unsafe YAML parsing), error-handling correctness (unhandled pipe errors, hanging readline, wrong exit code on cancellation), DX gaps (cleanup tip shown for non-HTTP scaffolds, wrong help example URL), and a broken E2E test fixture. This epic closes every finding so the feature is production-quality and the test suite is green. + +--- + +## Fix E2E test fixture missing `test:e2e` + +The "does not overwrite existing AGENTS.md with all directives" test has a fixture that does not include `test:e2e`, so `hasAllDirectives()` returns false and the installer appends content, breaking the assertion. + +**Requirements**: +- Given the test fixture represents an AGENTS.md with all required directives, should include a `test:e2e` mention so `hasAllDirectives()` returns true +- Given the updated fixture, the test should pass with content unchanged after install + +--- + +## Enforce HTTPS-only for remote scaffold URIs + +`isHttpUrl` in `scaffold-resolver.js` accepts both `http://` and `https://`. Downloading executable scaffold code over plain HTTP exposes developers to on-path tampering. + +**Requirements**: +- Given an HTTP (non-TLS) scaffold URI, `resolveExtension` should throw `ScaffoldValidationError` before any network request is made +- Given an HTTPS scaffold URI, should proceed normally +- Given the error message, should name the rejected URI and instruct the user to use `https://` + +--- + +## Only show cleanup tip for HTTP/HTTPS scaffolds + +The cleanup tip is printed after every successful `create`, including named and `file://` scaffolds that never create a `.aidd/scaffold/` directory, making the tip misleading. + +**Requirements**: +- Given a named scaffold (`next-shadcn`), `runCreate` should not suggest `scaffold-cleanup` +- Given a `file://` scaffold, `runCreate` should not suggest `scaffold-cleanup` +- Given an `https://` scaffold, `runCreate` should suggest `scaffold-cleanup` +- Given `resolveExtension` returns a `downloaded` flag (true for HTTP, false otherwise), `runCreate` uses it to conditionally include `cleanupTip` in the result + +--- + +## Use safe YAML schema in `parseManifest` + +`yaml.load(content)` with no schema option allows JS-specific types in the YAML. Scaffold manifests can come from untrusted remote URIs. + +**Requirements**: +- Given a SCAFFOLD-MANIFEST.yml with only plain strings, arrays, and objects, should parse successfully with `yaml.JSON_SCHEMA` +- Given a SCAFFOLD-MANIFEST.yml that uses JS-specific YAML constructs (e.g. `!!js/regexp`), should throw a parse error rather than silently executing +- Given the schema option is applied, the test suite should still pass for all existing valid manifests + +--- + +## Wrap `fs.readFile` in `ensureClaudeMd` with `try/catch` + +`agents-md.js:254` reads CLAUDE.md without error handling. A permission or I/O error would throw a raw Node error instead of a wrapped `AgentsFileError`. + +**Requirements**: +- Given CLAUDE.md exists but cannot be read (e.g. permission error), `ensureClaudeMd` should throw `AgentsFileError` with the path and original error as cause +- Given the read succeeds, behaviour is unchanged + +--- + +## Fix ad-hoc error handling in main install path and scaffold-cleanup + +`bin/aidd.js` lines 148–193 manually construct a `new Error()` before calling `handleCliErrors`, and `scaffold-cleanup` at line 366 has no typed handler at all. + +**Requirements**: +- Given the main install action encounters a `CloneError`, `FileSystemError`, or `ValidationError`, should handle it via `handleCliErrors` without manually constructing a wrapper `Error` +- Given `scaffold-cleanup` throws, should display a clear typed error message rather than a raw `err.message` fallback + +--- + +## Validate `type` in `resolveNamed` to prevent path traversal + +`resolveNamed` passes `type` directly to `path.resolve()` without checking that the result stays within the `ai/scaffolds/` directory. + +**Requirements**: +- Given a `type` containing path-traversal segments (e.g. `../../etc`), `resolveNamed` should throw `ScaffoldValidationError` before accessing the filesystem +- Given a valid named type (e.g. `next-shadcn`), should resolve normally + +--- + +## Auto-resolve bare GitHub repo URLs to latest release tarball + +`bin/aidd.js` line 227 shows `https://github.com/org/scaffold my-project` as an example. The implementation should detect a bare `https://github.com/owner/repo` URL and automatically resolve it to the latest release tarball via the GitHub API, then download that. Users should not need to know the tarball URL format. + +**Requirements**: +- Given a bare `https://github.com/owner/repo` URL (no path beyond the repo name), `resolveExtension` should fetch the latest release tag from the GitHub API and construct the tarball URL +- Given the resolved tarball URL, should download and extract it normally via `defaultDownloadAndExtract` +- Given a URL that is already a direct tarball link (contains `/archive/`), should download it directly without hitting the API +- Given the GitHub API returns no releases, should throw `ScaffoldNetworkError` with a clear message naming the repo +- Given the help text example, should show a bare `https://github.com/org/repo` URL consistent with the actual supported usage +- Given `ai/scaffolds/SCAFFOLD-AUTHORING.md`, should document both bare repo URL and direct tarball URL options + +--- + +## Add error and close handlers to `defaultConfirm` readline + +`defaultConfirm` in `scaffold-resolver.js` wraps `rl.question` in a promise with no `error` or `close` event handlers. If stdin is closed or not a TTY, the promise hangs forever. + +**Requirements**: +- Given stdin is closed before the user answers, the readline promise should reject with a `ScaffoldCancelledError` +- Given a readline error event fires, the promise should reject with a `ScaffoldCancelledError` +- Given the user answers normally, behaviour is unchanged + +--- + +## Add `stdin` error handler for `tar` spawn in `defaultDownloadAndExtract` + +`child.stdin.write(buffer)` and `child.stdin.end()` have no error listener on `child.stdin`. A broken pipe (tar exits early) crashes Node with an unhandled error event. + +**Requirements**: +- Given the tar process exits before consuming all stdin, the promise should reject with a descriptive error rather than crashing Node +- Given the tar process consumes all stdin successfully, behaviour is unchanged + +--- + +## Exit with code 0 on user cancellation + +`bin/aidd.js` calls `process.exit(1)` unconditionally after `ScaffoldCancelledError` in both `create` (line 295) and `verify-scaffold` (line 341). Cancellation is a graceful abort, not a failure. + +**Requirements**: +- Given the user declines the remote scaffold confirmation, `create` should exit with code 0 +- Given the user declines on `verify-scaffold`, should exit with code 0 +- Given any other error (network, validation, step failure), should still exit with code 1 diff --git a/tasks/claude-symlink-epic.md b/tasks/claude-symlink-epic.md new file mode 100644 index 00000000..4623d392 --- /dev/null +++ b/tasks/claude-symlink-epic.md @@ -0,0 +1,37 @@ +# `npx aidd --claude` Symlink Epic + +**Status**: 🔄 IN PROGRESS +**Goal**: Add `--claude` flag to `npx aidd` that creates a `.claude → ai/` symlink (mirroring `--cursor`), and refactor `createCursorSymlink` into a shared `lib/symlinks.js` to eliminate duplication. + +## Overview + +Claude Code discovers project commands from `.claude/commands/` and skills from `.claude/skills/`. Since `.claude/` is already in `.gitignore` (same as `.cursor/`), the right model is a generated symlink — identical to the existing `--cursor` integration. Refactoring into `lib/symlinks.js` lets both flags share one parameterized function instead of duplicating symlink logic. + +--- + +## Refactor symlink logic into `lib/symlinks.js` + +Extract `createCursorSymlink` from `lib/cli-core.js` into a generic, parameterized `createSymlink` in a new `lib/symlinks.js`. + +**Requirements**: +- Given `createSymlink({ name, targetBase, force })`, should create a symlink at `targetBase/name` pointing to the relative path `ai` +- Given the symlink target already exists and `force` is false, should throw a `ValidationError` with code `"VALIDATION_ERROR"` +- Given the symlink target already exists and `force` is false, should throw an error whose `cause.name` is `"ValidationError"` so that `handleCliErrors` dispatches to the correct handler +- Given the symlink target already exists and `force` is true, should remove the existing entry and create the symlink +- Given any unexpected filesystem error, should throw a `FileSystemError` with code `"FILESYSTEM_ERROR"` wrapping the original error +- Given any unexpected filesystem error, should throw an error whose `cause.name` is `"FileSystemError"` so that `handleCliErrors` dispatches to the correct handler +- Given `lib/cursor-symlink.test.js` is replaced by `lib/symlinks.test.js`, all existing cursor tests should continue to pass + +--- + +## Add `--claude` flag to `npx aidd` + +New `--claude` option that creates a `.claude → ai` symlink alongside (or instead of) `--cursor`. + +**Requirements**: +- Given `npx aidd --claude`, should create a `.claude` symlink pointing to `ai` in the target directory +- Given `npx aidd --cursor --claude`, should create both `.cursor` and `.claude` symlinks +- Given `--claude` without `--force` and `.claude` already exists, should report a validation error +- Given `--claude --force` and `.claude` already exists, should replace it with the symlink +- Given `npx aidd --help`, should list `--claude` in the Quick Start section with an example + diff --git a/tasks/npx-aidd-create-epic.md b/tasks/npx-aidd-create-epic.md index 2b997d82..29dd2038 100644 --- a/tasks/npx-aidd-create-epic.md +++ b/tasks/npx-aidd-create-epic.md @@ -1,6 +1,6 @@ # `npx aidd create` Epic -**Status**: 📋 PLANNED +**Status**: ✅ DONE **Goal**: Add a `create` subcommand to `aidd` that scaffolds new apps from manifest-driven extensions with fresh `@latest` installs. ## Overview @@ -9,6 +9,22 @@ Today there's no way to bootstrap a new project from the AIDD ecosystem — devs --- +## Node.js engine requirement + +**Requirements**: +- Given a user is on Node < 18, should receive a clear version constraint error from npm/node at install or run time — not a confusing runtime `ReferenceError` + +--- + +## CLAUDE.md stability on repeated installs + +Ensure `ensureClaudeMd` does not modify a `CLAUDE.md` that was created by a previous install. + +**Requirements**: +- Given `npx aidd` is run a second time on a project where `CLAUDE.md` was created by the first run, should return `action: "unchanged"` and not modify the file + +--- + ## Add `create` subcommand New Commander subcommand `create [type] ` added to `bin/aidd.js`. @@ -17,20 +33,23 @@ New Commander subcommand `create [type] ` added to `bin/aidd.js`. - Given `npx aidd create [type] `, should create a new directory `` in cwd - Given `` matching a scaffold name, should resolve to `ai/scaffolds/` in the package - Given `` as an HTTP/HTTPS URI, should treat it as a remote extension source -- Given no `` and no `AIDD_CUSTOM_EXTENSION_URI`, should use the bundled `ai/scaffolds/next-shadcn` extension -- Given `AIDD_CUSTOM_EXTENSION_URI` env var is set and no `` arg, should use the env URI (supports `http://`, `https://`, and `file://` schemes) +- Given no `` and no `AIDD_CUSTOM_CREATE_URI`, should use the bundled `ai/scaffolds/next-shadcn` extension +- Given `AIDD_CUSTOM_CREATE_URI` env var is set and no `` arg, should use the env URI (supports `https://` and `file://` schemes only — `http://` is rejected) - Given `--agent ` flag, should use that agent CLI for `prompt` steps (default: `claude`) - Given scaffold completes successfully, should suggest `npx aidd scaffold-cleanup` to remove downloaded extension files +- Given only a single `https://` or `file://` URI argument with no folder, should print `error: missing required argument 'folder'` and exit 1 (not silently create a directory with a mangled URL path) +- Given `resolveExtension` rejects for any reason (e.g. user cancels remote code confirmation, network failure), should not create any directory on disk --- ## Extension resolver -Resolve extension source and fetch `README.md`, `SCAFFOLD-MANIFEST.yml`, and `bin/extension.js`. +Resolve extension source and fetch `README.md` and `SCAFFOLD-MANIFEST.yml`. **Requirements**: +- Given `AIDD_CUSTOM_CREATE_URI` was not set before a test, should be fully absent from `process.env` after the test completes — even if the test throws - Given a named scaffold type, should read files directly from `ai/scaffolds/` in the package -- Given an HTTP/HTTPS URI, should fetch `/README.md`, `/SCAFFOLD-MANIFEST.yml`, and `/bin/extension.js` into `/.aidd/scaffold/` +- Given an HTTP/HTTPS URI pointing to a GitHub repository, should download the latest GitHub release tarball (rather than git clone) and extract it to `/.aidd/scaffold/` — this gives versioned, reproducible scaffolds without fetching the full git history - Given a `file://` URI, should read extension files from the local path it points to without copying them - Given any extension, should display README contents to the user before proceeding - Given a remote HTTP/HTTPS URI, should warn the user they are about to execute remote code and prompt for confirmation before downloading or running anything @@ -38,6 +57,15 @@ Resolve extension source and fetch `README.md`, `SCAFFOLD-MANIFEST.yml`, and `bi --- +## Scaffold verifier + +Pre-flight checks that a resolved scaffold is structurally valid before any steps are executed. + +**Requirements**: +- Given a missing manifest, should return an error message that includes the full `manifestPath` so the user knows exactly where the file was expected + +--- + ## SCAFFOLD-MANIFEST.yml runner Parse and execute manifest steps sequentially in the target directory. @@ -46,7 +74,31 @@ Parse and execute manifest steps sequentially in the target directory. - Given a `run` step, should execute it as a shell command in `` - Given a `prompt` step, should invoke the selected agent CLI (default: `claude`) with that prompt string in `` - Given any step fails, should report the error and halt execution -- Given a `bin/extension.js` is present, should execute it via Node.js in `` after all manifest steps complete + +### Manifest validation + +`parseManifest` must validate the manifest structure before returning steps so that malformed manifests fail fast with a clear error rather than silently producing wrong output. + +**Requirements**: +- Given `steps` is present but is not an array (e.g. a string or plain object), should throw `ScaffoldValidationError` with a message that includes `"steps"` and the actual type received +- Given a step item is not a plain object (e.g. a bare string, `null`, or nested array), should throw `ScaffoldValidationError` identifying the offending step number +- Given a step item has no recognized keys (`run` or `prompt`), should throw `ScaffoldValidationError` identifying the offending step number and the keys that were found +- Given a step item where `run` or `prompt` is not a string (e.g. `run: 123` or `prompt: [a, b]`), should throw `ScaffoldValidationError` with a message identifying the step number, key name, and actual type received +- Given `steps` is absent or `null`, should return an empty array (backward-compatible default) + +--- + +## Add `verify-scaffold` subcommand + +New Commander subcommand `verify-scaffold [type]` that validates a scaffold conforms to all structural requirements before it is run. + +**Requirements**: +- Given a valid named scaffold, should print `✅ Scaffold is valid` and exit 0 +- Given a named scaffold whose manifest is missing, should print a descriptive error and exit 1 +- Given a scaffold whose `steps` is not an array, should print a validation error and exit 1 +- Given a scaffold with a step that has no recognized keys, should print a validation error and exit 1 +- Given a scaffold with an empty steps array, should report that the scaffold would do nothing and exit 1 +- Given a `file://` URI or HTTP/HTTPS URI, should resolve and validate the same as named scaffolds --- @@ -67,9 +119,20 @@ Minimal fast-running scaffold at `ai/scaffolds/scaffold-example` used as the e2e **Requirements**: - Given the scaffold runs, should initialize a new npm project in `` -- Given the scaffold runs, should install `riteway`, `vitest`, `@playwright/test`, `error-causes`, and `cuid2` at `@latest` +- Given the scaffold runs, should install `riteway`, `vitest`, `@playwright/test`, `error-causes`, `@paralleldrive/cuid2`, and `release-it` at `@latest` +- Given the scaffold runs, should configure `scripts.test` as `vitest run` +- Given the scaffold runs, should configure `scripts.release` as `release-it` so the generated project can publish GitHub releases - Given the scaffold runs, should leave a working project where `npm test` can be invoked +### Scaffold author release workflow + +Each scaffold lives in its own repository and is distributed as a **GitHub release** (versioned tarball), not via raw git clone. + +**Requirements**: +- Each scaffold directory should include a `package.json` with a `release` script (`release-it`) so scaffold authors can cut a tagged GitHub release with one command +- The `files` array in the scaffold's `package.json` controls which files are included when publishing to **npm** — it does NOT affect GitHub release assets (those are controlled by the release workflow and what is committed to the repository) +- Scaffold consumers reference a released scaffold by its bare GitHub repo URL (`https://github.com/owner/repo`); the aidd resolver auto-resolves this to the latest release tarball via the GitHub API and downloads it instead of cloning the repo + --- ## Create `next-shadcn` scaffold stub @@ -88,6 +151,84 @@ End-to-end tests using `scaffold-example` as the test fixture. **Requirements**: - Given `aidd create scaffold-example test-project`, should create `test-project/` with expected packages installed -- Given `AIDD_CUSTOM_EXTENSION_URI` set to a `file://` URI, should use it over the default extension +- Given `AIDD_CUSTOM_CREATE_URI` set to a `file://` URI, should use it over the default extension - Given `aidd scaffold-cleanup test-project`, should remove `test-project/.aidd/` - Given `--agent claude` flag, should pass the agent name through to `prompt` step invocations + +--- + +## Rename `AIDD_CUSTOM_EXTENSION_URI` → `AIDD_CUSTOM_CREATE_URI` + +Rename the environment variable everywhere it appears for consistency with the `create` subcommand name. + +**Requirements**: +- Given any reference to `AIDD_CUSTOM_EXTENSION_URI` in source, tests, or docs, should be replaced with `AIDD_CUSTOM_CREATE_URI` + +--- + +## Fix `resolveNamed` path traversal check edge case + +The existing `!typeDir.startsWith(scaffoldsRoot + path.sep)` check incorrectly allows `type = ""` or `type = "."` to pass: `path.resolve(scaffoldsRoot, "")` returns `scaffoldsRoot` itself, which does not start with `scaffoldsRoot + sep` and was being rejected with a misleading "resolved outside" error. The fix uses `path.relative()` which cleanly separates the two failure modes. + +**Requirements**: +- Given `type` containing path-traversal segments (e.g. `../../etc/passwd`), `resolveNamed` should throw `ScaffoldValidationError` (unchanged existing behavior) +- Given `type = ""` (empty string), the `||` fallback chain treats it as falsy and uses the default scaffold — this is correct, not a bug +- Given `type = "."`, `resolveNamed` should throw `ScaffoldValidationError`; the error message must not say "outside the scaffolds directory" (`.` resolves to the scaffolds root itself, not outside it) +- Given a valid named type (e.g. `next-shadcn`), should resolve normally (unchanged existing behavior) +- Implementation: use `path.relative(scaffoldsRoot, typeDir)` — throw if the relative path starts with `..`, is absolute, or is empty + +--- + +## Support `GITHUB_TOKEN` for private repos and higher rate limits + +`defaultResolveRelease` and `defaultDownloadAndExtract` currently make unauthenticated requests, limiting usage to public repos and 60 API requests/hr per IP. Adding optional `GITHUB_TOKEN` support covers private-repo scaffold authors and avoids spurious rate-limit failures in busy environments. + +**Requirements**: +- Given `GITHUB_TOKEN` is set in the environment, `defaultResolveRelease` should include an `Authorization: Bearer ${GITHUB_TOKEN}` header on the GitHub API request +- Given `GITHUB_TOKEN` is set in the environment, `defaultDownloadAndExtract` should include an `Authorization: Bearer ${GITHUB_TOKEN}` header only when the download URL's hostname is `api.github.com`, `github.com`, or `codeload.github.com` — never for third-party hosts +- Given the GitHub API returns 403 (rate limited), the error should say "GitHub API rate limit exceeded — set GITHUB_TOKEN for 5,000 req/hr" regardless of whether a token is set +- Given the GitHub API returns 404 and `GITHUB_TOKEN` is **not** set, the error should include the hint: "If the repo is private, set GITHUB_TOKEN to authenticate" +- Given the GitHub API returns 404 and `GITHUB_TOKEN` **is** set, the error should not include that hint (the token is set; the repo simply doesn't exist or has no releases) + +--- + +## Fix `verify-scaffold` HTTP/HTTPS scaffold download location and cleanup + +`verify-scaffold` downloaded remote scaffolds to `.aidd/scaffold/` in the current working directory and never cleaned them up — a silent filesystem side effect for a read-only validation command. + +### Architectural decision + +Because the project directory may not yet exist at verification time, downloaded scaffold files belong in `~/.aidd/scaffold/` (the user-level aidd home directory), not in the project directory. Cleanup must happen unconditionally (in a `finally` block) whether verification succeeds or fails. + +**Requirements**: +- Given an HTTP/HTTPS scaffold URI, `verify-scaffold` should download scaffold files to `~/.aidd/scaffold/` (not the current working directory) +- Given successful verification, `verify-scaffold` should remove `~/.aidd/scaffold/` after the result is returned +- Given a verification error or resolution error (cancelled, network failure, validation error), `verify-scaffold` should still remove `~/.aidd/scaffold/` before propagating the error +- Given a named (`next-shadcn`) or `file://` scaffold, cleanup is a no-op (nothing was downloaded) + +--- + +## Add `set` subcommand and user-level config (`~/.aidd/config.yml`) + +### Architectural decision + +The extension URI priority chain is: + +``` +CLI arg > AIDD_CUSTOM_CREATE_URI env var > ~/.aidd/config.yml > default (next-shadcn) +``` + +The user config file (`~/.aidd/config.yml`) is read **directly** by `resolveExtension` as a third-level fallback — it is NOT applied to `process.env` at startup. The env var remains a distinct, higher-priority override (useful for CI and one-off runs). The `set` command provides a convenient way to write to `~/.aidd/config.yml` without hand-editing YAML. + +YAML is used for the config file because it is token-friendly for AI context injection. + +### `npx aidd set ` + +**Requirements**: +- Given `npx aidd set create-uri `, should write `create-uri: ` to `~/.aidd/config.yml`, creating the directory and file if they don't exist +- Given an existing `~/.aidd/config.yml`, should merge the new value without losing other keys +- Given an unknown ``, should print a clear error and exit 1 +- Given `npx aidd create` with no `` arg and no `AIDD_CUSTOM_CREATE_URI` env var, should fall back to the `create-uri` value from `~/.aidd/config.yml` +- Given `AIDD_CUSTOM_CREATE_URI` env var is set, should take precedence over `~/.aidd/config.yml` +- Given a CLI `` arg, should take precedence over both the env var and `~/.aidd/config.yml` +- Given a config file containing a YAML-specific tag (e.g. `!!binary`), `readConfig` should return `{}` (unsafe YAML types are rejected by `yaml.JSON_SCHEMA`)