diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 5bec1e46..c4a6c9ec 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,5 +1,5 @@ import type { Command } from "@commander-js/extra-typings"; -import { $, semver } from "bun"; +import { semver } from "bun"; import { logError } from "../helpers/log"; const NPM_REGISTRY = "https://registry.npmjs.org/archgate/latest"; @@ -58,9 +58,13 @@ export function registerUpgradeCommand(program: Command) { console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - const result = await $`npm install -g archgate@latest`.nothrow(); + const proc = Bun.spawn(["npm", "install", "-g", "archgate@latest"], { + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; - if (result.exitCode !== 0) { + if (exitCode !== 0) { logError( "Failed to install the latest version via npm.", "Try running `npm install -g archgate@latest` manually." diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 589c00d5..07464250 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -1,15 +1,32 @@ /** Git file-listing utilities for ADR scope resolution and change detection. */ +/** + * Run a git command using Bun.spawn (cross-platform, no shell). + * Bun.$ hangs on Windows due to pipe handling issues — this is the safe alternative. + */ +async function runGit(args: string[], cwd: string): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const text = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`git ${args[0]} exited with code ${exitCode}`); + } + return text; +} + /** Get all git-tracked (non-ignored) files in the project. */ export async function getGitTrackedFiles( projectRoot: string ): Promise | null> { try { - const result = - await Bun.$`git ls-files --cached --others --exclude-standard` - .cwd(projectRoot) - .quiet() - .text(); + const result = await runGit( + ["ls-files", "--cached", "--others", "--exclude-standard"], + projectRoot + ); return new Set(result.trim().split("\n").filter(Boolean)); } catch { return null; @@ -40,10 +57,10 @@ export async function resolveScopedFiles( /** Get changed files from git staging area. */ export async function getStagedFiles(projectRoot: string): Promise { try { - const result = await Bun.$`git diff --cached --name-only` - .cwd(projectRoot) - .quiet() - .text(); + const result = await runGit( + ["diff", "--cached", "--name-only"], + projectRoot + ); return result.trim().split("\n").filter(Boolean); } catch { return []; @@ -53,14 +70,11 @@ export async function getStagedFiles(projectRoot: string): Promise { /** Get all changed files (staged + unstaged). */ export async function getChangedFiles(projectRoot: string): Promise { try { - const staged = await Bun.$`git diff --cached --name-only` - .cwd(projectRoot) - .quiet() - .text(); - const unstaged = await Bun.$`git diff --name-only` - .cwd(projectRoot) - .quiet() - .text(); + const staged = await runGit( + ["diff", "--cached", "--name-only"], + projectRoot + ); + const unstaged = await runGit(["diff", "--name-only"], projectRoot); const all = new Set([ ...staged.trim().split("\n").filter(Boolean), ...unstaged.trim().split("\n").filter(Boolean), diff --git a/src/helpers/git.ts b/src/helpers/git.ts index 4c600fd4..9c04e8bb 100644 --- a/src/helpers/git.ts +++ b/src/helpers/git.ts @@ -21,12 +21,25 @@ export function installGit() { */ export async function getChangedFiles(projectRoot: string): Promise { try { - const result = - await $`git diff --name-only && git diff --cached --name-only` - .cwd(projectRoot) - .quiet() - .text(); - const files = result.trim().split("\n").filter(Boolean); + 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/plugin-install.ts b/src/helpers/plugin-install.ts index 8058d6fc..4c38a503 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -7,12 +7,29 @@ import { join } from "node:path"; import { mkdirSync, unlinkSync } from "node:fs"; -import { $ } from "bun"; import { logDebug } from "./log"; import type { StoredCredentials } from "./auth"; const PLUGINS_API = "https://plugins.archgate.dev"; +/** + * Run a command using Bun.spawn (cross-platform, no shell). + * Returns { exitCode, stdout }. + */ +async function run( + cmd: string[], + opts?: { cwd?: string } +): Promise<{ exitCode: number; stdout: string }> { + const proc = Bun.spawn(cmd, { + cwd: opts?.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return { exitCode, stdout }; +} + // --------------------------------------------------------------------------- // Claude Code — CLI auto-install + manual fallback // --------------------------------------------------------------------------- @@ -28,8 +45,12 @@ export function buildMarketplaceUrl(credentials: StoredCredentials): string { * Check whether the `claude` CLI is available on the system PATH. */ export async function isClaudeCliAvailable(): Promise { - const result = await $`claude --version`.nothrow().quiet(); - return result.exitCode === 0; + try { + const { exitCode } = await run(["claude", "--version"]); + return exitCode === 0; + } catch { + return false; + } } /** @@ -47,9 +68,7 @@ export async function installClaudePlugin( const url = buildMarketplaceUrl(credentials); logDebug("Adding archgate marketplace to claude CLI"); - const addResult = await $`claude plugin marketplace add ${url}` - .nothrow() - .quiet(); + const addResult = await run(["claude", "plugin", "marketplace", "add", url]); if (addResult.exitCode !== 0) { throw new Error( `claude plugin marketplace add failed (exit ${addResult.exitCode})` @@ -57,9 +76,12 @@ export async function installClaudePlugin( } logDebug("Installing archgate plugin via claude CLI"); - const installResult = await $`claude plugin install archgate@archgate` - .nothrow() - .quiet(); + const installResult = await run([ + "claude", + "plugin", + "install", + "archgate@archgate", + ]); if (installResult.exitCode !== 0) { throw new Error( `claude plugin install failed (exit ${installResult.exitCode})` @@ -129,7 +151,7 @@ async function extractTarGz( mkdirSync(destDir, { recursive: true }); // Extract using tar (available on macOS, Linux, and Windows 10+) - const result = await $`tar -xzf ${tmpArchive} -C ${destDir}`.nothrow(); + const result = await run(["tar", "-xzf", tmpArchive, "-C", destDir]); if (result.exitCode !== 0) { throw new Error( @@ -138,8 +160,8 @@ async function extractTarGz( } // List extracted files for reporting - const listResult = await $`tar -tzf ${tmpArchive}`.nothrow().text(); - const files = listResult + const listResult = await run(["tar", "-tzf", tmpArchive]); + const files = listResult.stdout .split("\n") .map((f) => f.trim()) .filter(Boolean);