Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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."
Expand Down
48 changes: 31 additions & 17 deletions src/engine/git-files.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Set<string> | 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;
Expand Down Expand Up @@ -40,10 +57,10 @@ export async function resolveScopedFiles(
/** Get changed files from git staging area. */
export async function getStagedFiles(projectRoot: string): Promise<string[]> {
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 [];
Expand All @@ -53,14 +70,11 @@ export async function getStagedFiles(projectRoot: string): Promise<string[]> {
/** Get all changed files (staged + unstaged). */
export async function getChangedFiles(projectRoot: string): Promise<string[]> {
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),
Expand Down
25 changes: 19 additions & 6 deletions src/helpers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,25 @@ export function installGit() {
*/
export async function getChangedFiles(projectRoot: string): Promise<string[]> {
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 [];
Expand Down
46 changes: 34 additions & 12 deletions src/helpers/plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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<boolean> {
const result = await $`claude --version`.nothrow().quiet();
return result.exitCode === 0;
try {
const { exitCode } = await run(["claude", "--version"]);
return exitCode === 0;
} catch {
return false;
}
}

/**
Expand All @@ -47,19 +68,20 @@ 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})`
);
}

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})`
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down