diff --git a/.oxlintrc.json b/.oxlintrc.json index dcb06144..7ab1af30 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,6 +5,7 @@ "perf": "error" }, "rules": { + "max-lines": ["error", { "max": 500 }], "max-lines-per-function": "off", "max-nested-callbacks": "off", "max-depth": "off", diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 8c089e54..6c5ff751 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -3534,6 +3534,68 @@ Archgate upgraded to 0.4.0 successfully. --- +## archgate doctor + +Check the system environment, installation method, and editor integrations. Useful for diagnosing configuration issues and sharing debug context in bug reports. + +```bash +archgate doctor [options] +``` + +### Options + +| Option | Description | +| -------- | ----------------------- | +| `--json` | Machine-readable output | + +### Output sections + +- **System** — OS, architecture, WSL detection, Bun and Node versions +- **Archgate** — CLI version, install method (binary, proto, local, global-pm), config directory, telemetry and login status +- **Project** — Whether an `.archgate/` project exists, ADR count, domains +- **Editor CLIs** — Whether `claude`, `cursor`, `code` (VS Code), `copilot`, and `git` are available on PATH +- **Project Integrations** — Whether editor-specific plugin files exist in the current project (`.claude/settings.local.json`, `.cursor/rules/archgate-governance.mdc`, `.vscode/settings.json`, `.github/copilot/instructions.md`) + +### Example + +```bash +archgate doctor +``` + +``` +System + OS: win32/x64 + Bun: 1.3.11 + Node: v24.3.0 + +Archgate + Version: 0.25.1 + Install: binary + Exec path: /home/user/.archgate/bin/archgate + Config dir: /home/user/.archgate OK + Telemetry: enabled + Logged in: yes + +Project + ADRs: 5 (3 with rules) + Domains: ARCH, GEN + +Editor CLIs + claude: OK + cursor: MISSING + code (vscode):OK + copilot: MISSING + git: OK + +Project Integrations + Claude: OK (.claude/settings.local.json) + Cursor: MISSING (.cursor/rules/archgate-governance.mdc) + VS Code: OK (.vscode/settings.json) + Copilot: MISSING (.github/copilot/instructions.md) +``` + +--- + ## archgate clean Remove the CLI cache directory. @@ -3880,12 +3942,21 @@ Archgate collects **anonymous usage data** to help us understand how the CLI is When you run an Archgate command, we record: -- **Command name** and **flags used** (e.g., `check --json`, not flag values) -- **Exit code** (0, 1, or 2) and **execution duration** -- **Environment**: OS, architecture, Bun version, Archgate version, CI detection, TTY detection +- **Command name** and **which flags were used** (e.g., `check --json` — only flag presence, never flag values) +- **Exit code** (0, 1, or 2) and **execution duration** (milliseconds) +- **Environment**: OS, architecture, Bun version, Archgate version, CI detection, TTY detection, WSL detection +- **Install context**: how the CLI was installed (binary, proto, local dev dependency, or global package manager) +- **Project context**: whether an Archgate project exists in the current directory, how many ADRs it has, and how many have automated rules - **Coarse location**: country and region (resolved server-side from your IP, then the IP is discarded — see [IP anonymization](#ip-anonymization)) - **Anonymous install ID**: a random UUID generated on first run — not derived from any personal data +In addition to the general command lifecycle events (`command_executed` / `command_completed`), specific commands send enriched outcome events: + +- **`check`**: aggregate rule counts (total, passed, failed, warnings, errors), output format used, and whether filters were applied — no file paths or violation content +- **`init`**: editor choice, whether the plugin was installed, and whether the project already existed +- **`upgrade`**: version transition (from → to), install method, and success/failure +- **`login`**: subcommand used (login, logout, refresh, status) and success/failure + ### Error tracking (Sentry) When the CLI crashes (exit code 2), we send: diff --git a/docs/src/content/docs/pt-br/reference/cli-commands.mdx b/docs/src/content/docs/pt-br/reference/cli-commands.mdx index 88b81275..4a5dadee 100644 --- a/docs/src/content/docs/pt-br/reference/cli-commands.mdx +++ b/docs/src/content/docs/pt-br/reference/cli-commands.mdx @@ -650,6 +650,68 @@ Archgate upgraded to 0.4.0 successfully. --- +## archgate doctor + +Verifica o ambiente do sistema, o método de instalação e as integrações com editores. Útil para diagnosticar problemas de configuração e compartilhar contexto de depuração em relatórios de bugs. + +```bash +archgate doctor [options] +``` + +### Opções + +| Opção | Descrição | +| -------- | ------------------------- | +| `--json` | Saída legível por máquina | + +### Seções da saída + +- **System** — SO, arquitetura, detecção de WSL, versões do Bun e Node +- **Archgate** — Versão da CLI, método de instalação (binary, proto, local, global-pm), diretório de configuração, status de telemetria e login +- **Project** — Se existe um projeto `.archgate/`, contagem de ADRs, domínios +- **Editor CLIs** — Se `claude`, `cursor`, `code` (VS Code), `copilot` e `git` estão disponíveis no PATH +- **Project Integrations** — Se os arquivos de plugin específicos de cada editor existem no projeto atual (`.claude/settings.local.json`, `.cursor/rules/archgate-governance.mdc`, `.vscode/settings.json`, `.github/copilot/instructions.md`) + +### Exemplo + +```bash +archgate doctor +``` + +``` +System + OS: win32/x64 + Bun: 1.3.11 + Node: v24.3.0 + +Archgate + Version: 0.25.1 + Install: binary + Exec path: /home/user/.archgate/bin/archgate + Config dir: /home/user/.archgate OK + Telemetry: enabled + Logged in: yes + +Project + ADRs: 5 (3 with rules) + Domains: ARCH, GEN + +Editor CLIs + claude: OK + cursor: MISSING + code (vscode):OK + copilot: MISSING + git: OK + +Project Integrations + Claude: OK (.claude/settings.local.json) + Cursor: MISSING (.cursor/rules/archgate-governance.mdc) + VS Code: OK (.vscode/settings.json) + Copilot: MISSING (.github/copilot/instructions.md) +``` + +--- + ## archgate clean Remove o diretório de cache da CLI. diff --git a/docs/src/content/docs/pt-br/reference/telemetry.mdx b/docs/src/content/docs/pt-br/reference/telemetry.mdx index d21c2e85..ac7c8bbf 100644 --- a/docs/src/content/docs/pt-br/reference/telemetry.mdx +++ b/docs/src/content/docs/pt-br/reference/telemetry.mdx @@ -11,12 +11,21 @@ O Archgate coleta **dados de uso anônimos** para nos ajudar a entender como a C Quando você executa um comando do Archgate, registramos: -- **Nome do comando** e **flags utilizadas** (ex: `check --json`, não os valores das flags) -- **Código de saída** (0, 1 ou 2) e **duração da execução** -- **Ambiente**: SO, arquitetura, versão do Bun, versão do Archgate, detecção de CI, detecção de TTY +- **Nome do comando** e **quais flags foram usadas** (ex: `check --json` — apenas a presença da flag, nunca valores) +- **Código de saída** (0, 1 ou 2) e **duração da execução** (milissegundos) +- **Ambiente**: SO, arquitetura, versão do Bun, versão do Archgate, detecção de CI, detecção de TTY, detecção de WSL +- **Contexto de instalação**: como a CLI foi instalada (binário, proto, dependência local ou gerenciador de pacotes global) +- **Contexto do projeto**: se existe um projeto Archgate no diretório atual, quantas ADRs possui e quantas têm regras automatizadas - **Localização aproximada**: país e região (resolvidos no servidor a partir do seu IP, que é descartado em seguida — veja [Anonimização de IP](#anonimização-de-ip)) - **ID de instalação anônimo**: um UUID aleatório gerado na primeira execução — não derivado de nenhum dado pessoal +Além dos eventos gerais do ciclo de vida do comando (`command_executed` / `command_completed`), comandos específicos enviam eventos enriquecidos: + +- **`check`**: contagens agregadas de regras (total, aprovadas, reprovadas, avisos, erros), formato de saída usado e se filtros foram aplicados — sem caminhos de arquivo ou conteúdo de violação +- **`init`**: escolha do editor, se o plugin foi instalado e se o projeto já existia +- **`upgrade`**: transição de versão (de → para), método de instalação e sucesso/falha +- **`login`**: subcomando usado (login, logout, refresh, status) e sucesso/falha + ### Rastreamento de erros (Sentry) Quando a CLI falha (código de saída 2), enviamos: diff --git a/docs/src/content/docs/reference/cli-commands.mdx b/docs/src/content/docs/reference/cli-commands.mdx index 5345a455..afe93385 100644 --- a/docs/src/content/docs/reference/cli-commands.mdx +++ b/docs/src/content/docs/reference/cli-commands.mdx @@ -1,6 +1,6 @@ --- title: CLI Commands -description: "Complete reference for all Archgate CLI commands: init, check, adr create, adr list, adr show, adr update, login, review-context, and more." +description: "Complete reference for all Archgate CLI commands: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean, and telemetry." --- ## Global options @@ -652,6 +652,68 @@ Archgate upgraded to 0.4.0 successfully. --- +## archgate doctor + +Check the system environment, installation method, and editor integrations. Useful for diagnosing configuration issues and sharing debug context in bug reports. + +```bash +archgate doctor [options] +``` + +### Options + +| Option | Description | +| -------- | ----------------------- | +| `--json` | Machine-readable output | + +### Output sections + +- **System** — OS, architecture, WSL detection, Bun and Node versions +- **Archgate** — CLI version, install method (binary, proto, local, global-pm), config directory, telemetry and login status +- **Project** — Whether an `.archgate/` project exists, ADR count, domains +- **Editor CLIs** — Whether `claude`, `cursor`, `code` (VS Code), `copilot`, and `git` are available on PATH +- **Project Integrations** — Whether editor-specific plugin files exist in the current project (`.claude/settings.local.json`, `.cursor/rules/archgate-governance.mdc`, `.vscode/settings.json`, `.github/copilot/instructions.md`) + +### Example + +```bash +archgate doctor +``` + +``` +System + OS: win32/x64 + Bun: 1.3.11 + Node: v24.3.0 + +Archgate + Version: 0.25.1 + Install: binary + Exec path: /home/user/.archgate/bin/archgate + Config dir: /home/user/.archgate OK + Telemetry: enabled + Logged in: yes + +Project + ADRs: 5 (3 with rules) + Domains: ARCH, GEN + +Editor CLIs + claude: OK + cursor: MISSING + code (vscode):OK + copilot: MISSING + git: OK + +Project Integrations + Claude: OK (.claude/settings.local.json) + Cursor: MISSING (.cursor/rules/archgate-governance.mdc) + VS Code: OK (.vscode/settings.json) + Copilot: MISSING (.github/copilot/instructions.md) +``` + +--- + ## archgate clean Remove the CLI cache directory. diff --git a/docs/src/content/docs/reference/telemetry.mdx b/docs/src/content/docs/reference/telemetry.mdx index 3aac7e01..184102f2 100644 --- a/docs/src/content/docs/reference/telemetry.mdx +++ b/docs/src/content/docs/reference/telemetry.mdx @@ -11,12 +11,21 @@ Archgate collects **anonymous usage data** to help us understand how the CLI is When you run an Archgate command, we record: -- **Command name** and **flags used** (e.g., `check --json`, not flag values) -- **Exit code** (0, 1, or 2) and **execution duration** -- **Environment**: OS, architecture, Bun version, Archgate version, CI detection, TTY detection +- **Command name** and **which flags were used** (e.g., `check --json` — only flag presence, never flag values) +- **Exit code** (0, 1, or 2) and **execution duration** (milliseconds) +- **Environment**: OS, architecture, Bun version, Archgate version, CI detection, TTY detection, WSL detection +- **Install context**: how the CLI was installed (binary, proto, local dev dependency, or global package manager) +- **Project context**: whether an Archgate project exists in the current directory, how many ADRs it has, and how many have automated rules - **Coarse location**: country and region (resolved server-side from your IP, then the IP is discarded — see [IP anonymization](#ip-anonymization)) - **Anonymous install ID**: a random UUID generated on first run — not derived from any personal data +In addition to the general command lifecycle events (`command_executed` / `command_completed`), specific commands send enriched outcome events: + +- **`check`**: aggregate rule counts (total, passed, failed, warnings, errors), output format used, and whether filters were applied — no file paths or violation content +- **`init`**: editor choice, whether the plugin was installed, and whether the project already existed +- **`upgrade`**: version transition (from → to), install method, and success/failure +- **`login`**: subcommand used (login, logout, refresh, status) and success/failure + ### Error tracking (Sentry) When the CLI crashes (exit code 2), we send: diff --git a/src/cli.ts b/src/cli.ts index d33008d5..aa7b3d5a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import packageJson from "../package.json"; import { registerAdrCommand } from "./commands/adr/index"; import { registerCheckCommand } from "./commands/check"; import { registerCleanCommand } from "./commands/clean"; +import { registerDoctorCommand } from "./commands/doctor"; import { registerInitCommand } from "./commands/init"; import { registerLoginCommand } from "./commands/login"; import { registerPluginCommand } from "./commands/plugin/index"; @@ -16,7 +17,7 @@ import { registerUpgradeCommand } from "./commands/upgrade"; import { installGit } from "./helpers/git"; import { logError } from "./helpers/log"; import { createPathIfNotExists, paths } from "./helpers/paths"; -import { isSupportedPlatform } from "./helpers/platform"; +import { getPlatformInfo, isSupportedPlatform } from "./helpers/platform"; import { addBreadcrumb, captureException, @@ -31,16 +32,30 @@ import { } from "./helpers/telemetry"; import { checkForUpdatesIfNeeded } from "./helpers/update-check"; +// Pre-main environment guards — these are user-facing errors (exit 1), not bugs. +// The Bun check must throw (logError requires Bun APIs). The rest use logError +// for clean output. if (typeof Bun === "undefined") throw new Error( "You need to run `archgate` with Bun. Do `bunx archgate [command]`" ); -if (!semver.satisfies(Bun.version, ">=1.2.21")) - throw new Error("You need to update Bun to version 1.2.21 or higher"); +if (!semver.satisfies(Bun.version, ">=1.2.21")) { + logError( + "You need to update Bun to version 1.2.21 or higher.", + `Current version: ${Bun.version}` + ); + process.exit(1); +} -if (!isSupportedPlatform()) - throw new Error("Archgate only supports macOS, Linux, and Windows"); +if (!isSupportedPlatform()) { + const { runtime } = getPlatformInfo(); + logError( + "Archgate only supports macOS, Linux, and Windows.", + `Detected platform: ${runtime}/${process.arch}` + ); + process.exit(1); +} createPathIfNotExists(paths.cacheFolder); @@ -61,7 +76,14 @@ async function main() { program.hook("preAction", (thisCommand) => { const fullCommand = getFullCommandName(thisCommand); addBreadcrumb("command", `Running: ${fullCommand}`); - trackCommand(fullCommand); + // Collect which options were used (presence only, no values) + const opts = thisCommand.opts() as Record; + const optionFlags: Record = {}; + for (const key of Object.keys(opts)) { + const val = opts[key]; + optionFlags[`opt_${key}`] = val !== undefined && val !== false; + } + trackCommand(fullCommand, optionFlags); commandStartTime = performance.now(); }); @@ -80,6 +102,7 @@ async function main() { registerPluginCommand(program); registerUpgradeCommand(program); registerCleanCommand(program); + registerDoctorCommand(program); registerTelemetryCommand(program); const isUpgrade = process.argv.includes("upgrade"); diff --git a/src/commands/check.ts b/src/commands/check.ts index 58d05843..d7d3668d 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -6,11 +6,13 @@ import { reportJSON, reportCI, getExitCode, + buildSummary, } from "../engine/reporter"; import { runChecks } from "../engine/runner"; import { logError } from "../helpers/log"; import { formatJSON, isAgentContext } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; +import { trackCheckResult } from "../helpers/telemetry"; export function registerCheckCommand(program: Command) { program @@ -91,6 +93,9 @@ export function registerCheckCommand(program: Command) { files: filterFiles.length > 0 ? filterFiles : undefined, }); + // Determine output format for telemetry + const outputFormat = opts.ci ? "ci" : useJson ? "json" : "console"; + if (opts.ci) { reportCI(result); } else if (useJson) { @@ -99,6 +104,22 @@ export function registerCheckCommand(program: Command) { reportConsole(result, opts.verbose ?? false); } + // Track aggregate check results (no file paths or violation content) + const summary = buildSummary(result); + trackCheckResult({ + total_rules: summary.total, + passed: summary.passed, + failed: summary.failed, + warnings: summary.warnings, + errors: summary.errors, + rule_errors: summary.ruleErrors, + pass: summary.pass, + output_format: outputFormat, + used_staged: Boolean(opts.staged), + used_file_filter: filterFiles.length > 0, + used_adr_filter: Boolean(opts.adr), + }); + process.exit(getExitCode(result)); }); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 00000000..74b95c70 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,103 @@ +import { styleText } from "node:util"; + +import type { Command } from "@commander-js/extra-typings"; + +import type { DoctorReport } from "../helpers/doctor"; +import { runDoctor } from "../helpers/doctor"; +import { logError } from "../helpers/log"; +import { formatJSON, isAgentContext } from "../helpers/output"; + +const CHECK = styleText("green", "OK"); +const CROSS = styleText("red", "MISSING"); +const WARN = styleText("yellow", "NO"); + +function bool(value: boolean, trueLabel = CHECK, falseLabel = CROSS): string { + return value ? trueLabel : falseLabel; +} + +function printConsole(report: DoctorReport): void { + const { system, archgate, project, editors, integrations } = report; + + console.log(styleText("bold", "\nSystem")); + console.log(` OS: ${system.os}/${system.arch}`); + if (system.is_wsl) { + console.log(` WSL: ${system.wsl_distro ?? "yes"}`); + } + console.log(` Bun: ${system.bun_version}`); + console.log(` Node: ${system.node_version}`); + + console.log(styleText("bold", "\nArchgate")); + console.log(` Version: ${archgate.version}`); + console.log(` Install: ${archgate.install_method}`); + console.log(` Exec path: ${archgate.exec_path}`); + console.log( + ` Config dir: ${archgate.config_dir} ${bool(archgate.config_dir_exists)}` + ); + console.log( + ` Telemetry: ${archgate.telemetry_enabled ? "enabled" : "disabled"}` + ); + console.log(` Logged in: ${bool(archgate.logged_in, "yes", WARN)}`); + + console.log(styleText("bold", "\nProject")); + if (project.has_project) { + console.log( + ` ADRs: ${project.adr_count} (${project.adr_with_rules_count} with rules)` + ); + console.log( + ` Domains: ${project.domains.length > 0 ? project.domains.join(", ") : "none"}` + ); + } else { + console.log( + ` Status: ${styleText("yellow", "no .archgate/ found in current directory")}` + ); + } + + console.log(styleText("bold", "\nEditor CLIs")); + console.log(` claude: ${bool(editors.claude_cli)}`); + console.log(` cursor: ${bool(editors.cursor_cli)}`); + console.log(` code (vscode):${bool(editors.vscode_cli)}`); + console.log(` copilot: ${bool(editors.copilot_cli)}`); + console.log(` git: ${bool(editors.git)}`); + + console.log(styleText("bold", "\nProject Integrations")); + if (project.has_project) { + console.log( + ` Claude: ${bool(integrations.claude_plugin)} (.claude/settings.local.json)` + ); + console.log( + ` Cursor: ${bool(integrations.cursor_plugin)} (.cursor/rules/archgate-governance.mdc)` + ); + console.log( + ` VS Code: ${bool(integrations.vscode_settings)} (.vscode/settings.json)` + ); + console.log( + ` Copilot: ${bool(integrations.copilot_settings)} (.github/copilot/instructions.md)` + ); + } else { + console.log(` ${styleText("dim", "(no project — skipped)")}`); + } + + console.log(""); +} + +export function registerDoctorCommand(program: Command) { + program + .command("doctor") + .description("Check system environment and diagnose issues") + .option("--json", "Output results as JSON") + .action(async (opts) => { + try { + const report = await runDoctor(); + const useJson = opts.json || isAgentContext(); + + if (useJson) { + console.log(formatJSON(report, opts.json ? true : undefined)); + } else { + printConsole(report); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 915ed3dc..199f529b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; @@ -5,10 +7,12 @@ import { Option } from "@commander-js/extra-typings"; import inquirer from "inquirer"; import { loadCredentials } from "../helpers/credential-store"; +import { detectEditors, promptEditorSelection } from "../helpers/editor-detect"; 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 { trackInitResult } from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; const EDITOR_DIRS: Record = { @@ -26,9 +30,10 @@ const SIGNUP_EDITORS: Record = { copilot: "copilot-cli", }; -const editorOption = new Option("--editor ", "editor integration") - .choices(["claude", "cursor", "vscode", "copilot"] as const) - .default("claude" as const); +const editorOption = new Option( + "--editor ", + "editor integration (omit to auto-detect and select)" +).choices(["claude", "cursor", "vscode", "copilot"] as const); export function registerInitCommand(program: Command) { program @@ -41,6 +46,20 @@ export function registerInitCommand(program: Command) { ) .action(async (opts) => { try { + // Resolve editors: explicit flag, interactive prompt, or default + let editors: EditorTarget[]; + if (opts.editor) { + editors = [opts.editor]; + } else if (process.stdin.isTTY) { + const detected = await detectEditors(); + editors = await promptEditorSelection(detected); + } else { + editors = ["claude"]; + } + + const hadExistingProject = existsSync( + join(process.cwd(), ".archgate", "adrs") + ); let hasCredentials = (await loadCredentials()) !== null; // If no credentials and --install-plugin not explicitly set, offer to log in @@ -62,7 +81,7 @@ export function registerInitCommand(program: Command) { if (wantPlugin) { const result = await runLoginFlow({ - editor: SIGNUP_EDITORS[opts.editor], + editor: SIGNUP_EDITORS[editors[0]], }); hasCredentials = result.ok; } @@ -70,37 +89,50 @@ export function registerInitCommand(program: Command) { const installPlugin = opts.installPlugin ?? hasCredentials; - const result = await initProject(process.cwd(), { - editor: opts.editor, - installPlugin, - }); - - const label = EDITOR_LABELS[opts.editor]; - const dir = EDITOR_DIRS[opts.editor]; - - console.log(`Initialized Archgate governance in ${result.projectRoot}`); - console.log(` adrs/ - architecture decision records`); - console.log(` lint/ - linter-specific rules`); - console.log(` ${dir.padEnd(13)}- ${label} settings configured`); - - // Plugin install output - if (result.plugin?.installed) { - console.log(""); - if (result.plugin.autoInstalled) { - logInfo(`Archgate plugin installed for ${label}.`); - if (result.plugin.detail) { - console.log(` ${result.plugin.detail}`); + // Run init for each selected editor (sequential for ordered output) + for (const editor of editors) { + // oxlint-disable-next-line no-await-in-loop -- sequential init with per-editor output + const result = await initProject(process.cwd(), { + editor, + installPlugin, + }); + + const label = EDITOR_LABELS[editor]; + const dir = EDITOR_DIRS[editor]; + + if (editors.indexOf(editor) === 0) { + console.log( + `Initialized Archgate governance in ${result.projectRoot}` + ); + console.log(` adrs/ - architecture decision records`); + console.log(` lint/ - linter-specific rules`); + } + console.log(` ${dir.padEnd(13)}- ${label} settings configured`); + + // Plugin install output + if (result.plugin?.installed) { + console.log(""); + if (result.plugin.autoInstalled) { + logInfo(`Archgate plugin installed for ${label}.`); + if (result.plugin.detail) { + console.log(` ${result.plugin.detail}`); + } + } else { + printManualInstructions(editor, result.plugin.detail); } - } else { - // CLI not found for this editor — show manual commands - printManualInstructions(opts.editor, result.plugin.detail); + } else if (installPlugin && editors.indexOf(editor) === 0) { + logWarn( + "Plugin not installed — not logged in.", + "Run `archgate login` first, then re-run `archgate init --install-plugin`." + ); } - } else if (installPlugin) { - // User wanted plugin but no credentials - logWarn( - "Plugin not installed — not logged in.", - "Run `archgate login` first, then re-run `archgate init --install-plugin`." - ); + + trackInitResult({ + editor, + plugin_installed: Boolean(result.plugin?.installed), + plugin_auto_installed: Boolean(result.plugin?.autoInstalled), + had_existing_project: hadExistingProject, + }); } } catch (err) { if (isTlsError(err)) { diff --git a/src/commands/login.ts b/src/commands/login.ts index 7c7eb02f..a438e97f 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -6,6 +6,7 @@ import { loadCredentials, clearCredentials } from "../helpers/credential-store"; import { logError, logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; +import { trackLoginResult } from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; export function registerLoginCommand(program: Command) { @@ -26,12 +27,14 @@ export function registerLoginCommand(program: Command) { } const result = await runLoginFlow(); + trackLoginResult({ subcommand: "login", success: result.ok }); if (result.ok) { printNextStep(); } else { process.exit(1); } } catch (err) { + trackLoginResult({ subcommand: "login", success: false }); if (isTlsError(err)) { logError(tlsHintMessage()); process.exit(1); @@ -45,11 +48,17 @@ export function registerLoginCommand(program: Command) { .command("status") .description("Show current authentication status") .action(async () => { - const creds = await loadCredentials(); - if (creds) { - console.log(`Logged in as ${styleText("bold", creds.github_user)}.`); - } else { - console.log("Not logged in. Run `archgate login` to authenticate."); + try { + const creds = await loadCredentials(); + trackLoginResult({ subcommand: "status", success: creds !== null }); + if (creds) { + console.log(`Logged in as ${styleText("bold", creds.github_user)}.`); + } else { + console.log("Not logged in. Run `archgate login` to authenticate."); + } + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); } }); @@ -57,8 +66,14 @@ export function registerLoginCommand(program: Command) { .command("logout") .description("Remove stored credentials") .action(async () => { - await clearCredentials(); - console.log("Logged out successfully."); + try { + await clearCredentials(); + trackLoginResult({ subcommand: "logout", success: true }); + console.log("Logged out successfully."); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } }); login @@ -68,12 +83,14 @@ export function registerLoginCommand(program: Command) { try { await clearCredentials(); const result = await runLoginFlow(); + trackLoginResult({ subcommand: "refresh", success: result.ok }); if (result.ok) { printNextStep(); } else { process.exit(1); } } catch (err) { + trackLoginResult({ subcommand: "refresh", success: false }); if (isTlsError(err)) { logError(tlsHintMessage()); process.exit(1); diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index d8088d94..3a63c86e 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -4,7 +4,12 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; import { loadCredentials } from "../../helpers/credential-store"; +import { + detectEditors, + promptEditorSelection, +} from "../../helpers/editor-detect"; import { EDITOR_LABELS } from "../../helpers/init-project"; +import type { EditorTarget } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; import { findProjectRoot } from "../../helpers/paths"; import { @@ -20,9 +25,84 @@ import { } from "../../helpers/plugin-install"; import { configureVscodeSettings } from "../../helpers/vscode-settings"; -const editorOption = new Option("--editor ", "target editor") - .choices(["claude", "cursor", "vscode", "copilot"] as const) - .default("claude" as const); +const editorOption = new Option( + "--editor ", + "target editor (omit to auto-detect and select)" +).choices(["claude", "cursor", "vscode", "copilot"] as const); + +async function installForEditor( + editor: EditorTarget, + label: string, + token: string +): Promise { + switch (editor) { + case "claude": { + if (await isClaudeCliAvailable()) { + await installClaudePlugin(); + logInfo(`Archgate plugin installed for ${label}.`); + } else { + const url = buildMarketplaceUrl(); + logWarn("Claude CLI not found. To install the plugin manually, run:"); + console.log( + ` ${styleText("bold", "claude plugin marketplace add")} ${url}` + ); + console.log( + ` ${styleText("bold", "claude plugin install")} archgate@archgate` + ); + } + break; + } + case "copilot": { + if (await isCopilotCliAvailable()) { + await installCopilotPlugin(); + logInfo(`Archgate plugin installed for ${label}.`); + } else { + const url = buildVscodeMarketplaceUrl(); + logWarn("Copilot CLI not found. To install the plugin manually, run:"); + console.log( + ` ${styleText("bold", "copilot plugin marketplace add")} ${url}` + ); + console.log( + ` ${styleText("bold", "copilot plugin install")} archgate@archgate` + ); + } + break; + } + case "cursor": { + const projectRoot = findProjectRoot() ?? process.cwd(); + const files = await installCursorPlugin(projectRoot, token); + logInfo( + `Archgate plugin installed for ${label}.`, + `Extracted ${files.length} files to .cursor/` + ); + break; + } + case "vscode": { + const url = buildVscodeMarketplaceUrl(); + await configureVscodeSettings(findProjectRoot() ?? process.cwd(), url); + logInfo( + `Archgate plugin configured for ${label}.`, + "Marketplace URL added to VS Code user settings." + ); + if (await isVscodeCliAvailable()) { + await installVscodeExtension(token); + logInfo(`Archgate extension installed for ${label}.`); + } else { + logWarn( + "VS Code CLI (`code`) not found. To install the extension manually, run:" + ); + console.log( + ` ${styleText("bold", "curl")} -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix` + ); + console.log( + ` ${styleText("bold", "code")} --install-extension archgate.vsix` + ); + console.log(` rm archgate.vsix`); + } + break; + } + } +} export function registerPluginInstallCommand(plugin: Command) { plugin @@ -39,96 +119,29 @@ export function registerPluginInstallCommand(plugin: Command) { process.exit(1); } - const label = EDITOR_LABELS[opts.editor]; - - try { - switch (opts.editor) { - case "claude": { - if (await isClaudeCliAvailable()) { - await installClaudePlugin(); - logInfo(`Archgate plugin installed for ${label}.`); - } else { - const url = buildMarketplaceUrl(); - logWarn( - "Claude CLI not found. To install the plugin manually, run:" - ); - console.log( - ` ${styleText("bold", "claude plugin marketplace add")} ${url}` - ); - console.log( - ` ${styleText("bold", "claude plugin install")} archgate@archgate` - ); - } - break; - } - - case "copilot": { - if (await isCopilotCliAvailable()) { - await installCopilotPlugin(); - logInfo(`Archgate plugin installed for ${label}.`); - } else { - const url = buildVscodeMarketplaceUrl(); - logWarn( - "Copilot CLI not found. To install the plugin manually, run:" - ); - console.log( - ` ${styleText("bold", "copilot plugin marketplace add")} ${url}` - ); - console.log( - ` ${styleText("bold", "copilot plugin install")} archgate@archgate` - ); - } - break; - } - - case "cursor": { - const projectRoot = findProjectRoot() ?? process.cwd(); - const files = await installCursorPlugin( - projectRoot, - credentials.token - ); - logInfo( - `Archgate plugin installed for ${label}.`, - `Extracted ${files.length} files to .cursor/` - ); - break; - } - - case "vscode": { - const url = buildVscodeMarketplaceUrl(); - await configureVscodeSettings( - findProjectRoot() ?? process.cwd(), - url - ); - logInfo( - `Archgate plugin configured for ${label}.`, - "Marketplace URL added to VS Code user settings." - ); + // Resolve editors: explicit flag, interactive prompt, or default + let editors: EditorTarget[]; + if (opts.editor) { + editors = [opts.editor]; + } else if (process.stdin.isTTY) { + const detected = await detectEditors(); + editors = await promptEditorSelection(detected); + } else { + editors = ["claude"]; + } - if (await isVscodeCliAvailable()) { - await installVscodeExtension(credentials.token); - logInfo(`Archgate extension installed for ${label}.`); - } else { - logWarn( - "VS Code CLI (`code`) not found. To install the extension manually, run:" - ); - console.log( - ` ${styleText("bold", "curl")} -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix` - ); - console.log( - ` ${styleText("bold", "code")} --install-extension archgate.vsix` - ); - console.log(` rm archgate.vsix`); - } - break; - } + for (const editor of editors) { + const label = EDITOR_LABELS[editor]; + try { + // oxlint-disable-next-line no-await-in-loop -- sequential install with per-editor output + await installForEditor(editor, label, credentials.token); + } catch (err) { + logError( + `Failed to install plugin for ${label}.`, + err instanceof Error ? err.message : String(err) + ); + process.exit(1); } - } catch (err) { - logError( - `Failed to install plugin for ${label}.`, - err instanceof Error ? err.message : String(err) - ); - process.exit(1); } }); } diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts index dab8cb85..30987693 100644 --- a/src/commands/plugin/url.ts +++ b/src/commands/plugin/url.ts @@ -1,25 +1,43 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; +import { + detectEditors, + promptSingleEditorSelection, +} from "../../helpers/editor-detect"; +import type { EditorTarget } from "../../helpers/init-project"; import { logError } from "../../helpers/log"; import { buildMarketplaceUrl, buildVscodeMarketplaceUrl, } from "../../helpers/plugin-install"; -const editorOption = new Option("--editor ", "target editor") - .choices(["claude", "vscode", "copilot"] as const) - .default("claude" as const); +const editorOption = new Option( + "--editor ", + "target editor (omit to auto-detect and select)" +).choices(["claude", "vscode", "copilot"] as const); export function registerPluginUrlCommand(plugin: Command) { plugin .command("url") .description("Print the plugin repository URL for manual configuration") .addOption(editorOption) - .action((opts) => { + .action(async (opts) => { try { + let editor: EditorTarget; + if (opts.editor) { + editor = opts.editor; + } else if (process.stdin.isTTY) { + const detected = (await detectEditors()).filter( + (e) => e.id !== "cursor" + ); + editor = await promptSingleEditorSelection(detected); + } else { + editor = "claude"; + } + const url = - opts.editor === "vscode" + editor === "vscode" ? buildVscodeMarketplaceUrl() : buildMarketplaceUrl(); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index ad6d9208..f07fcbc8 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -14,6 +14,7 @@ import { import { logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; import { getPlatformInfo, resolveCommand } from "../helpers/platform"; +import { trackUpgradeResult } from "../helpers/telemetry"; type InstallMethod = | { type: "binary"; binaryPath: string } @@ -56,18 +57,18 @@ const PACKAGE_MANAGERS: PackageManager[] = [ ]; function isBinaryInstall(): boolean { - const binDir = internalPath("bin"); - return process.execPath.startsWith(binDir); + return process.execPath.startsWith(internalPath("bin")); } function getProtoHome(): string { - const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? "~"; - return Bun.env.PROTO_HOME ?? join(home, ".proto"); + return ( + Bun.env.PROTO_HOME ?? + join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "~", ".proto") + ); } function isProtoInstall(): boolean { - const protoToolDir = join(getProtoHome(), "tools", "archgate"); - return process.execPath.startsWith(protoToolDir); + return process.execPath.startsWith(join(getProtoHome(), "tools", "archgate")); } function isLocalInstall(): boolean { @@ -180,26 +181,16 @@ async function upgradeBinary(tag: string): Promise { `Unsupported platform: ${getPlatformInfo().runtime}/${process.arch}`, "archgate supports darwin/arm64, linux/x64, and win32/x64." ); - process.exit(2); - } - - const hint = getManualInstallHint(); - let newBinaryPath: string; - try { - newBinaryPath = await downloadReleaseBinary(tag, artifact); - } catch (err) { - logError( - "Failed to download the latest release.", - `${err instanceof Error ? err.message : String(err)}\nTry running \`${hint}\` manually.` - ); process.exit(1); } + const hint = getManualInstallHint(); try { + const newBinaryPath = await downloadReleaseBinary(tag, artifact); replaceBinary(process.execPath, newBinaryPath); } catch (err) { logError( - "Failed to replace the binary.", + "Failed to upgrade binary.", `${err instanceof Error ? err.message : String(err)}\nTry running \`${hint}\` manually.` ); process.exit(1); @@ -274,8 +265,21 @@ export function registerUpgradeCommand(program: Command) { ); } + trackUpgradeResult({ + from_version: currentVersion, + to_version: latestVersion, + install_method: method.type, + success: true, + }); + console.log(`Archgate upgraded to ${latestVersion} successfully.`); } catch (err) { + trackUpgradeResult({ + from_version: "unknown", + to_version: "unknown", + install_method: "unknown", + success: false, + }); logError(err instanceof Error ? err.message : String(err)); process.exit(1); } diff --git a/src/helpers/doctor.ts b/src/helpers/doctor.ts new file mode 100644 index 00000000..1374a34e --- /dev/null +++ b/src/helpers/doctor.ts @@ -0,0 +1,144 @@ +/** + * doctor.ts — Gathers system diagnostic information for debugging. + * + * Collects environment, installation, project, and integration details + * without exposing sensitive data (tokens, paths with usernames are truncated). + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import packageJson from "../../package.json"; +import { loadCredentials } from "./credential-store"; +import { detectEditors } from "./editor-detect"; +import { detectInstallMethod, getProjectContext } from "./install-info"; +import { internalPath } from "./paths"; +import { getPlatformInfo, resolveCommand } from "./platform"; +import { isTelemetryEnabled } from "./telemetry-config"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DoctorReport { + system: { + os: NodeJS.Platform; + arch: string; + is_wsl: boolean; + wsl_distro: string | null; + bun_version: string; + node_version: string; + }; + archgate: { + version: string; + install_method: string; + exec_path: string; + config_dir: string; + config_dir_exists: boolean; + telemetry_enabled: boolean; + logged_in: boolean; + }; + project: { + has_project: boolean; + adr_count: number; + adr_with_rules_count: number; + domains: string[]; + }; + editors: { + claude_cli: boolean; + cursor_cli: boolean; + vscode_cli: boolean; + copilot_cli: boolean; + git: boolean; + }; + integrations: { + claude_plugin: boolean; + cursor_plugin: boolean; + vscode_settings: boolean; + copilot_settings: boolean; + }; +} + +// --------------------------------------------------------------------------- +// Integration detection +// --------------------------------------------------------------------------- + +interface IntegrationInfo { + claudePlugin: boolean; + cursorPlugin: boolean; + vscodeSettings: boolean; + copilotSettings: boolean; +} + +function detectIntegrations(): IntegrationInfo { + const cwd = process.cwd(); + return { + claudePlugin: existsSync(join(cwd, ".claude", "settings.local.json")), + cursorPlugin: existsSync( + join(cwd, ".cursor", "rules", "archgate-governance.mdc") + ), + vscodeSettings: existsSync(join(cwd, ".vscode", "settings.json")), + copilotSettings: existsSync( + join(cwd, ".github", "copilot", "instructions.md") + ), + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function runDoctor(): Promise { + const platform = getPlatformInfo(); + const projectCtx = getProjectContext(); + const integrations = detectIntegrations(); + const configDir = internalPath(); + + // Run async checks in parallel + const [editors, gitCmd, credentials] = await Promise.all([ + detectEditors(), + resolveCommand("git").then((r) => r !== null), + loadCredentials(), + ]); + + const editorMap = Object.fromEntries(editors.map((e) => [e.id, e.available])); + + return { + system: { + os: platform.runtime, + arch: process.arch, + is_wsl: platform.isWSL, + wsl_distro: platform.wslDistro, + bun_version: Bun.version, + node_version: process.version, + }, + archgate: { + version: packageJson.version, + install_method: detectInstallMethod(), + exec_path: process.execPath, + config_dir: configDir, + config_dir_exists: existsSync(configDir), + telemetry_enabled: isTelemetryEnabled(), + logged_in: credentials !== null, + }, + project: { + has_project: projectCtx.hasProject, + adr_count: projectCtx.adrCount, + adr_with_rules_count: projectCtx.adrWithRulesCount, + domains: projectCtx.domains, + }, + editors: { + claude_cli: Boolean(editorMap.claude), + cursor_cli: Boolean(editorMap.cursor), + vscode_cli: Boolean(editorMap.vscode), + copilot_cli: Boolean(editorMap.copilot), + git: gitCmd, + }, + integrations: { + claude_plugin: integrations.claudePlugin, + cursor_plugin: integrations.cursorPlugin, + vscode_settings: integrations.vscodeSettings, + copilot_settings: integrations.copilotSettings, + }, + }; +} diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts new file mode 100644 index 00000000..82765451 --- /dev/null +++ b/src/helpers/editor-detect.ts @@ -0,0 +1,98 @@ +/** + * editor-detect.ts — Detects available editor CLIs and prompts the user to select. + * + * Used by commands that accept --editor to auto-detect when no flag is provided. + * In non-TTY (agent) contexts, defaults to "claude" for backward compatibility. + */ + +import inquirer from "inquirer"; + +import { EDITOR_LABELS } from "./init-project"; +import type { EditorTarget } from "./init-project"; +import { resolveCommand } from "./platform"; +import { + isClaudeCliAvailable, + isCopilotCliAvailable, + isVscodeCliAvailable, +} from "./plugin-install"; + +/** Result of editor availability detection. */ +export interface DetectedEditor { + id: EditorTarget; + label: string; + available: boolean; +} + +/** + * Detect which editor CLIs are available on PATH. + * Runs all checks in parallel for speed. + */ +export async function detectEditors(): Promise { + const [claude, cursor, vscode, copilot] = await Promise.all([ + isClaudeCliAvailable(), + resolveCommand("cursor").then((r) => r !== null), + isVscodeCliAvailable(), + isCopilotCliAvailable(), + ]); + + return [ + { id: "claude" as const, label: EDITOR_LABELS.claude, available: claude }, + { id: "cursor" as const, label: EDITOR_LABELS.cursor, available: cursor }, + { id: "vscode" as const, label: EDITOR_LABELS.vscode, available: vscode }, + { + id: "copilot" as const, + label: EDITOR_LABELS.copilot, + available: copilot, + }, + ]; +} + +/** + * Prompt the user to select one or more editors from the detected list. + * Detected editors are pre-checked; unavailable ones are shown but unchecked. + * Returns at least one selection (validation enforced). + */ +export async function promptEditorSelection( + detected: DetectedEditor[] +): Promise { + const { selected } = await inquirer.prompt([ + { + type: "checkbox", + name: "selected", + message: "Select editors to configure:", + choices: detected.map((e) => ({ + name: e.available ? `${e.label} (detected)` : `${e.label}`, + value: e.id, + checked: e.available, + })), + validate: (input: EditorTarget[]) => + input.length > 0 || "Select at least one editor.", + }, + ]); + return selected; +} + +/** + * Prompt the user to select a single editor. + * Used by commands that operate on one editor at a time (e.g., plugin url). + */ +export async function promptSingleEditorSelection( + detected: DetectedEditor[] +): Promise { + const available = detected.filter((e) => e.available); + const defaultEditor = available.length > 0 ? available[0].id : "claude"; + + const { selected } = await inquirer.prompt([ + { + type: "list", + name: "selected", + message: "Select editor:", + choices: detected.map((e) => ({ + name: e.available ? `${e.label} (detected)` : e.label, + value: e.id, + })), + default: defaultEditor, + }, + ]); + return selected; +} diff --git a/src/helpers/install-info.ts b/src/helpers/install-info.ts new file mode 100644 index 00000000..34de04e4 --- /dev/null +++ b/src/helpers/install-info.ts @@ -0,0 +1,127 @@ +/** + * install-info.ts — Detects the CLI installation method and project context. + * + * Shared across telemetry, sentry, doctor, and other modules that need + * to know how archgate was installed or whether a project exists. + */ + +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { internalPath } from "./paths"; + +// --------------------------------------------------------------------------- +// Install method detection (cached) +// --------------------------------------------------------------------------- + +let cachedInstallMethod: string | null = null; + +/** + * Detect how archgate was installed. + * Returns: "binary" | "proto" | "local" | "global-pm" + * + * Uses process.execPath for compiled binaries and Bun.main for `bun run` + * development mode (where process.execPath is the bun runtime, not archgate). + */ +export function detectInstallMethod(): string { + if (cachedInstallMethod) return cachedInstallMethod; + + // Compiled binary: process.execPath IS archgate (doesn't contain "bun") + // Dev mode: process.execPath is the bun runtime, Bun.main is the script + const archgatePath = process.execPath.includes("bun") + ? Bun.main + : process.execPath; + + const binDir = internalPath("bin"); + if (archgatePath.startsWith(binDir)) { + cachedInstallMethod = "binary"; + return cachedInstallMethod; + } + + const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? "~"; + const protoHome = Bun.env.PROTO_HOME ?? join(home, ".proto"); + if (archgatePath.startsWith(join(protoHome, "tools", "archgate"))) { + cachedInstallMethod = "proto"; + return cachedInstallMethod; + } + + if (archgatePath.includes("node_modules")) { + cachedInstallMethod = "local"; + return cachedInstallMethod; + } + + cachedInstallMethod = "global-pm"; + return cachedInstallMethod; +} + +// --------------------------------------------------------------------------- +// Project context (cached) +// --------------------------------------------------------------------------- + +export interface ProjectContext { + hasProject: boolean; + adrCount: number; + adrWithRulesCount: number; + domains: string[]; +} + +let cachedProjectContext: ProjectContext | null = null; + +/** + * Scan the current working directory for an archgate project. + * Results are cached per process. + */ +export function getProjectContext(): ProjectContext { + if (cachedProjectContext) return cachedProjectContext; + + const adrsDir = join(process.cwd(), ".archgate", "adrs"); + const hasProject = existsSync(adrsDir); + + if (!hasProject) { + cachedProjectContext = { + hasProject: false, + adrCount: 0, + adrWithRulesCount: 0, + domains: [], + }; + return cachedProjectContext; + } + + try { + const entries = readdirSync(adrsDir); + const mdFiles = entries.filter((f) => f.endsWith(".md")); + const rulesFiles = entries.filter((f) => f.endsWith(".rules.ts")); + + const domainSet = new Set(); + for (const f of mdFiles) { + const match = f.match(/^([A-Z]+)-\d+/); + if (match) domainSet.add(match[1]); + } + + cachedProjectContext = { + hasProject: true, + adrCount: mdFiles.length, + adrWithRulesCount: rulesFiles.length, + domains: [...domainSet].sort(), + }; + } catch { + cachedProjectContext = { + hasProject: true, + adrCount: 0, + adrWithRulesCount: 0, + domains: [], + }; + } + + return cachedProjectContext; +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** Reset all caches. For testing only. */ +export function _resetInstallInfoCaches(): void { + cachedInstallMethod = null; + cachedProjectContext = null; +} diff --git a/src/helpers/sentry.ts b/src/helpers/sentry.ts index 54e90e2c..77e54776 100644 --- a/src/helpers/sentry.ts +++ b/src/helpers/sentry.ts @@ -13,13 +13,11 @@ * are wrapped to never affect CLI behavior or exit codes. */ -import { join } from "node:path"; - import * as Sentry from "@sentry/node-core/light"; import packageJson from "../../package.json"; +import { detectInstallMethod } from "./install-info"; import { logDebug } from "./log"; -import { internalPath } from "./paths"; import { getPlatformInfo } from "./platform"; import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; @@ -41,34 +39,13 @@ const SENTRY_DSN = * The path to the archgate executable or script. * - Compiled binary: process.execPath IS the archgate binary * - bun run / bunx: Bun.main is the entry script (src/cli.ts or similar) - * - proto: process.argv[1] points to the shim */ function getArchgatePath(): string { - // When compiled, process.execPath is the archgate binary itself - // (not the bun runtime). Detect by checking if execPath contains "bun". const execPath = process.execPath; if (!execPath.includes("bun")) return execPath; - - // When running via `bun run`, Bun.main is the entry script return Bun.main; } -function detectInstallMethod(): string { - const archgatePath = getArchgatePath(); - const binDir = internalPath("bin"); - - if (archgatePath.startsWith(binDir)) return "binary"; - - const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? "~"; - const protoHome = Bun.env.PROTO_HOME ?? join(home, ".proto"); - const protoToolDir = join(protoHome, "tools", "archgate"); - if (archgatePath.startsWith(protoToolDir)) return "proto"; - - if (archgatePath.includes("node_modules")) return "local"; - - return "package-manager"; -} - // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index fdf63835..fc4fc8d8 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -15,6 +15,7 @@ import { PostHog } from "posthog-node"; import packageJson from "../../package.json"; +import { detectInstallMethod, getProjectContext } from "./install-info"; import { logDebug } from "./log"; import { getPlatformInfo } from "./platform"; import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; @@ -43,7 +44,8 @@ let distinctId = ""; // --------------------------------------------------------------------------- function getCommonProperties(): Record { - const { runtime } = getPlatformInfo(); + const { runtime, isWSL } = getPlatformInfo(); + const ctx = getProjectContext(); return { cli_version: packageJson.version, os: runtime, @@ -51,6 +53,11 @@ function getCommonProperties(): Record { bun_version: Bun.version, is_ci: Boolean(Bun.env.CI), is_tty: Boolean(process.stdout.isTTY), + is_wsl: isWSL, + install_method: detectInstallMethod(), + has_project: ctx.hasProject, + adr_count: ctx.adrCount, + adr_with_rules_count: ctx.adrWithRulesCount, // Signal PostHog to resolve geo then discard the IP $ip: null, }; @@ -111,7 +118,8 @@ export function trackEvent( } /** - * Track a CLI command invocation. + * Track a CLI command invocation with the options used. + * Option values are reduced to booleans/presence — no user data is sent. */ export function trackCommand( command: string, @@ -135,6 +143,60 @@ export function trackCommandResult( }); } +/** + * Track the outcome of `archgate check`. + * Captures aggregate counts — no file paths or violation content. + */ +export function trackCheckResult(properties: { + total_rules: number; + passed: number; + failed: number; + warnings: number; + errors: number; + rule_errors: number; + pass: boolean; + output_format: "console" | "json" | "ci"; + used_staged: boolean; + used_file_filter: boolean; + used_adr_filter: boolean; +}): void { + trackEvent("check_completed", properties); +} + +/** + * Track the outcome of `archgate init`. + */ +export function trackInitResult(properties: { + editor: string; + plugin_installed: boolean; + plugin_auto_installed: boolean; + had_existing_project: boolean; +}): void { + trackEvent("init_completed", properties); +} + +/** + * Track the outcome of `archgate upgrade`. + */ +export function trackUpgradeResult(properties: { + from_version: string; + to_version: string; + install_method: string; + success: boolean; +}): void { + trackEvent("upgrade_completed", properties); +} + +/** + * Track the outcome of `archgate login`. + */ +export function trackLoginResult(properties: { + subcommand: "login" | "logout" | "refresh" | "status"; + success: boolean; +}): void { + trackEvent("login_completed", properties); +} + /** * Flush pending events to PostHog. Call before process exit to ensure * events are delivered. diff --git a/tests/commands/plugin/install.test.ts b/tests/commands/plugin/install.test.ts index 5564363e..28d3679b 100644 --- a/tests/commands/plugin/install.test.ts +++ b/tests/commands/plugin/install.test.ts @@ -19,13 +19,13 @@ describe("registerPluginInstallCommand", () => { expect(sub.description()).toBeTruthy(); }); - test("accepts --editor option with default 'claude'", () => { + test("accepts --editor option without default (auto-detect when omitted)", () => { const program = new Command(); registerPluginInstallCommand(program); const sub = program.commands.find((c) => c.name() === "install")!; const editorOpt = sub.options.find((o) => o.long === "--editor"); expect(editorOpt).toBeDefined(); - expect(editorOpt!.defaultValue).toBe("claude"); + expect(editorOpt!.defaultValue).toBeUndefined(); }); test("--editor option restricts choices to valid editors", () => { diff --git a/tests/commands/plugin/url.test.ts b/tests/commands/plugin/url.test.ts index c9bc06f7..170089ac 100644 --- a/tests/commands/plugin/url.test.ts +++ b/tests/commands/plugin/url.test.ts @@ -19,13 +19,13 @@ describe("registerPluginUrlCommand", () => { expect(sub.description()).toBeTruthy(); }); - test("accepts --editor option with default 'claude'", () => { + test("accepts --editor option without default (auto-detect when omitted)", () => { const program = new Command(); registerPluginUrlCommand(program); const sub = program.commands.find((c) => c.name() === "url")!; const editorOpt = sub.options.find((o) => o.long === "--editor"); expect(editorOpt).toBeDefined(); - expect(editorOpt!.defaultValue).toBe("claude"); + expect(editorOpt!.defaultValue).toBeUndefined(); }); test("--editor option restricts choices to valid editors", () => { diff --git a/tests/helpers/doctor.test.ts b/tests/helpers/doctor.test.ts new file mode 100644 index 00000000..7aaebc5f --- /dev/null +++ b/tests/helpers/doctor.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { runDoctor } from "../../src/helpers/doctor"; +import type { DoctorReport } from "../../src/helpers/doctor"; + +describe("doctor", () => { + describe("runDoctor", () => { + test("returns a complete DoctorReport structure", async () => { + const report = await runDoctor(); + + // System section + expect(report.system).toBeDefined(); + expect(typeof report.system.os).toBe("string"); + expect(typeof report.system.arch).toBe("string"); + expect(typeof report.system.is_wsl).toBe("boolean"); + expect(typeof report.system.bun_version).toBe("string"); + expect(typeof report.system.node_version).toBe("string"); + + // Archgate section + expect(report.archgate).toBeDefined(); + expect(typeof report.archgate.version).toBe("string"); + expect(["binary", "proto", "local", "global-pm"]).toContain( + report.archgate.install_method + ); + expect(typeof report.archgate.exec_path).toBe("string"); + expect(typeof report.archgate.telemetry_enabled).toBe("boolean"); + expect(typeof report.archgate.logged_in).toBe("boolean"); + + // Project section + expect(report.project).toBeDefined(); + expect(typeof report.project.has_project).toBe("boolean"); + expect(typeof report.project.adr_count).toBe("number"); + expect(Array.isArray(report.project.domains)).toBe(true); + + // Editors section + expect(report.editors).toBeDefined(); + expect(typeof report.editors.git).toBe("boolean"); + + // Integrations section + expect(report.integrations).toBeDefined(); + }); + + test("detects the current project when run from repo root", async () => { + // This test runs from the archgate CLI repo, which has .archgate/adrs/ + const report = await runDoctor(); + + expect(report.project.has_project).toBe(true); + expect(report.project.adr_count).toBeGreaterThan(0); + expect(report.project.adr_with_rules_count).toBeGreaterThan(0); + expect(report.project.domains.length).toBeGreaterThan(0); + }); + + test("report is JSON-serializable", async () => { + const report = await runDoctor(); + const json = JSON.stringify(report); + const parsed = JSON.parse(json) as DoctorReport; + expect(parsed.system.os).toBe(report.system.os); + expect(parsed.archgate.version).toBe(report.archgate.version); + }); + }); +}); diff --git a/tests/helpers/editor-detect.test.ts b/tests/helpers/editor-detect.test.ts new file mode 100644 index 00000000..ca6440d4 --- /dev/null +++ b/tests/helpers/editor-detect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; + +import { detectEditors } from "../../src/helpers/editor-detect"; + +describe("editor-detect", () => { + describe("detectEditors", () => { + test("returns all four editors with availability status", async () => { + const editors = await detectEditors(); + + expect(editors).toHaveLength(4); + expect(editors.map((e) => e.id)).toEqual([ + "claude", + "cursor", + "vscode", + "copilot", + ]); + + for (const editor of editors) { + expect(typeof editor.available).toBe("boolean"); + expect(typeof editor.label).toBe("string"); + expect(editor.label.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/tests/helpers/install-info.test.ts b/tests/helpers/install-info.test.ts new file mode 100644 index 00000000..0eacf7d6 --- /dev/null +++ b/tests/helpers/install-info.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test, afterEach } from "bun:test"; + +import { + detectInstallMethod, + getProjectContext, + _resetInstallInfoCaches, +} from "../../src/helpers/install-info"; + +describe("install-info", () => { + afterEach(() => { + _resetInstallInfoCaches(); + }); + + describe("detectInstallMethod", () => { + test("returns a valid install method string", () => { + const method = detectInstallMethod(); + expect(["binary", "proto", "local", "global-pm"]).toContain(method); + }); + + test("result is cached across calls", () => { + const first = detectInstallMethod(); + const second = detectInstallMethod(); + expect(first).toBe(second); + }); + }); + + describe("getProjectContext", () => { + test("detects the current archgate project", () => { + // Running from the CLI repo root, which has .archgate/adrs/ + const ctx = getProjectContext(); + expect(ctx.hasProject).toBe(true); + expect(ctx.adrCount).toBeGreaterThan(0); + expect(ctx.adrWithRulesCount).toBeGreaterThan(0); + expect(ctx.domains.length).toBeGreaterThan(0); + }); + + test("result is cached across calls", () => { + const first = getProjectContext(); + const second = getProjectContext(); + expect(first).toBe(second); + }); + + test("domains are sorted alphabetically", () => { + const ctx = getProjectContext(); + const sorted = [...ctx.domains].sort(); + expect(ctx.domains).toEqual(sorted); + }); + }); +}); diff --git a/tests/helpers/telemetry.test.ts b/tests/helpers/telemetry.test.ts index 8bced011..49a07bea 100644 --- a/tests/helpers/telemetry.test.ts +++ b/tests/helpers/telemetry.test.ts @@ -81,6 +81,68 @@ describe("telemetry", () => { }); }); + describe("trackCheckResult", () => { + test("captures check_completed event without throwing", async () => { + const { initTelemetry, trackCheckResult } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + trackCheckResult({ + total_rules: 5, + passed: 4, + failed: 1, + warnings: 2, + errors: 1, + rule_errors: 0, + pass: false, + output_format: "console", + used_staged: false, + used_file_filter: false, + used_adr_filter: false, + }); + }); + }); + + describe("trackInitResult", () => { + test("captures init_completed event without throwing", async () => { + const { initTelemetry, trackInitResult } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + trackInitResult({ + editor: "claude", + plugin_installed: true, + plugin_auto_installed: true, + had_existing_project: false, + }); + }); + }); + + describe("trackUpgradeResult", () => { + test("captures upgrade_completed event without throwing", async () => { + const { initTelemetry, trackUpgradeResult } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + trackUpgradeResult({ + from_version: "0.24.0", + to_version: "0.25.0", + install_method: "binary", + success: true, + }); + }); + }); + + describe("trackLoginResult", () => { + test("captures login_completed event without throwing", async () => { + const { initTelemetry, trackLoginResult } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + trackLoginResult({ subcommand: "login", success: true }); + }); + }); + describe("flushTelemetry", () => { test("flushes without throwing when initialized", async () => { const { initTelemetry, flushTelemetry } =