diff --git a/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md new file mode 100644 index 00000000..9aa24b98 --- /dev/null +++ b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.md @@ -0,0 +1,74 @@ +--- +id: ARCH-011 +title: Consistent Project Root Resolution +domain: architecture +rules: true +files: ["src/commands/**/*.ts"] +--- + +# Consistent Project Root Resolution + +## Context + +Commands that operate on project-level resources (ADRs, rules, `.archgate/` config) need to locate the project root directory. Inconsistent strategies for finding the project root cause different behavior depending on the user's working directory: + +- Commands using `findProjectRoot()` (walks up from cwd to find `.archgate/adrs/`) work correctly from any subdirectory +- Commands using `process.cwd()` directly fail when the user is in a subdirectory because they assume cwd IS the project root + +This inconsistency was discovered during a repository-wide consistency review where `archgate check` worked from subdirectories but `archgate adr list` did not. + +**Alternatives considered:** + +- **Always use `process.cwd()`** — Simplest, but breaks when the user is in a subdirectory of the project. This is a common workflow (e.g., running `archgate adr list` while editing files in `src/`). +- **Require users to run from the project root** — Adds friction and goes against CLI conventions (git, npm, etc. all resolve upward). +- **Walk up from cwd to find `.archgate/adrs/`** — Standard convention used by git, npm, and other project-aware CLIs. Already implemented as `findProjectRoot()` in `src/helpers/paths.ts`. + +## Decision + +All commands that operate on `.archgate/` project resources MUST use `findProjectRoot()` from `src/helpers/paths.ts` to locate the project root. Direct use of `process.cwd()` for project root resolution in command files is prohibited. + +**Exceptions:** + +- `archgate init` — Creates the `.archgate/` directory; uses `process.cwd()` because no project root exists yet +- `archgate upgrade` — Operates on the binary, not on a project; its `findPackageRoot()` walks up from the binary path to find `package.json` for local install detection (a different concern than project root) +- Commands that don't require a project (e.g., `clean`, `login`) are not affected + +## Do's and Don'ts + +### Do + +- Use `findProjectRoot()` from `src/helpers/paths.ts` in all commands that read from `.archgate/` +- Check the return value for `null` and exit with a helpful error message +- Pass the resolved `projectRoot` to `projectPaths()` for derived paths + +### Don't + +- Don't use `process.cwd()` to locate `.archgate/` in command files (except `init`) +- Don't define local `findProjectRoot()` variants — use the shared implementation +- Don't assume the user is running from the project root + +## Consequences + +### Positive + +- All commands work consistently regardless of the user's working directory +- Matches user expectations from git, npm, and other project-aware CLIs + +### Negative + +- Slightly more verbose command setup (null check on `findProjectRoot()`) + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** `ARCH-011/no-process-cwd-for-project-root`: Scans command files for `process.cwd()` usage and flags violations. The `init` command is exempt. Severity: `error`. + +### Manual Enforcement + +Code reviewers MUST verify that new commands use `findProjectRoot()` for project-aware operations. + +## References + +- [ARCH-001 — Command Structure](./ARCH-001-command-structure.md) — Commands handle I/O only +- [ARCH-002 — Error Handling](./ARCH-002-error-handling.md) — Exit with code 1 when project not found diff --git a/.archgate/adrs/ARCH-011-consistent-project-root-resolution.rules.ts b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.rules.ts new file mode 100644 index 00000000..af209b7f --- /dev/null +++ b/.archgate/adrs/ARCH-011-consistent-project-root-resolution.rules.ts @@ -0,0 +1,38 @@ +/// + +export default { + rules: { + "no-process-cwd-for-project-root": { + description: + "Command files must use findProjectRoot() instead of process.cwd() for project root resolution", + severity: "error", + async check(ctx) { + // init.ts is exempt — it creates the project, so no root exists yet + const files = ctx.scopedFiles.filter( + (f) => + f.includes("commands/") && + !f.endsWith("init.ts") && + !f.endsWith("index.ts") + ); + + const checks = files.map(async (file) => { + const matches = await ctx.grep(file, /process\.cwd\(\)/); + for (const m of matches) { + // Allow process.cwd() as a fallback after findProjectRoot() + // e.g. findProjectRoot() ?? process.cwd() + if (m.content.includes("findProjectRoot")) continue; + + ctx.report.violation({ + message: + "Use findProjectRoot() from helpers/paths.ts instead of process.cwd() for project root resolution", + file: m.file, + line: m.line, + fix: "Import { findProjectRoot } from '../helpers/paths' and use findProjectRoot()", + }); + } + }); + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; diff --git a/.archgate/adrs/ARCH-012-command-error-boundaries.md b/.archgate/adrs/ARCH-012-command-error-boundaries.md new file mode 100644 index 00000000..3406f06c --- /dev/null +++ b/.archgate/adrs/ARCH-012-command-error-boundaries.md @@ -0,0 +1,92 @@ +--- +id: ARCH-012 +title: Command Error Boundaries +domain: architecture +rules: true +files: ["src/commands/**/*.ts"] +--- + +# Command Error Boundaries + +## Context + +Async command actions that lack try-catch error boundaries produce poor user experiences when they fail. Without explicit error handling: + +1. Errors propagate to the top-level `main().catch()` in `cli.ts`, which exits with code 2 (internal error) and shows only the raw error message +2. Users cannot distinguish between a command failure (code 1) and a CLI bug (code 2) +3. Error messages lack context about what the command was trying to do + +This was discovered during a repository-wide review where `review-context`, `session-context claude-code`, and `session-context cursor` all lacked error boundaries. + +ARCH-002 defines the exit code convention and logging patterns, but does not require error boundaries in command actions. This ADR complements ARCH-002 by making error boundaries mandatory. + +**Why not a global Commander.js error handler?** Commander provides `.exitOverride()` and `.configureOutput()` for parsing errors (unknown options, missing arguments), but these do **not** cover errors thrown inside async `.action()` callbacks. Commander's `preAction`/`postAction` hooks could theoretically wrap actions, but they don't catch async errors from the action body. The `main().catch()` in `cli.ts` catches unhandled rejections as a safety net (exit 2), but per-command try-catch is needed to produce contextual error messages and exit with code 1 instead of 2. + +## Decision + +Every async command action MUST wrap its body in a try-catch block that: + +1. Catches errors from async operations +2. Formats them with `logError()` from `src/helpers/log.ts` +3. Exits with code 1 (expected failure) for user-facing errors + +The top-level `main().catch()` in `cli.ts` remains as a safety net for truly unexpected errors (code 2), but it should never be the primary error handler for commands. + +**Pattern:** + +```typescript +.action(async (opts) => { + try { + // command logic + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +}); +``` + +**Exceptions:** + +- Synchronous command actions (e.g., `clean`) that cannot throw async errors +- Command group index files that only register subcommands + +## Do's and Don'ts + +### Do + +- Wrap every async command action body in a try-catch +- Use `logError()` for error messages in the catch block +- Exit with code 1 for expected failures + +### Don't + +- Don't rely on `main().catch()` as the only error handler for commands +- Don't catch and silently swallow errors — always log them +- Don't exit with code 2 in command catch blocks — that code is reserved for unexpected crashes + +## Consequences + +### Positive + +- Users see contextual error messages instead of raw exception text +- Exit code 1 vs 2 distinction is preserved — scripts and CI can differentiate +- Every command handles its own failures gracefully + +### Negative + +- Minor boilerplate in every async command action + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** `ARCH-012/async-action-error-boundary`: Scans async command actions for try-catch blocks. Severity: `warning` (some commands may have valid reasons for alternative patterns). + +### Manual Enforcement + +Code reviewers MUST verify that new async commands include error boundaries. + +## References + +- [ARCH-002 — Error Handling](./ARCH-002-error-handling.md) — Exit code convention and logError() requirement +- [ARCH-001 — Command Structure](./ARCH-001-command-structure.md) — Command file conventions diff --git a/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts b/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts new file mode 100644 index 00000000..ab45efe2 --- /dev/null +++ b/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts @@ -0,0 +1,41 @@ +/// + +export default { + rules: { + "async-action-error-boundary": { + description: + "Async command actions must include try-catch error boundaries", + severity: "warning", + async check(ctx) { + // Only check non-index command files + const files = ctx.scopedFiles.filter( + (f) => f.includes("commands/") && !f.endsWith("index.ts") + ); + + const checks = files.map(async (file) => { + const content = await ctx.readFile(file); + + // Find async action callbacks + const hasAsyncAction = /\.action\(\s*async\s/.test(content); + if (!hasAsyncAction) return; + + // Check if the async action body contains a try block + // Match: .action(async (...) => { ... try { ... } ... }) + const hasTryCatch = /\.action\(\s*async\s[\s\S]*?\btry\s*\{/.test( + content + ); + + if (!hasTryCatch) { + ctx.report.warning({ + message: + "Async command action should include a try-catch error boundary", + file, + fix: "Wrap the action body in try { ... } catch (err) { logError(...); process.exit(1); }", + }); + } + }); + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; diff --git a/.archgate/adrs/ARCH-013-version-synchronization.md b/.archgate/adrs/ARCH-013-version-synchronization.md new file mode 100644 index 00000000..833b9000 --- /dev/null +++ b/.archgate/adrs/ARCH-013-version-synchronization.md @@ -0,0 +1,64 @@ +--- +id: ARCH-013 +title: Version Synchronization +domain: architecture +rules: true +files: ["package.json", "docs/**"] +--- + +# Version Synchronization + +## Context + +The CLI version appears in multiple locations that must stay in sync: + +1. `package.json` `version` — canonical source of truth +2. `package.json` `optionalDependencies` — platform-specific npm packages (`archgate-darwin-arm64`, `archgate-linux-x64`, `archgate-win32-x64`) must match the CLI version +3. `docs/astro.config.mjs` — `softwareVersion` in the JSON-LD structured data + +When versions diverge, npm installs pull mismatched platform binaries and search engines display outdated version info. This was discovered during a consistency review where `package.json` was at `0.16.0` but `docs/astro.config.mjs` was still at `0.11.0`. + +## Decision + +`package.json` `version` is the single source of truth. All other version references MUST match it. + +**Automated via release process:** The `.simple-release.js` bump hook already syncs `optionalDependencies` versions during the release workflow — it reads `package.json` after the version bump and updates all `optionalDependencies` entries to match. This is fully automated and requires no manual intervention. + +**Automated via release process:** The `.simple-release.js` bump hook also updates `softwareVersion` in `docs/astro.config.mjs` to match `package.json`. Both syncs are fully automated. + +## Do's and Don'ts + +### Do + +- Rely on `.simple-release.js` for both `optionalDependencies` and `softwareVersion` sync (do not update manually) +- Use the companion rules to catch version drift in CI as a safety net + +### Don't + +- Don't manually edit `optionalDependencies` versions — the release hook handles this +- Don't manually edit `softwareVersion` in `docs/astro.config.mjs` — the release hook handles this + +## Consequences + +### Positive + +- Consistent version information across npm packages and user-facing surfaces +- CI catches version drift before it reaches production +- Platform-specific npm packages always match the CLI version + +### Negative + +- None — all version sync is automated via the release hook + +## Compliance and Enforcement + +### Automated Enforcement + +- **Release hook** `.simple-release.js`: Syncs `optionalDependencies` versions and `docs/astro.config.mjs` `softwareVersion` during `bump()`. Fully automated. +- **Archgate rule** `ARCH-013/docs-version-sync`: Checks that `softwareVersion` in `docs/astro.config.mjs` matches `package.json` version. Severity: `error`. +- **Archgate rule** `ARCH-013/optional-deps-version-sync`: Checks that all `optionalDependencies` versions match `package.json` version. Severity: `error`. + +## References + +- [GEN-001 — Documentation Site](./GEN-001-documentation-site.md) — Docs site structure and configuration +- [`.simple-release.js`](../../.simple-release.js) — Release bump hook that syncs optionalDependencies diff --git a/.archgate/adrs/ARCH-013-version-synchronization.rules.ts b/.archgate/adrs/ARCH-013-version-synchronization.rules.ts new file mode 100644 index 00000000..d26f4d5d --- /dev/null +++ b/.archgate/adrs/ARCH-013-version-synchronization.rules.ts @@ -0,0 +1,61 @@ +/// + +export default { + rules: { + "docs-version-sync": { + description: + "softwareVersion in docs/astro.config.mjs must match package.json version", + severity: "error", + async check(ctx) { + const pkgJson = (await ctx.readJSON("package.json")) as { + version?: string; + }; + if (!pkgJson.version) return; + + let astroConfig: string; + try { + astroConfig = await ctx.readFile("docs/astro.config.mjs"); + } catch { + // docs/astro.config.mjs may not exist in all contexts + return; + } + + const match = astroConfig.match(/softwareVersion:\s*"([^"]+)"/); + if (!match) return; + + const docsVersion = match[1]; + if (docsVersion !== pkgJson.version) { + ctx.report.violation({ + message: `docs/astro.config.mjs softwareVersion "${docsVersion}" does not match package.json version "${pkgJson.version}"`, + file: "docs/astro.config.mjs", + fix: `Update softwareVersion to "${pkgJson.version}" in docs/astro.config.mjs`, + }); + } + }, + }, + "optional-deps-version-sync": { + description: + "optionalDependencies versions must match package.json version", + severity: "error", + async check(ctx) { + const pkgJson = (await ctx.readJSON("package.json")) as { + version?: string; + optionalDependencies?: Record; + }; + if (!pkgJson.version || !pkgJson.optionalDependencies) return; + + for (const [dep, depVersion] of Object.entries( + pkgJson.optionalDependencies + )) { + if (depVersion !== pkgJson.version) { + ctx.report.violation({ + message: `optionalDependencies "${dep}" version "${depVersion}" does not match package.json version "${pkgJson.version}"`, + file: "package.json", + fix: `Update ${dep} to "${pkgJson.version}" in optionalDependencies (normally handled by .simple-release.js during release)`, + }); + } + } + }, + }, + }, +} satisfies RuleSet; diff --git a/.simple-release.js b/.simple-release.js index c1bb8884..9e6808c9 100644 --- a/.simple-release.js +++ b/.simple-release.js @@ -1,5 +1,5 @@ import { execSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { NpmProject } from "@simple-release/npm"; @@ -13,6 +13,7 @@ class ArchgateProject extends NpmProject { const version = pkg.version; let changed = false; + // Sync optionalDependencies (platform-specific npm packages) for (const dep of Object.keys(pkg.optionalDependencies || {})) { if (pkg.optionalDependencies[dep] !== version) { pkg.optionalDependencies[dep] = version; @@ -25,6 +26,20 @@ class ArchgateProject extends NpmProject { execSync("bun install", { stdio: "inherit" }); this.changedFiles.push("bun.lock"); } + + // Sync docs/astro.config.mjs softwareVersion + const astroConfigPath = "docs/astro.config.mjs"; + if (existsSync(astroConfigPath)) { + const astroConfig = readFileSync(astroConfigPath, "utf8"); + const updated = astroConfig.replace( + /softwareVersion:\s*"[^"]+"/, + `softwareVersion: "${version}"` + ); + if (updated !== astroConfig) { + writeFileSync(astroConfigPath, updated); + this.changedFiles.push(astroConfigPath); + } + } } return result; diff --git a/CLAUDE.md b/CLAUDE.md index 4611679e..2bb261e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,10 @@ Entry point: `src/cli.ts` (shebang `#!/usr/bin/env bun`). Commands registered vi Zod schemas are the single source of truth. Types derived via `z.infer<>` — never define separate interfaces. Use `safeParse()`. Reuse `AdrFrontmatterSchema.shape.*` to avoid duplicating enums. +## npm Distribution + +The npm package is a **thin shim** — it contains only `bin/archgate.cjs` and `scripts/postinstall.cjs`. The postinstall script downloads the prebuilt platform binary. All runtime dependencies (commander, inquirer, zod) are bundled into the compiled binary via `bun build --compile`, so they belong in `devDependencies`, not `dependencies`. The `optionalDependencies` (archgate-darwin-arm64, etc.) are platform-specific binary packages synced to the CLI version by `.simple-release.js` during release. + ## Conventions - Commands export `register*Command(program)`, handle I/O only — no business logic diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9ae020b0..e5b8dbb7 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -177,7 +177,7 @@ export default defineConfig({ applicationCategory: "DeveloperApplication", applicationSubCategory: "Code Governance", operatingSystem: "macOS, Linux, Windows", - softwareVersion: "0.11.0", + softwareVersion: "0.16.0", license: "https://github.com/archgate/cli/blob/main/LICENSE", offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, url: "https://cli.archgate.dev", diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index 544e2b1d..7d3d7a90 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -1,5 +1,3 @@ -import { existsSync } from "node:fs"; - import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; import inquirer from "inquirer"; @@ -7,7 +5,7 @@ import inquirer from "inquirer"; import { ADR_DOMAINS, type AdrDomain } from "../../formats/adr"; import { createAdrFile } from "../../helpers/adr-writer"; import { logError } from "../../helpers/log"; -import { projectPaths } from "../../helpers/paths"; +import { findProjectRoot, projectPaths } from "../../helpers/paths"; const domainOption = new Option("--domain ", "ADR domain").choices( ADR_DOMAINS @@ -24,75 +22,80 @@ export function registerAdrCreateCommand(adr: Command) { .option("--rules", "Set rules: true in frontmatter") .option("--json", "Output as JSON") .action(async (opts) => { - const projectRoot = process.cwd(); - const paths = projectPaths(projectRoot); - - if (!existsSync(paths.root)) { + const projectRoot = findProjectRoot(); + if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); process.exit(1); } - let domain: AdrDomain; - let title: string; - let files: string[] | undefined; - let body: string | undefined; + try { + const paths = projectPaths(projectRoot); - // Non-interactive mode when --title and --domain are provided - if (opts.title && opts.domain) { - domain = opts.domain; - title = opts.title; - files = opts.files - ? opts.files - .split(",") - .map((f) => f.trim()) - .filter(Boolean) - : undefined; - body = opts.body; - } else { - // Interactive mode - const answers = await inquirer.prompt([ - { - type: "list", - name: "domain", - message: "Domain:", - choices: ADR_DOMAINS.map((d) => ({ name: d, value: d })), - }, - { - type: "input", - name: "title", - message: "Title:", - validate: (input: string) => - input.trim() !== "" || "Title is required", - }, - { - type: "input", - name: "files", - message: "File patterns (comma-separated, optional):", - }, - ]); + let domain: AdrDomain; + let title: string; + let files: string[] | undefined; + let body: string | undefined; - domain = answers.domain as AdrDomain; - title = answers.title; - files = answers.files - ? answers.files - .split(",") - .map((f: string) => f.trim()) - .filter(Boolean) - : undefined; - } + // Non-interactive mode when --title and --domain are provided + if (opts.title && opts.domain) { + domain = opts.domain; + title = opts.title; + files = opts.files + ? opts.files + .split(",") + .map((f) => f.trim()) + .filter(Boolean) + : undefined; + body = opts.body; + } else { + // Interactive mode + const answers = await inquirer.prompt([ + { + type: "list", + name: "domain", + message: "Domain:", + choices: ADR_DOMAINS.map((d) => ({ name: d, value: d })), + }, + { + type: "input", + name: "title", + message: "Title:", + validate: (input: string) => + input.trim() !== "" || "Title is required", + }, + { + type: "input", + name: "files", + message: "File patterns (comma-separated, optional):", + }, + ]); - const result = await createAdrFile(paths.adrsDir, { - title, - domain, - files, - body, - rules: opts.rules, - }); + domain = answers.domain as AdrDomain; + title = answers.title; + files = answers.files + ? answers.files + .split(",") + .map((f: string) => f.trim()) + .filter(Boolean) + : undefined; + } - if (opts.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(`Created ADR: ${result.filePath}`); + const result = await createAdrFile(paths.adrsDir, { + title, + domain, + files, + body, + rules: opts.rules, + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Created ADR: ${result.filePath}`); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); } }); } diff --git a/src/commands/adr/list.ts b/src/commands/adr/list.ts index 6b04895b..e6018086 100644 --- a/src/commands/adr/list.ts +++ b/src/commands/adr/list.ts @@ -2,10 +2,11 @@ import { existsSync, readdirSync } from "node:fs"; import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; -import { parseAdr, type AdrDocument } from "../../formats/adr"; +import { ADR_DOMAINS, parseAdr, type AdrDocument } from "../../formats/adr"; import { logError } from "../../helpers/log"; -import { projectPaths } from "../../helpers/paths"; +import { findProjectRoot, projectPaths } from "../../helpers/paths"; async function loadAdrs(adrsDir: string): Promise { const files = readdirSync(adrsDir).filter((f) => f.endsWith(".md")); @@ -27,69 +28,78 @@ export function registerAdrListCommand(adr: Command) { .command("list") .description("List all ADRs") .option("--json", "Output as JSON") - .option("--domain ", "Filter by domain") + .addOption( + new Option("--domain ", "Filter by domain").choices(ADR_DOMAINS) + ) .action(async (options) => { - const projectRoot = process.cwd(); - const paths = projectPaths(projectRoot); - - if (!existsSync(paths.root)) { + const projectRoot = findProjectRoot(); + if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); process.exit(1); } - if (!existsSync(paths.adrsDir)) { - console.log("No ADRs found."); - return; - } + try { + const paths = projectPaths(projectRoot); - const files = readdirSync(paths.adrsDir).filter((f) => f.endsWith(".md")); + if (!existsSync(paths.adrsDir)) { + console.log("No ADRs found."); + return; + } - if (files.length === 0) { - console.log("No ADRs found."); - return; - } + const files = readdirSync(paths.adrsDir).filter((f) => + f.endsWith(".md") + ); - const adrs = await loadAdrs(paths.adrsDir); + if (files.length === 0) { + console.log("No ADRs found."); + return; + } - // Filter by domain if specified - const filtered = options.domain - ? adrs.filter((a) => a.frontmatter.domain === options.domain) - : adrs; + const adrs = await loadAdrs(paths.adrsDir); - if (options.json) { - console.log( - JSON.stringify( - filtered.map((a) => a.frontmatter), - null, - 2 - ) - ); - return; - } + // Filter by domain if specified + const filtered = options.domain + ? adrs.filter((a) => a.frontmatter.domain === options.domain) + : adrs; - // Table output - const idWidth = 12; - const domainWidth = 14; - const rulesWidth = 7; + if (options.json) { + console.log( + JSON.stringify( + filtered.map((a) => a.frontmatter), + null, + 2 + ) + ); + return; + } - console.log( - styleText( - "bold", - `${"ID".padEnd(idWidth)}${"Domain".padEnd(domainWidth)}${"Rules".padEnd(rulesWidth)}Title` - ) - ); - console.log( - styleText( - "dim", - `${"─".repeat(idWidth)}${"─".repeat(domainWidth)}${"─".repeat(rulesWidth)}${"─".repeat(30)}` - ) - ); + // Table output + const idWidth = 12; + const domainWidth = 14; + const rulesWidth = 7; - for (const adr of filtered) { - const fm = adr.frontmatter; console.log( - `${fm.id.padEnd(idWidth)}${fm.domain.padEnd(domainWidth)}${String(fm.rules).padEnd(rulesWidth)}${fm.title}` + styleText( + "bold", + `${"ID".padEnd(idWidth)}${"Domain".padEnd(domainWidth)}${"Rules".padEnd(rulesWidth)}Title` + ) ); + console.log( + styleText( + "dim", + `${"─".repeat(idWidth)}${"─".repeat(domainWidth)}${"─".repeat(rulesWidth)}${"─".repeat(30)}` + ) + ); + + for (const adr of filtered) { + const fm = adr.frontmatter; + console.log( + `${fm.id.padEnd(idWidth)}${fm.domain.padEnd(domainWidth)}${String(fm.rules).padEnd(rulesWidth)}${fm.title}` + ); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); } }); } diff --git a/src/commands/adr/show.ts b/src/commands/adr/show.ts index b4176512..b32b8745 100644 --- a/src/commands/adr/show.ts +++ b/src/commands/adr/show.ts @@ -1,10 +1,8 @@ -import { existsSync } from "node:fs"; - import type { Command } from "@commander-js/extra-typings"; import { findAdrFileById } from "../../helpers/adr-writer"; import { logError } from "../../helpers/log"; -import { projectPaths } from "../../helpers/paths"; +import { findProjectRoot, projectPaths } from "../../helpers/paths"; export function registerAdrShowCommand(adr: Command) { adr @@ -12,22 +10,26 @@ export function registerAdrShowCommand(adr: Command) { .description("Show a specific ADR by ID") .argument("", "ADR ID (e.g., GEN-001)") .action(async (id) => { - const projectRoot = process.cwd(); - const paths = projectPaths(projectRoot); - - if (!existsSync(paths.adrsDir)) { + const projectRoot = findProjectRoot(); + if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); process.exit(1); } - const adr = await findAdrFileById(paths.adrsDir, id); + try { + const { adrsDir } = projectPaths(projectRoot); + const adr = await findAdrFileById(adrsDir, id); - if (!adr) { - logError(`ADR with ID '${id}' not found.`); + if (!adr) { + logError(`ADR with ID '${id}' not found.`); + process.exit(1); + } + + const content = await Bun.file(adr.filePath).text(); + console.log(content); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); process.exit(1); } - - const content = await Bun.file(adr.filePath).text(); - console.log(content); }); } diff --git a/src/commands/adr/update.ts b/src/commands/adr/update.ts index 77519fdd..f4e2545d 100644 --- a/src/commands/adr/update.ts +++ b/src/commands/adr/update.ts @@ -1,12 +1,10 @@ -import { existsSync } from "node:fs"; - import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; import { ADR_DOMAINS } from "../../formats/adr"; import { updateAdrFile } from "../../helpers/adr-writer"; import { logError } from "../../helpers/log"; -import { projectPaths } from "../../helpers/paths"; +import { findProjectRoot, projectPaths } from "../../helpers/paths"; const domainOption = new Option("--domain ", "new ADR domain").choices( ADR_DOMAINS @@ -27,13 +25,12 @@ export function registerAdrUpdateCommand(adr: Command) { .option("--rules", "Set rules: true in frontmatter") .option("--json", "Output as JSON") .action(async (opts) => { - const projectRoot = process.cwd(); - const paths = projectPaths(projectRoot); - - if (!existsSync(paths.root)) { + const projectRoot = findProjectRoot(); + if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); process.exit(1); } + const paths = projectPaths(projectRoot); const files = opts.files ? opts.files diff --git a/src/commands/init.ts b/src/commands/init.ts index 3a8df6d5..57713129 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,19 +5,12 @@ import { Option } from "@commander-js/extra-typings"; import inquirer from "inquirer"; import { loadCredentials } from "../helpers/auth"; -import { initProject } from "../helpers/init-project"; +import { EDITOR_LABELS, initProject } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; import { logError, logInfo, logWarn } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; -const EDITOR_LABELS: Record = { - claude: "Claude Code", - cursor: "Cursor", - vscode: "VS Code", - copilot: "Copilot CLI", -}; - const EDITOR_DIRS: Record = { claude: ".claude/", cursor: ".cursor/", diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index eb109c61..a93b50a2 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -4,8 +4,9 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; import { loadCredentials } from "../../helpers/auth"; -import type { EditorTarget } from "../../helpers/init-project"; +import { EDITOR_LABELS } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; +import { findProjectRoot } from "../../helpers/paths"; import { buildMarketplaceUrl, buildVscodeMarketplaceUrl, @@ -17,13 +18,6 @@ import { } from "../../helpers/plugin-install"; import { configureVscodeSettings } from "../../helpers/vscode-settings"; -const EDITOR_LABELS: Record = { - claude: "Claude Code", - cursor: "Cursor", - vscode: "VS Code", - copilot: "Copilot CLI", -}; - const editorOption = new Option("--editor ", "target editor") .choices(["claude", "cursor", "vscode", "copilot"] as const) .default("claude" as const); @@ -83,8 +77,9 @@ export function registerPluginInstallCommand(plugin: Command) { } case "cursor": { + const projectRoot = findProjectRoot() ?? process.cwd(); const files = await installCursorPlugin( - process.cwd(), + projectRoot, credentials.token ); logInfo( @@ -96,7 +91,10 @@ export function registerPluginInstallCommand(plugin: Command) { case "vscode": { const url = buildVscodeMarketplaceUrl(credentials); - await configureVscodeSettings(process.cwd(), url); + await configureVscodeSettings( + findProjectRoot() ?? process.cwd(), + url + ); logInfo( `Archgate plugin configured for ${label}.`, "Marketplace URL added to VS Code user settings." diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts index f12f21d8..b381fc13 100644 --- a/src/commands/plugin/url.ts +++ b/src/commands/plugin/url.ts @@ -9,7 +9,7 @@ import { } from "../../helpers/plugin-install"; const editorOption = new Option("--editor ", "target editor") - .choices(["claude", "cursor", "vscode", "copilot"] as const) + .choices(["claude", "vscode", "copilot"] as const) .default("claude" as const); export function registerPluginUrlCommand(plugin: Command) { @@ -20,20 +20,25 @@ export function registerPluginUrlCommand(plugin: Command) { ) .addOption(editorOption) .action(async (opts) => { - const credentials = await loadCredentials(); - if (!credentials) { - logError( - "Not logged in.", - "Run `archgate login` first to authenticate." - ); - process.exit(1); - } + try { + const credentials = await loadCredentials(); + if (!credentials) { + logError( + "Not logged in.", + "Run `archgate login` first to authenticate." + ); + process.exit(1); + } - const url = - opts.editor === "vscode" - ? buildVscodeMarketplaceUrl(credentials) - : buildMarketplaceUrl(credentials); + const url = + opts.editor === "vscode" + ? buildVscodeMarketplaceUrl(credentials) + : buildMarketplaceUrl(credentials); - console.log(url); + console.log(url); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } }); } diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index 45d6a10d..91f62861 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -29,12 +29,17 @@ export function registerReviewContextCommand(program: Command) { process.exit(1); } - const context = await buildReviewContext(projectRoot, { - staged: opts.staged, - runChecks: opts.runChecks, - domain: opts.domain, - }); + try { + const context = await buildReviewContext(projectRoot, { + staged: opts.staged, + runChecks: opts.runChecks, + domain: opts.domain, + }); - console.log(JSON.stringify(context, null, 2)); + console.log(JSON.stringify(context, null, 2)); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } }); } diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index eead8adf..b19178b2 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -16,16 +16,21 @@ export function registerClaudeCodeSessionContextCommand(parent: Command) { .description("Read Claude Code session transcript for the project") .addOption(maxEntriesOption) .action(async (opts) => { - const projectRoot = findProjectRoot(); - const result = await readClaudeCodeSession(projectRoot, { - maxEntries: opts.maxEntries, - }); + try { + const projectRoot = findProjectRoot(); + const result = await readClaudeCodeSession(projectRoot, { + maxEntries: opts.maxEntries, + }); - if (!result.ok) { - logError(result.error); + if (!result.ok) { + logError(result.error); + process.exit(1); + } + + console.log(JSON.stringify(result.data, null, 2)); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); process.exit(1); } - - console.log(JSON.stringify(result.data, null, 2)); }); } diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 296e6433..4c64e475 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -17,17 +17,22 @@ export function registerCursorSessionContextCommand(parent: Command) { .addOption(maxEntriesOption) .option("--session-id ", "Specific session UUID to read") .action(async (opts) => { - const projectRoot = findProjectRoot(); - const result = await readCursorSession(projectRoot, { - maxEntries: opts.maxEntries, - sessionId: opts.sessionId, - }); + try { + const projectRoot = findProjectRoot(); + const result = await readCursorSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId: opts.sessionId, + }); - if (!result.ok) { - logError(result.error); + if (!result.ok) { + logError(result.error); + process.exit(1); + } + + console.log(JSON.stringify(result.data, null, 2)); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); process.exit(1); } - - console.log(JSON.stringify(result.data, null, 2)); }); } diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 641bfbd8..7d8f2249 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -15,10 +15,6 @@ import { logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; import { getPlatformInfo, resolveCommand } from "../helpers/platform"; -// --------------------------------------------------------------------------- -// Install method detection -// --------------------------------------------------------------------------- - type InstallMethod = | { type: "binary"; binaryPath: string } | { type: "proto"; protoCmd: string } @@ -78,7 +74,8 @@ function isLocalInstall(): boolean { return process.execPath.includes("node_modules"); } -function findProjectRoot(): string | null { +/** Walk up from the binary location to find the nearest package.json (for local installs). */ +function findPackageRoot(): string | null { let dir = dirname(process.execPath); while (true) { if (existsSync(join(dir, "package.json"))) return dir; @@ -101,7 +98,7 @@ async function detectLocalPm(): Promise<{ args: string[]; manualHint: string; } | null> { - const root = findProjectRoot(); + const root = findPackageRoot(); if (!root) return null; const match = LOCKFILE_TO_PM.find(([lockfile]) => @@ -176,10 +173,6 @@ async function detectInstallMethod(): Promise { }; } -// --------------------------------------------------------------------------- -// Upgrade flows -// --------------------------------------------------------------------------- - async function upgradeBinary(tag: string): Promise { const artifact = getArtifactInfo(); if (!artifact) { @@ -229,69 +222,66 @@ async function runExternalUpgrade( } } -// --------------------------------------------------------------------------- -// Command registration -// --------------------------------------------------------------------------- - export function registerUpgradeCommand(program: Command) { program .command("upgrade") .description("Upgrade Archgate to the latest version") .action(async () => { - console.log("Checking for latest Archgate release..."); - - const tag = await fetchLatestGitHubVersion(); - if (!tag) { - logError( - "Failed to fetch release info from GitHub.", - "Check your network connection." - ); + try { + console.log("Checking for latest Archgate release..."); + + const tag = await fetchLatestGitHubVersion(); + if (!tag) { + logError( + "Failed to fetch release info from GitHub.", + "Check your network connection." + ); + process.exit(1); + } + + const packageJson = await import("../../package.json"); + const currentVersion = packageJson.default.version; + const latestVersion = tag.replace(/^v/, ""); + const order = semver.order(currentVersion, latestVersion); + + if (order === null) { + logError( + `Could not compare versions: ${currentVersion} vs ${latestVersion}` + ); + process.exit(2); + } + + if (order >= 0) { + console.log(`Archgate is already up-to-date (${currentVersion}).`); + process.exit(0); + } + + console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); + + const method = await detectInstallMethod(); + + if (method.type === "binary") { + await upgradeBinary(tag); + } else if (method.type === "proto") { + await runExternalUpgrade( + [method.protoCmd, "install", "archgate", "latest", "--pin"], + "proto install archgate latest --pin" + ); + } else { + await runExternalUpgrade( + [method.cmd, ...method.args], + method.manualHint + ); + } + + console.log(`Archgate upgraded to ${latestVersion} successfully.`); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); process.exit(1); } - - const packageJson = await import("../../package.json"); - const currentVersion = packageJson.default.version; - const latestVersion = tag.replace(/^v/, ""); - const order = semver.order(currentVersion, latestVersion); - - if (order === null) { - logError( - `Could not compare versions: ${currentVersion} vs ${latestVersion}` - ); - process.exit(2); - } - - if (order >= 0) { - console.log(`Archgate is already up-to-date (${currentVersion}).`); - process.exit(0); - } - - console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - - const method = await detectInstallMethod(); - - if (method.type === "binary") { - await upgradeBinary(tag); - } else if (method.type === "proto") { - await runExternalUpgrade( - [method.protoCmd, "install", "archgate", "latest", "--pin"], - "proto install archgate latest --pin" - ); - } else { - await runExternalUpgrade( - [method.cmd, ...method.args], - method.manualHint - ); - } - - console.log(`Archgate upgraded to ${latestVersion} successfully.`); }); } -// --------------------------------------------------------------------------- -// Exports for testing -// --------------------------------------------------------------------------- - export { isBinaryInstall as _isBinaryInstall, isProtoInstall as _isProtoInstall, diff --git a/src/formats/adr.ts b/src/formats/adr.ts index 6cbc5d87..62d02777 100644 --- a/src/formats/adr.ts +++ b/src/formats/adr.ts @@ -53,7 +53,7 @@ function formatZodErrors(error: z.ZodError): string[] { * Throws on invalid frontmatter. */ export function parseAdr(content: string, filePath: string): AdrDocument { - const frontmatterRegex = /^---\n([\s\S]*?)\n---/; + const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---/; const match = content.match(frontmatterRegex); if (!match) { diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts index 73504567..1f26b13d 100644 --- a/src/helpers/binary-upgrade.ts +++ b/src/helpers/binary-upgrade.ts @@ -68,7 +68,7 @@ const GITHUB_RELEASES_API = `https://api.github.com/repos/${GITHUB_REPO}/release export async function fetchLatestGitHubVersion(): Promise { const response = await fetch(GITHUB_RELEASES_API, { headers: { "User-Agent": "archgate-cli" }, - signal: AbortSignal.timeout(15000), + signal: AbortSignal.timeout(15_000), }); if (!response.ok) return null; @@ -93,7 +93,7 @@ export async function downloadReleaseBinary( const response = await fetch(archiveUrl, { headers: { "User-Agent": "archgate-cli" }, - signal: AbortSignal.timeout(60000), + signal: AbortSignal.timeout(60_000), }); if (!response.ok) { diff --git a/src/helpers/git.ts b/src/helpers/git.ts index fdc80c0e..273491ab 100644 --- a/src/helpers/git.ts +++ b/src/helpers/git.ts @@ -21,33 +21,3 @@ export async function installGit() { throw new Error(`Failed to install git (exit code ${exitCode})`); } } - -/** - * Get list of changed files (unstaged + staged) relative to project root. - */ -export async function getChangedFiles(projectRoot: string): Promise { - try { - const spawnOpts = { - cwd: projectRoot, - stdout: "pipe" as const, - stderr: "pipe" as const, - }; - const unstaged = Bun.spawn(["git", "diff", "--name-only"], spawnOpts); - const staged = Bun.spawn( - ["git", "diff", "--cached", "--name-only"], - spawnOpts - ); - const [unstagedText, stagedText] = await Promise.all([ - new Response(unstaged.stdout).text(), - new Response(staged.stdout).text(), - ]); - await Promise.all([unstaged.exited, staged.exited]); - const files = [ - ...unstagedText.trim().split("\n"), - ...stagedText.trim().split("\n"), - ].filter(Boolean); - return [...new Set(files)]; - } catch { - return []; - } -} diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 16bbd819..2cac1e94 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -11,6 +11,13 @@ import { configureVscodeSettings } from "./vscode-settings"; export type EditorTarget = "claude" | "cursor" | "vscode" | "copilot"; +export const EDITOR_LABELS: Record = { + claude: "Claude Code", + cursor: "Cursor", + vscode: "VS Code", + copilot: "Copilot CLI", +}; + export interface InitOptions { editor?: EditorTarget; /** When true, attempt to install the archgate plugin using stored credentials. */ diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 01fa8962..601614f0 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -1,25 +1,18 @@ import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { logDebug } from "./log"; export function internalPath(...path: string[]) { - const internalFolder = join( - process.env.HOME ?? process.env.USERPROFILE ?? "~", - ".archgate" - ); + // Use process.env.HOME/USERPROFILE first (testable via env override), + // fall back to os.homedir() which handles platform-specific resolution. + const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); + const internalFolder = join(home, ".archgate"); return join(internalFolder, ...path); } -export const paths = { - // TODO: this must follow the git tags matching the CLI version - templatesRemoteArchive: - "https://github.com/archgate/templates/archive/refs/heads/main.zip", - cacheFolder: internalPath("cache"), - templatesZipFile: internalPath("cache", "templates.zip"), - templatesUnzippedFolder: internalPath("cache", "templates-main"), - template: (name: string) => internalPath("cache", "templates-main", name), -} as const; +export const paths = { cacheFolder: internalPath("cache") } as const; export function projectPath(projectRoot: string, ...path: string[]) { return join(projectRoot, ".archgate", ...path); diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index 9635ff37..c974b347 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -118,15 +118,16 @@ export async function configureVscodeSettings( ): Promise { const vscodeDir = join(projectRoot, ".vscode"); - if (!existsSync(vscodeDir)) { - mkdirSync(vscodeDir, { recursive: true }); - } - // --- User-level: chat.plugins.marketplaces --- if (marketplaceUrl) { await addMarketplaceToUserSettings(marketplaceUrl); } + // Only create .vscode/ dir when there's a reason to (marketplace URL present) + if (marketplaceUrl && !existsSync(vscodeDir)) { + mkdirSync(vscodeDir, { recursive: true }); + } + return vscodeDir; } diff --git a/tests/commands/adr/list.test.ts b/tests/commands/adr/list.test.ts index 4b48d0bf..07f9cc2c 100644 --- a/tests/commands/adr/list.test.ts +++ b/tests/commands/adr/list.test.ts @@ -195,8 +195,8 @@ describe("adr list action handler", () => { expect(allOutput).toContain("No ADRs found."); }); - test("prints 'No ADRs found.' when adrs directory does not exist", async () => { - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + test("prints 'No ADRs found.' when adrs directory is empty", async () => { + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); process.chdir(tempDir); const parent = makeProgram(); diff --git a/tests/commands/plugin/url.test.ts b/tests/commands/plugin/url.test.ts index cdd6b094..c9bc06f7 100644 --- a/tests/commands/plugin/url.test.ts +++ b/tests/commands/plugin/url.test.ts @@ -33,11 +33,6 @@ describe("registerPluginUrlCommand", () => { registerPluginUrlCommand(program); const sub = program.commands.find((c) => c.name() === "url")!; const editorOpt = sub.options.find((o) => o.long === "--editor")!; - expect(editorOpt.argChoices).toEqual([ - "claude", - "cursor", - "vscode", - "copilot", - ]); + expect(editorOpt.argChoices).toEqual(["claude", "vscode", "copilot"]); }); }); diff --git a/tests/helpers/git.test.ts b/tests/helpers/git.test.ts index ccaeb2a2..7aca5829 100644 --- a/tests/helpers/git.test.ts +++ b/tests/helpers/git.test.ts @@ -1,69 +1,10 @@ -import { - describe, - expect, - test, - beforeEach, - afterEach, - setDefaultTimeout, -} from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; -import { getChangedFiles, installGit } from "../../src/helpers/git"; -import { git, safeRmSync } from "../test-utils"; - -setDefaultTimeout(15_000); - -describe("getChangedFiles", () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-git-test-")); - await git(["init"], tempDir); - await git(["config", "user.email", "test@test.com"], tempDir); - await git(["config", "user.name", "Test"], tempDir); - // Create an initial commit so HEAD exists - writeFileSync(join(tempDir, "README.md"), "init"); - await git(["add", "."], tempDir); - await git(["commit", "-m", "init"], tempDir); - }); - - afterEach(() => { - safeRmSync(tempDir); - }); - - test("returns empty array when no changes", async () => { - const files = await getChangedFiles(tempDir); - expect(files).toEqual([]); - }); - - test("returns modified files", async () => { - writeFileSync(join(tempDir, "README.md"), "changed"); - const files = await getChangedFiles(tempDir); - expect(files).toContain("README.md"); - }); - - test("returns staged files", async () => { - writeFileSync(join(tempDir, "new.ts"), "export const x = 1;"); - await git(["add", "new.ts"], tempDir); - const files = await getChangedFiles(tempDir); - expect(files).toContain("new.ts"); - }); - - test("deduplicates files that are both staged and modified", async () => { - writeFileSync(join(tempDir, "file.ts"), "v1"); - await git(["add", "file.ts"], tempDir); - writeFileSync(join(tempDir, "file.ts"), "v2"); - const files = await getChangedFiles(tempDir); - const count = files.filter((f) => f === "file.ts").length; - expect(count).toBe(1); - }); -}); +import { installGit } from "../../src/helpers/git"; describe("installGit", () => { - test("does nothing when git is already available", async () => { - // Git is present in the test environment — installGit should return without throwing + test("does not throw when git is available", async () => { + // git is expected to be available in the test environment await expect(installGit()).resolves.toBeUndefined(); }); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index 51a1f437..5cbf1497 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -67,16 +67,10 @@ describe("configureVscodeSettings", () => { rmSync(tempDir, { recursive: true, force: true }); }); - test("creates .vscode/ dir when nothing exists", async () => { + test("does not create .vscode/ dir when no marketplace URL is provided", async () => { await configureVscodeSettings(tempDir); - expect(existsSync(join(tempDir, ".vscode"))).toBe(true); - }); - - test("does not create mcp.json", async () => { - await configureVscodeSettings(tempDir); - - expect(existsSync(join(tempDir, ".vscode", "mcp.json"))).toBe(false); + expect(existsSync(join(tempDir, ".vscode"))).toBe(false); }); test("returns path to .vscode/ directory", async () => {