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
14 changes: 11 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bun
import { Command } from "@commander-js/extra-typings";
import { Command, Option } from "@commander-js/extra-typings";
import { semver } from "bun";

import packageJson from "../package.json";
Expand All @@ -15,7 +15,7 @@ import { registerSessionContextCommand } from "./commands/session-context/index"
import { registerTelemetryCommand } from "./commands/telemetry";
import { registerUpgradeCommand } from "./commands/upgrade";
import { installGit } from "./helpers/git";
import { logError } from "./helpers/log";
import { type LogLevel, logError, setLogLevel } from "./helpers/log";
import { createPathIfNotExists, paths } from "./helpers/paths";
import { getPlatformInfo, isSupportedPlatform } from "./helpers/platform";
import {
Expand Down Expand Up @@ -66,14 +66,22 @@ async function main() {
initSentry();
initTelemetry();

const logLevelOption = new Option("--log-level <level>", "Set log verbosity")
.choices(["error", "warn", "info", "debug"] as const)
.default("info" as const);

const program = new Command()
.name("archgate")
.version(packageJson.version)
.description("AI governance for software development");
.description("AI governance for software development")
.addOption(logLevelOption);

// Track command execution for Sentry breadcrumbs and PostHog analytics
let commandStartTime = 0;
program.hook("preAction", (thisCommand) => {
// Apply log level from global option before any command runs
const rootOpts = program.opts();
setLogLevel(rootOpts.logLevel as LogLevel);
const fullCommand = getFullCommandName(thisCommand);
addBreadcrumb("command", `Running: ${fullCommand}`);
// Collect which options were used (presence only, no values)
Expand Down
41 changes: 38 additions & 3 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
getManualInstallHint,
replaceBinary,
} from "../helpers/binary-upgrade";
import { logError } from "../helpers/log";
import { logDebug, logError } from "../helpers/log";
import { internalPath } from "../helpers/paths";
import { getPlatformInfo, resolveCommand } from "../helpers/platform";
import { trackUpgradeResult } from "../helpers/telemetry";
Expand Down Expand Up @@ -114,39 +114,63 @@ async function detectLocalPm(): Promise<{

async function getGlobalBinDir(cmd: string[]): Promise<string | null> {
try {
logDebug("Getting global bin dir:", cmd.join(" "));
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) return null;
if (exitCode !== 0) {
logDebug("Global bin dir command failed, exit code:", exitCode);
return null;
}
logDebug("Global bin dir:", stdout.trim());
return stdout.trim() || null;
} catch {
return null;
}
}

async function detectInstallMethod(): Promise<InstallMethod> {
logDebug("Detecting install method, execPath:", process.execPath);

if (isBinaryInstall()) {
logDebug("Install method: binary");
return { type: "binary", binaryPath: process.execPath };
}

if (isProtoInstall()) {
const protoCmd = (await resolveCommand("proto")) ?? "proto";
logDebug("Install method: proto, cmd:", protoCmd);
return { type: "proto", protoCmd };
}

if (isLocalInstall()) {
const local = await detectLocalPm();
if (local) return { type: "local", ...local };
if (local) {
logDebug("Install method: local, pm:", local.cmd);
return { type: "local", ...local };
}
}

const binaryPath = process.execPath;
logDebug(
"Checking package managers:",
PACKAGE_MANAGERS.map((pm) => pm.name).join(", ")
);

const candidates = await Promise.all(
PACKAGE_MANAGERS.map(async (pm) => {
const resolved = await resolveCommand(pm.name);
if (!resolved) return null;
const globalBinCmd = [resolved, ...pm.globalBinCmd.slice(1)];
const binDir = await getGlobalBinDir(globalBinCmd);
logDebug(
"PM candidate:",
pm.name,
"resolved:",
resolved,
"binDir:",
binDir
);
return { pm, resolved, binDir };
})
);
Expand All @@ -156,6 +180,7 @@ async function detectInstallMethod(): Promise<InstallMethod> {
);

if (match) {
logDebug("Install method: package-manager, matched:", match.pm.name);
return {
type: "package-manager",
cmd: match.resolved,
Expand All @@ -164,6 +189,7 @@ async function detectInstallMethod(): Promise<InstallMethod> {
};
}

logDebug("Install method: package-manager (fallback to npm)");
const npmCandidate = candidates.find((c) => c?.pm.name === "npm");
const npm = PACKAGE_MANAGERS.find((pm) => pm.name === "npm")!;
return {
Expand All @@ -175,6 +201,7 @@ async function detectInstallMethod(): Promise<InstallMethod> {
}

async function upgradeBinary(tag: string): Promise<void> {
logDebug("Upgrading via binary download for tag:", tag);
const artifact = getArtifactInfo();
if (!artifact) {
logError(
Expand All @@ -184,9 +211,12 @@ async function upgradeBinary(tag: string): Promise<void> {
process.exit(1);
}

logDebug("Artifact:", artifact.name, "ext:", artifact.ext);
const hint = getManualInstallHint();
try {
const newBinaryPath = await downloadReleaseBinary(tag, artifact);
logDebug("Downloaded binary to:", newBinaryPath);
logDebug("Replacing binary:", process.execPath);
replaceBinary(process.execPath, newBinaryPath);
} catch (err) {
logError(
Expand All @@ -201,8 +231,10 @@ async function runExternalUpgrade(
cmd: string[],
manualHint: string
): Promise<void> {
logDebug("Running external upgrade:", cmd.join(" "));
const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
const exitCode = await proc.exited;
logDebug("External upgrade exit code:", exitCode);

if (exitCode !== 0) {
logError(
Expand All @@ -222,6 +254,7 @@ export function registerUpgradeCommand(program: Command) {
console.log("Checking for latest Archgate release...");

const tag = await fetchLatestGitHubVersion();
logDebug("GitHub latest tag:", tag ?? "(null)");
if (!tag) {
logError(
"Failed to fetch release info from GitHub.",
Expand All @@ -233,6 +266,7 @@ export function registerUpgradeCommand(program: Command) {
const packageJson = await import("../../package.json");
const currentVersion = packageJson.default.version;
const latestVersion = tag.replace(/^v/, "");
logDebug("Version comparison:", currentVersion, "vs", latestVersion);
const order = semver.order(currentVersion, latestVersion);

if (order === null) {
Expand All @@ -250,6 +284,7 @@ export function registerUpgradeCommand(program: Command) {
console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`);

const method = await detectInstallMethod();
logDebug("Upgrade method:", method.type);

if (method.type === "binary") {
await upgradeBinary(tag);
Expand Down
24 changes: 21 additions & 3 deletions src/engine/git-files.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/** Git file-listing utilities for ADR scope resolution and change detection. */

import { logDebug } from "../helpers/log";

/**
* 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> {
logDebug("Running: git", args.join(" "));
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
Expand All @@ -27,8 +30,11 @@ export async function getGitTrackedFiles(
["ls-files", "--cached", "--others", "--exclude-standard"],
projectRoot
);
return new Set(result.trim().split("\n").filter(Boolean));
const files = new Set(result.trim().split("\n").filter(Boolean));
logDebug("Git tracked files:", files.size);
return files;
} catch {
logDebug("Git tracked files lookup failed (not a git repo?)");
return null;
}
}
Expand All @@ -54,7 +60,14 @@ export async function resolveScopedFiles(
})
);

return [...new Set(results.flat())].sort();
const all = [...new Set(results.flat())].sort();
logDebug(
"Scoped files resolved:",
all.length,
"from patterns:",
patterns.join(", ")
);
return all;
}

/** Get changed files from git staging area. */
Expand All @@ -64,8 +77,11 @@ export async function getStagedFiles(projectRoot: string): Promise<string[]> {
["diff", "--cached", "--name-only"],
projectRoot
);
return result.trim().split("\n").filter(Boolean);
const files = result.trim().split("\n").filter(Boolean);
logDebug("Staged files:", files.length);
return files;
} catch {
logDebug("Failed to get staged files (not a git repo?)");
return [];
}
}
Expand All @@ -82,8 +98,10 @@ export async function getChangedFiles(projectRoot: string): Promise<string[]> {
...staged.trim().split("\n").filter(Boolean),
...unstaged.trim().split("\n").filter(Boolean),
]);
logDebug("Changed files (staged + unstaged):", all.size);
return [...all].sort();
} catch {
logDebug("Failed to get changed files (not a git repo?)");
return [];
}
}
18 changes: 17 additions & 1 deletion src/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* credential helpers (macOS Keychain, Windows Credential Manager, libsecret).
*/

import { logDebug } from "./log";
import { SignupRequiredError, isSignupRequiredError } from "./signup";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -64,6 +65,7 @@ type DeviceTokenResponse =
* The user will be shown the `user_code` and asked to visit `verification_uri`.
*/
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
logDebug("Requesting device code from:", GITHUB_DEVICE_CODE_URL);
const response = await fetch(GITHUB_DEVICE_CODE_URL, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
Expand All @@ -72,12 +74,15 @@ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
});

if (!response.ok) {
logDebug("Device code request failed, status:", response.status);
throw new Error(
`GitHub device code request failed (HTTP ${response.status})`
);
}

return (await response.json()) as DeviceCodeResponse;
const data = (await response.json()) as DeviceCodeResponse;
logDebug("Device code received, expires in:", data.expires_in, "seconds");
return data;
}

/**
Expand All @@ -91,6 +96,10 @@ export async function pollForAccessToken(
): Promise<string> {
const deadline = Date.now() + expiresIn * 1000;
let pollInterval = interval;
logDebug(
"Starting device code polling, deadline:",
new Date(deadline).toISOString()
);

/* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */
while (Date.now() < deadline) {
Expand All @@ -117,15 +126,18 @@ export async function pollForAccessToken(
const data = (await response.json()) as DeviceTokenResponse;

if ("access_token" in data) {
logDebug("Access token received");
return data.access_token;
}

if ("error" in data) {
if (data.error === "authorization_pending") {
logDebug("Authorization pending, retrying in", pollInterval, "seconds");
continue;
}
if (data.error === "slow_down") {
pollInterval += 5;
logDebug("Slow down requested, new interval:", pollInterval, "seconds");
continue;
}
// expired_token, access_denied, or other terminal error
Expand All @@ -150,6 +162,7 @@ export interface GitHubUserInfo {
export async function getGitHubUser(
accessToken: string
): Promise<GitHubUserInfo> {
logDebug("Fetching GitHub user info");
const response = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
Expand All @@ -169,6 +182,7 @@ export async function getGitHubUser(
if (!data.login) {
throw new Error("GitHub API did not return a username");
}
logDebug("GitHub user:", data.login);
return { login: data.login, email: data.email ?? null };
}

Expand All @@ -181,6 +195,7 @@ export async function getGitHubUser(
* via POST /api/token/claim on the plugins service.
*/
export async function claimArchgateToken(githubToken: string): Promise<string> {
logDebug("Claiming archgate token from:", `${PLUGINS_API}/api/token/claim`);
const response = await fetch(`${PLUGINS_API}/api/token/claim`, {
method: "POST",
headers: {
Expand Down Expand Up @@ -210,5 +225,6 @@ export async function claimArchgateToken(githubToken: string): Promise<string> {
if (!data.token) {
throw new Error("Plugins service did not return a token");
}
logDebug("Archgate token claimed successfully");
return data.token;
}
Loading
Loading