diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index dc66b55..5f78854 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -3984,19 +3984,45 @@ Archgate collects **anonymous usage data** to help us understand how the CLI is When you run an Archgate command, we record: - **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 +- **Exit code** (0, 1, 2, or 130) and **execution duration** (milliseconds), plus a short **outcome** tag (`success`, `user_error`, `internal_error`, `cancelled`) +- **Environment**: OS, architecture, Bun version, Archgate version, CI detection (including provider: GitHub Actions / GitLab CI / CircleCI / etc.), TTY detection, WSL detection, shell (bash, zsh, pwsh...), and locale - **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 +- **Project context**: whether an Archgate project exists in the current directory, how many ADRs it has, how many have automated rules, and how many distinct ADR domains are used +- **Repo context** (non-identifying): whether the current directory is a git repository, the host bucket (`github` / `gitlab` / `bitbucket` / `azure-devops` / `other`), a **hashed `repo_id`** (SHA-256 of the normalized remote URL, truncated to 16 hex characters — not reversible), and the default branch name - **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 +- **`check`**: aggregate rule counts (total, passed, failed, warnings, errors), output format used, whether filters were applied, files scanned, load duration, check duration — no file paths or violation content +- **`init`**: editor choice, whether the plugin was installed, whether the project already existed. A separate one-time `project_initialized` event is emitted with the repo host bucket, `repo_is_git`, and a `repo_public` flag. For repos confirmed public on GitHub / GitLab / Bitbucket, this event also carries the remote URL, owner, and repo name — see [Repo identity](#repo-identity). Private and self-hosted repos never have identity shared. +- **`upgrade`**: version transition (from → to), install method, success/failure, and an optional failure reason +- **`login`**: subcommand used (login, logout, refresh, status), success/failure, and a failure bucket (`network`, `tls`, `denied`, `other`) when it fails +- **`telemetry_preference_changed`**: fires once when you enable or disable telemetry, so we can understand opt-out rates + +## Repo identity + +Archgate sends a **hashed** `repo_id` with every event so we can count distinct repositories using the CLI without learning their names. The raw remote URL, owner, and repository name are **not** included in the common event stream. + +On `archgate init`, a one-time `project_initialized` event is emitted. If — and only if — the repository is confirmed **public** on GitHub, GitLab, Bitbucket, or Azure DevOps (via an unauthenticated API probe against the host), that event additionally includes `remote_url`, `repo_owner`, and `repo_name`. This lets us see which public repositories are adopting Archgate without ever exposing private ones. + +**What's never shared:** + +- Private repositories (API probe returns 404, 401, or `private: true`) +- Self-hosted Git hosts (the probe skips these entirely) +- Repositories where the probe times out, is rate-limited, or otherwise fails to return a definitive public answer + +**Don't want the event at all?** Disable telemetry entirely — the whole `project_initialized` event is then suppressed along with everything else: + +```bash +# Per-shell / per-invocation +export ARCHGATE_TELEMETRY=0 + +# Or persistently +archgate telemetry disable +``` + +See [How to opt out](#how-to-opt-out) below for the full details. ### Error tracking (Sentry) @@ -4008,8 +4034,8 @@ When the CLI crashes (exit code 2), we send: ## What we do NOT collect -- **No personal information**: no usernames, emails, IP addresses, or GitHub identifiers -- **No file content**: no ADR content, source code, project names, or file paths +- **No personal information**: no usernames, emails, or IP addresses. GitHub / GitLab / Bitbucket owner/repository names are only sent on the one-time `project_initialized` event for repositories that are confirmed public by their host — see [Repo identity](#repo-identity). Private and self-hosted repos never have identity shared. +- **No file content**: no ADR content, source code, or file paths - **No prompt or AI context**: nothing from agent interactions, prompts, or AI-generated content - **No flag values**: we record that `--json` was used, not what the JSON output contained - **No network activity**: no URLs, API keys, or tokens diff --git a/docs/src/content/docs/pt-br/reference/telemetry.mdx b/docs/src/content/docs/pt-br/reference/telemetry.mdx index ac7c8bb..e7c5f2b 100644 --- a/docs/src/content/docs/pt-br/reference/telemetry.mdx +++ b/docs/src/content/docs/pt-br/reference/telemetry.mdx @@ -12,19 +12,45 @@ 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 **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 +- **Código de saída** (0, 1, 2 ou 130) e **duração da execução** (milissegundos), além de um **tag de desfecho** resumida (`success`, `user_error`, `internal_error`, `cancelled`) +- **Ambiente**: SO, arquitetura, versão do Bun, versão do Archgate, detecção de CI (incluindo provedor: GitHub Actions / GitLab CI / CircleCI / etc.), detecção de TTY, detecção de WSL, shell (bash, zsh, pwsh...) e locale - **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 +- **Contexto do projeto**: se existe um projeto Archgate no diretório atual, quantas ADRs possui, quantas têm regras automatizadas e quantos domínios distintos de ADR estão em uso +- **Contexto do repositório** (não identificável): se o diretório atual é um repositório git, o bucket do host (`github` / `gitlab` / `bitbucket` / `azure-devops` / `other`), um **`repo_id` com hash** (SHA-256 da URL remota normalizada, truncado em 16 caracteres hex — não reversível) e o nome do branch padrão - **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 +- **`check`**: contagens agregadas de regras (total, aprovadas, reprovadas, avisos, erros), formato de saída usado, se filtros foram aplicados, arquivos analisados, duração de carregamento, duração da verificação — sem caminhos de arquivo ou conteúdo de violação +- **`init`**: escolha do editor, se o plugin foi instalado, se o projeto já existia. Um evento separado `project_initialized` é enviado uma única vez, contendo o bucket do host do repositório, `repo_is_git` e um flag `repo_public`. Para repositórios confirmados como públicos no GitHub / GitLab / Bitbucket, esse evento também leva URL remota, owner e nome do repositório — veja [Identidade do repositório](#identidade-do-repositório). Repositórios privados e auto-hospedados nunca têm identidade compartilhada. +- **`upgrade`**: transição de versão (de → para), método de instalação, sucesso/falha e uma razão de falha opcional +- **`login`**: subcomando usado (login, logout, refresh, status), sucesso/falha e um bucket de falha (`network`, `tls`, `denied`, `other`) quando falha +- **`telemetry_preference_changed`**: disparado uma vez quando você ativa ou desativa a telemetria, para que possamos entender as taxas de opt-out + +## Identidade do repositório + +O Archgate envia um `repo_id` **com hash** em todo evento para que possamos contar repositórios distintos usando a CLI sem aprender seus nomes. A URL remota bruta, o owner e o nome do repositório **não** são incluídos no fluxo comum de eventos. + +Em `archgate init`, um evento `project_initialized` único é enviado. Se — e somente se — o repositório for confirmado como **público** no GitHub, GitLab, Bitbucket ou Azure DevOps (via consulta não autenticada à API do host), esse evento também inclui `remote_url`, `repo_owner` e `repo_name`. Isso nos permite ver quais repositórios públicos estão adotando o Archgate sem jamais expor os privados. + +**O que nunca é compartilhado:** + +- Repositórios privados (a consulta retorna 404, 401 ou `private: true`) +- Hosts Git auto-hospedados (a verificação pula esses completamente) +- Repositórios em que a consulta expira, é limitada por rate-limit ou não retorna uma resposta pública definitiva + +**Não quer o evento de forma alguma?** Desative a telemetria por completo — o evento `project_initialized` é suprimido junto com todos os outros: + +```bash +# Por shell / por invocação +export ARCHGATE_TELEMETRY=0 + +# Ou persistentemente +archgate telemetry disable +``` + +Veja [Como desativar](#como-desativar) abaixo para detalhes completos. ### Rastreamento de erros (Sentry) @@ -36,8 +62,8 @@ Quando a CLI falha (código de saída 2), enviamos: ## O que NÃO coletamos -- **Nenhuma informação pessoal**: nenhum nome de usuário, email, endereço IP ou identificador do GitHub -- **Nenhum conteúdo de arquivo**: nenhum conteúdo de ADR, código-fonte, nome de projeto ou caminho de arquivo +- **Nenhuma informação pessoal**: nenhum nome de usuário, email ou endereço IP. Owners e nomes de repositórios no GitHub / GitLab / Bitbucket só são enviados no evento único `project_initialized` para repositórios confirmados como públicos pelo host — veja [Identidade do repositório](#identidade-do-repositório). Repositórios privados e auto-hospedados nunca têm identidade compartilhada. +- **Nenhum conteúdo de arquivo**: nenhum conteúdo de ADR, código-fonte ou caminho de arquivo - **Nenhum contexto de IA**: nada de interações com agentes, prompts ou conteúdo gerado por IA - **Nenhum valor de flag**: registramos que `--json` foi usado, não o conteúdo da saída JSON - **Nenhuma atividade de rede**: nenhuma URL, chave de API ou token diff --git a/docs/src/content/docs/reference/telemetry.mdx b/docs/src/content/docs/reference/telemetry.mdx index 184102f..a134c6e 100644 --- a/docs/src/content/docs/reference/telemetry.mdx +++ b/docs/src/content/docs/reference/telemetry.mdx @@ -12,19 +12,45 @@ Archgate collects **anonymous usage data** to help us understand how the CLI is When you run an Archgate command, we record: - **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 +- **Exit code** (0, 1, 2, or 130) and **execution duration** (milliseconds), plus a short **outcome** tag (`success`, `user_error`, `internal_error`, `cancelled`) +- **Environment**: OS, architecture, Bun version, Archgate version, CI detection (including provider: GitHub Actions / GitLab CI / CircleCI / etc.), TTY detection, WSL detection, shell (bash, zsh, pwsh...), and locale - **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 +- **Project context**: whether an Archgate project exists in the current directory, how many ADRs it has, how many have automated rules, and how many distinct ADR domains are used +- **Repo context** (non-identifying): whether the current directory is a git repository, the host bucket (`github` / `gitlab` / `bitbucket` / `azure-devops` / `other`), a **hashed `repo_id`** (SHA-256 of the normalized remote URL, truncated to 16 hex characters — not reversible), and the default branch name - **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 +- **`check`**: aggregate rule counts (total, passed, failed, warnings, errors), output format used, whether filters were applied, files scanned, load duration, check duration — no file paths or violation content +- **`init`**: editor choice, whether the plugin was installed, whether the project already existed. A separate one-time `project_initialized` event is emitted with the repo host bucket, `repo_is_git`, and a `repo_public` flag. For repos confirmed public on GitHub / GitLab / Bitbucket, this event also carries the remote URL, owner, and repo name — see [Repo identity](#repo-identity). Private and self-hosted repos never have identity shared. +- **`upgrade`**: version transition (from → to), install method, success/failure, and an optional failure reason +- **`login`**: subcommand used (login, logout, refresh, status), success/failure, and a failure bucket (`network`, `tls`, `denied`, `other`) when it fails +- **`telemetry_preference_changed`**: fires once when you enable or disable telemetry, so we can understand opt-out rates + +## Repo identity + +Archgate sends a **hashed** `repo_id` with every event so we can count distinct repositories using the CLI without learning their names. The raw remote URL, owner, and repository name are **not** included in the common event stream. + +On `archgate init`, a one-time `project_initialized` event is emitted. If — and only if — the repository is confirmed **public** on GitHub, GitLab, Bitbucket, or Azure DevOps (via an unauthenticated API probe against the host), that event additionally includes `remote_url`, `repo_owner`, and `repo_name`. This lets us see which public repositories are adopting Archgate without ever exposing private ones. + +**What's never shared:** + +- Private repositories (API probe returns 404, 401, or `private: true`) +- Self-hosted Git hosts (the probe skips these entirely) +- Repositories where the probe times out, is rate-limited, or otherwise fails to return a definitive public answer + +**Don't want the event at all?** Disable telemetry entirely — the whole `project_initialized` event is then suppressed along with everything else: + +```bash +# Per-shell / per-invocation +export ARCHGATE_TELEMETRY=0 + +# Or persistently +archgate telemetry disable +``` + +See [How to opt out](#how-to-opt-out) below for the full details. ### Error tracking (Sentry) @@ -36,8 +62,8 @@ When the CLI crashes (exit code 2), we send: ## What we do NOT collect -- **No personal information**: no usernames, emails, IP addresses, or GitHub identifiers -- **No file content**: no ADR content, source code, project names, or file paths +- **No personal information**: no usernames, emails, or IP addresses. GitHub / GitLab / Bitbucket owner/repository names are only sent on the one-time `project_initialized` event for repositories that are confirmed public by their host — see [Repo identity](#repo-identity). Private and self-hosted repos never have identity shared. +- **No file content**: no ADR content, source code, or file paths - **No prompt or AI context**: nothing from agent interactions, prompts, or AI-generated content - **No flag values**: we record that `--json` was used, not what the JSON output contained - **No network activity**: no URLs, API keys, or tokens diff --git a/src/cli.ts b/src/cli.ts index 38a1a8f..b54e664 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { registerReviewContextCommand } from "./commands/review-context"; import { registerSessionContextCommand } from "./commands/session-context/index"; import { registerTelemetryCommand } from "./commands/telemetry"; import { registerUpgradeCommand } from "./commands/upgrade"; +import { beginCommand, exitWith, finalizeCommand } from "./helpers/exit"; import { installGit } from "./helpers/git"; import { type LogLevel, logError, setLogLevel } from "./helpers/log"; import { createPathIfNotExists, paths } from "./helpers/paths"; @@ -28,7 +29,6 @@ import { flushTelemetry, initTelemetry, trackCommand, - trackCommandResult, } from "./helpers/telemetry"; import { checkForUpdatesIfNeeded } from "./helpers/update-check"; @@ -62,9 +62,11 @@ createPathIfNotExists(paths.cacheFolder); async function main() { await installGit(); - // Initialize error tracking and telemetry (no-ops if opted out) + // Initialize error tracking and telemetry (no-ops if opted out). + // Telemetry resolves repo context asynchronously — we don't block on it; + // any events emitted before it finishes simply won't carry repo_id. initSentry(); - initTelemetry(); + void initTelemetry(); const logLevelOption = new Option("--log-level ", "Set log verbosity") .choices(["error", "warn", "info", "debug"] as const) @@ -76,29 +78,39 @@ async function main() { .description("AI governance for software development") .addOption(logLevelOption); - // Track command execution for Sentry breadcrumbs and PostHog analytics - let commandStartTime = 0; - program.hook("preAction", (thisCommand) => { + // Track command execution for Sentry breadcrumbs and PostHog analytics. + // + // Commander invokes the hook callback with (hookedCommand, actionCommand); + // the second arg is the actual subcommand being executed. We use the action + // command so `adr create` etc. resolves correctly instead of always "root". + program.hook("preAction", (_hookedCommand, actionCommand) => { // Apply log level from global option before any command runs const rootOpts = program.opts(); setLogLevel(rootOpts.logLevel as LogLevel); - const fullCommand = getFullCommandName(thisCommand); + const fullCommand = getFullCommandName(actionCommand); addBreadcrumb("command", `Running: ${fullCommand}`); // Collect which options were used (presence only, no values) - const opts = thisCommand.opts() as Record; + const opts = actionCommand.opts() as Record; const optionFlags: Record = {}; + const optionsUsed: string[] = []; for (const key of Object.keys(opts)) { const val = opts[key]; - optionFlags[`opt_${key}`] = val !== undefined && val !== false; + const used = val !== undefined && val !== false; + optionFlags[`opt_${key}`] = used; + if (used) optionsUsed.push(key); } - trackCommand(fullCommand, optionFlags); - commandStartTime = performance.now(); + const depth = fullCommand === "root" ? 0 : fullCommand.split(" ").length; + trackCommand(fullCommand, { + ...optionFlags, + command_depth: depth, + options_used: optionsUsed, + }); + beginCommand(fullCommand); }); - program.hook("postAction", (thisCommand) => { - const fullCommand = getFullCommandName(thisCommand); - const durationMs = Math.round(performance.now() - commandStartTime); - trackCommandResult(fullCommand, 0, durationMs); + program.hook("postAction", (_hookedCommand, actionCommand) => { + const fullCommand = getFullCommandName(actionCommand); + finalizeCommand(fullCommand, 0, "success"); }); registerInitCommand(program); @@ -128,16 +140,22 @@ async function main() { /** * Reconstruct the full command name from Commander's command chain. * E.g., "adr create" from the "create" subcommand of "adr". + * + * Typed against the loose Commander "unknown opts" shape because it's called + * from the `preAction` / `postAction` hook callback, where Commander gives us + * a `CommandUnknownOpts`, not the narrowly-typed `Command<[], {}, {}>`. */ -function getFullCommandName(command: Command): string { +function getFullCommandName( + command: { name(): string; parent: unknown } | null +): string { const parts: string[] = []; - let current: Command | null = command; + let current = command; while (current) { const name = current.name(); if (name && name !== "archgate") { parts.unshift(name); } - current = current.parent as Command | null; + current = current.parent as typeof command; } return parts.join(" ") || "root"; } @@ -145,11 +163,29 @@ function getFullCommandName(command: Command): string { main().catch(async (err: unknown) => { // User pressed Ctrl+C during an Inquirer prompt — exit silently if (err instanceof Error && err.name === "ExitPromptError") { - process.exit(130); + await exitWith(130, { outcome: "cancelled" }); } captureException(err, { command: "main" }); - await Promise.all([flushTelemetry(), flushSentry()]); logError(String(err)); - process.exit(2); + await exitWith(2, { + outcome: "internal_error", + errorKind: classifyErrorKind(err), + }); }); + +/** + * Classify an error into a high-level bucket for telemetry. + * Returns a short tag — never the raw error message. + */ +function classifyErrorKind(err: unknown): string { + if (!(err instanceof Error)) return "unknown"; + const name = err.name || "Error"; + const msg = err.message || ""; + if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN/i.test(msg)) return "network"; + if (/certificate|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(msg)) return "tls"; + if (/EACCES|EPERM/.test(msg)) return "permission"; + if (name === "SyntaxError") return "syntax"; + if (name === "TypeError") return "type"; + return name; +} diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index f2fb500..88ec903 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -4,6 +4,7 @@ import inquirer from "inquirer"; import { ADR_DOMAINS, type AdrDomain } from "../../formats/adr"; import { createAdrFile } from "../../helpers/adr-writer"; +import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; import { findProjectRoot, projectPaths } from "../../helpers/paths"; @@ -26,7 +27,8 @@ export function registerAdrCreateCommand(adr: Command) { const projectRoot = findProjectRoot(); if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); - process.exit(1); + await exitWith(1); + return; } try { @@ -97,7 +99,7 @@ export function registerAdrCreateCommand(adr: Command) { } } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/adr/list.ts b/src/commands/adr/list.ts index a9bda14..89e5053 100644 --- a/src/commands/adr/list.ts +++ b/src/commands/adr/list.ts @@ -6,6 +6,7 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; import { ADR_DOMAINS, parseAdr, type AdrDocument } from "../../formats/adr"; +import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; import { findProjectRoot, projectPaths } from "../../helpers/paths"; @@ -37,7 +38,8 @@ export function registerAdrListCommand(adr: Command) { const projectRoot = findProjectRoot(); if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); - process.exit(1); + await exitWith(1); + return; } try { @@ -101,7 +103,7 @@ export function registerAdrListCommand(adr: Command) { } } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/adr/show.ts b/src/commands/adr/show.ts index b32b874..faadcbe 100644 --- a/src/commands/adr/show.ts +++ b/src/commands/adr/show.ts @@ -1,6 +1,7 @@ import type { Command } from "@commander-js/extra-typings"; import { findAdrFileById } from "../../helpers/adr-writer"; +import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { findProjectRoot, projectPaths } from "../../helpers/paths"; @@ -13,7 +14,8 @@ export function registerAdrShowCommand(adr: Command) { const projectRoot = findProjectRoot(); if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); - process.exit(1); + await exitWith(1); + return; } try { @@ -22,14 +24,15 @@ export function registerAdrShowCommand(adr: Command) { if (!adr) { logError(`ADR with ID '${id}' not found.`); - process.exit(1); + await exitWith(1); + return; } const content = await Bun.file(adr.filePath).text(); console.log(content); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/adr/update.ts b/src/commands/adr/update.ts index ef84838..199d34f 100644 --- a/src/commands/adr/update.ts +++ b/src/commands/adr/update.ts @@ -3,6 +3,7 @@ import { Option } from "@commander-js/extra-typings"; import { ADR_DOMAINS } from "../../formats/adr"; import { updateAdrFile } from "../../helpers/adr-writer"; +import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON, isAgentContext } from "../../helpers/output"; import { findProjectRoot, projectPaths } from "../../helpers/paths"; @@ -29,7 +30,8 @@ export function registerAdrUpdateCommand(adr: Command) { const projectRoot = findProjectRoot(); if (!projectRoot) { logError("No .archgate/ directory found. Run `archgate init` first."); - process.exit(1); + await exitWith(1); + return; } const paths = projectPaths(projectRoot); @@ -59,7 +61,7 @@ export function registerAdrUpdateCommand(adr: Command) { } catch (err) { const message = err instanceof Error ? err.message : String(err); logError(message); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/check.ts b/src/commands/check.ts index d7d3668..89a05f7 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -9,6 +9,7 @@ import { buildSummary, } from "../engine/reporter"; import { runChecks } from "../engine/runner"; +import { exitWith } from "../helpers/exit"; import { logError } from "../helpers/log"; import { formatJSON, isAgentContext } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; @@ -30,18 +31,22 @@ export function registerCheckCommand(program: Command) { logError( "No archgate project found. Run 'archgate init' to create one." ); - process.exit(1); + await exitWith(1); + return; } let loadResults; + const loadStart = performance.now(); try { loadResults = await loadRuleAdrs(projectRoot, opts.adr); } catch (err) { logError( err instanceof Error ? err.message : `Failed to load rules: ${err}` ); - process.exit(1); + await exitWith(1); + return; } + const loadDurationMs = Math.round(performance.now() - loadStart); const useJson = opts.json || (!opts.ci && isAgentContext()); @@ -67,7 +72,7 @@ export function registerCheckCommand(program: Command) { } else { console.log(" No rules to check."); } - process.exit(0); + await exitWith(0); } // Collect file paths from arguments and/or stdin pipe. @@ -118,8 +123,13 @@ export function registerCheckCommand(program: Command) { used_staged: Boolean(opts.staged), used_file_filter: filterFiles.length > 0, used_adr_filter: Boolean(opts.adr), + files_scanned: filterFiles.length, + load_duration_ms: loadDurationMs, + check_duration_ms: Math.round(result.totalDurationMs), }); - process.exit(getExitCode(result)); + const exitCode = getExitCode(result); + // Only 0, 1, and 2 are emitted by getExitCode() + await exitWith(exitCode as 0 | 1 | 2); }); } diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 004864b..c1a52e4 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { Command } from "@commander-js/extra-typings"; +import { exitWith } from "../helpers/exit"; import { logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; @@ -19,7 +20,7 @@ export function registerCleanCommand(program: Command) { program .command("clean") .description("Clean the CLI temp files") - .action(() => { + .action(async () => { const destinationPath = internalPath(); if (!existsSync(destinationPath)) { @@ -49,7 +50,7 @@ export function registerCleanCommand(program: Command) { `Failed to clean ${destinationPath}.`, error instanceof Error ? error.message : String(error) ); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 74b95c7..dbbefdc 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,6 +4,7 @@ import type { Command } from "@commander-js/extra-typings"; import type { DoctorReport } from "../helpers/doctor"; import { runDoctor } from "../helpers/doctor"; +import { exitWith } from "../helpers/exit"; import { logError } from "../helpers/log"; import { formatJSON, isAgentContext } from "../helpers/output"; @@ -97,7 +98,7 @@ export function registerDoctorCommand(program: Command) { } } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/init.ts b/src/commands/init.ts index 199f529..d518c90 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -8,11 +8,17 @@ import inquirer from "inquirer"; import { loadCredentials } from "../helpers/credential-store"; import { detectEditors, promptEditorSelection } from "../helpers/editor-detect"; +import { exitWith } from "../helpers/exit"; 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 { + getRepoContext, + isPublicRepo, + shouldShareRepoIdentity, +} from "../helpers/repo"; +import { trackInitResult, trackProjectInitialized } from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; const EDITOR_DIRS: Record = { @@ -134,13 +140,41 @@ export function registerInitCommand(program: Command) { had_existing_project: hadExistingProject, }); } + + // One-time `project_initialized` event. The hashed `repo_id` ships in + // every event already via the common props; this richer event is the + // only place the raw remote URL / owner / name appear, and only for + // repositories we can confirm public via the host's unauthenticated + // API. Users who don't want the event at all disable telemetry + // (`ARCHGATE_TELEMETRY=0` / `archgate telemetry disable`) — no + // identity-specific knob is needed on top of that. + const repo = await getRepoContext(); + const repoPublic = await isPublicRepo(repo); + const shareIdentity = shouldShareRepoIdentity(repoPublic); + trackProjectInitialized({ + editors, + editor_primary: editors[0], + plugin_installed: installPlugin, + had_existing_project: hadExistingProject, + identity_shared: shareIdentity, + repo_host: repo.host, + repo_is_git: repo.isGit, + repo_public: repoPublic, + ...(shareIdentity + ? { + remote_url: repo.remoteUrl, + repo_owner: repo.owner, + repo_name: repo.name, + } + : {}), + }); } catch (err) { if (isTlsError(err)) { logError(tlsHintMessage()); - process.exit(1); + await exitWith(1); } logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/login.ts b/src/commands/login.ts index a438e97..8f54919 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -3,6 +3,7 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; import { loadCredentials, clearCredentials } from "../helpers/credential-store"; +import { exitWith } from "../helpers/exit"; import { logError, logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; @@ -31,16 +32,21 @@ export function registerLoginCommand(program: Command) { if (result.ok) { printNextStep(); } else { - process.exit(1); + await exitWith(1); } } catch (err) { - trackLoginResult({ subcommand: "login", success: false }); + const failureReason = isTlsError(err) ? "tls" : "other"; + trackLoginResult({ + subcommand: "login", + success: false, + failure_reason: failureReason, + }); if (isTlsError(err)) { logError(tlsHintMessage()); - process.exit(1); + await exitWith(1); } logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); @@ -58,7 +64,7 @@ export function registerLoginCommand(program: Command) { } } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); @@ -72,7 +78,7 @@ export function registerLoginCommand(program: Command) { console.log("Logged out successfully."); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); @@ -87,16 +93,21 @@ export function registerLoginCommand(program: Command) { if (result.ok) { printNextStep(); } else { - process.exit(1); + await exitWith(1); } } catch (err) { - trackLoginResult({ subcommand: "refresh", success: false }); + const failureReason = isTlsError(err) ? "tls" : "other"; + trackLoginResult({ + subcommand: "refresh", + success: false, + failure_reason: failureReason, + }); if (isTlsError(err)) { logError(tlsHintMessage()); - process.exit(1); + await exitWith(1); } logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 8018a7a..43dc3bd 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -8,6 +8,7 @@ import { detectEditors, promptEditorSelection, } from "../../helpers/editor-detect"; +import { exitWith } from "../../helpers/exit"; import { EDITOR_LABELS } from "../../helpers/init-project"; import type { EditorTarget } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; @@ -122,7 +123,8 @@ export function registerPluginInstallCommand(plugin: Command) { "Not logged in.", "Run `archgate login` first to authenticate." ); - process.exit(1); + await exitWith(1); + return; } // Resolve editors: explicit flag, interactive prompt, or default @@ -191,7 +193,8 @@ export function registerPluginInstallCommand(plugin: Command) { } } - process.exit(1); + // oxlint-disable-next-line no-await-in-loop -- exit immediately on first editor failure + await exitWith(1); } } }); diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts index cb293c8..00328ee 100644 --- a/src/commands/plugin/url.ts +++ b/src/commands/plugin/url.ts @@ -5,6 +5,7 @@ import { detectEditors, promptSingleEditorSelection, } from "../../helpers/editor-detect"; +import { exitWith } from "../../helpers/exit"; import type { EditorTarget } from "../../helpers/init-project"; import { logError } from "../../helpers/log"; import { @@ -45,7 +46,7 @@ export function registerPluginUrlCommand(plugin: Command) { console.log(url); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index 49b0391..f8643a2 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -3,6 +3,7 @@ import { Option } from "@commander-js/extra-typings"; import { buildReviewContext } from "../engine/context"; import { ADR_DOMAINS } from "../formats/adr"; +import { exitWith } from "../helpers/exit"; import { logError } from "../helpers/log"; import { formatJSON } from "../helpers/output"; import { findProjectRoot } from "../helpers/paths"; @@ -27,7 +28,8 @@ export function registerReviewContextCommand(program: Command) { logError( "No archgate project found. Run 'archgate init' to create one." ); - process.exit(1); + await exitWith(1); + return; } try { @@ -40,7 +42,7 @@ export function registerReviewContextCommand(program: Command) { console.log(formatJSON(context)); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index a4b44a2..ec4b652 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -1,6 +1,7 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; +import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; @@ -25,13 +26,14 @@ export function registerClaudeCodeSessionContextCommand(parent: Command) { if (!result.ok) { logError(result.error); - process.exit(1); + await exitWith(1); + return; } console.log(formatJSON(result.data)); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 7116b18..603b1ab 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -1,6 +1,7 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; +import { exitWith } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; @@ -27,13 +28,14 @@ export function registerCursorSessionContextCommand(parent: Command) { if (!result.ok) { logError(result.error); - process.exit(1); + await exitWith(1); + return; } console.log(formatJSON(result.data)); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts index d287d48..db5d03a 100644 --- a/src/commands/telemetry.ts +++ b/src/commands/telemetry.ts @@ -1,6 +1,12 @@ import type { Command } from "@commander-js/extra-typings"; +import { exitWith } from "../helpers/exit"; import { logError } from "../helpers/log"; +import { + flushTelemetry, + initTelemetry, + trackTelemetryPreferenceChange, +} from "../helpers/telemetry"; import { isTelemetryEnabled, isEnvTelemetryDisabled, @@ -52,12 +58,18 @@ export function registerTelemetryCommand(program: Command) { ); } await setTelemetryEnabled(true); + // Initialize the client so we can emit the opt-in event. Fire and + // forget — if flush fails for any reason it must not block the + // command. Disabled via env var? Both calls are no-ops. + await initTelemetry(); + trackTelemetryPreferenceChange({ enabled: true }); + await flushTelemetry(); console.log( "Telemetry enabled. Thank you for helping improve Archgate." ); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); @@ -66,11 +78,16 @@ export function registerTelemetryCommand(program: Command) { .description("Disable anonymous usage data collection") .action(async () => { try { + // Emit the opt-out event BEFORE persisting the disable — once the + // config flips off, future trackEvent calls are no-ops. We also + // flush before returning so the event actually ships. + trackTelemetryPreferenceChange({ enabled: false }); + await flushTelemetry(); await setTelemetryEnabled(false); console.log("Telemetry disabled. No usage data will be collected."); } catch (err) { logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 6a2e419..6b0f668 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -11,6 +11,7 @@ import { getManualInstallHint, replaceBinary, } from "../helpers/binary-upgrade"; +import { exitWith } from "../helpers/exit"; import { logDebug, logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; import { getPlatformInfo, resolveCommand } from "../helpers/platform"; @@ -208,7 +209,8 @@ async function upgradeBinary(tag: string): Promise { `Unsupported platform: ${getPlatformInfo().runtime}/${process.arch}`, "archgate supports darwin/arm64, linux/x64, and win32/x64." ); - process.exit(1); + await exitWith(1); + return; } logDebug("Artifact:", artifact.name, "ext:", artifact.ext); @@ -223,7 +225,7 @@ async function upgradeBinary(tag: string): Promise { "Failed to upgrade binary.", `${err instanceof Error ? err.message : String(err)}\nTry running \`${hint}\` manually.` ); - process.exit(1); + await exitWith(1); } } @@ -241,7 +243,7 @@ async function runExternalUpgrade( "Failed to install the latest version.", `Try running \`${manualHint}\` manually.` ); - process.exit(1); + await exitWith(1); } } @@ -260,7 +262,8 @@ export function registerUpgradeCommand(program: Command) { "Failed to fetch release info from GitHub.", "Check your network connection." ); - process.exit(1); + await exitWith(1); + return; } const packageJson = await import("../../package.json"); @@ -273,12 +276,14 @@ export function registerUpgradeCommand(program: Command) { logError( `Could not compare versions: ${currentVersion} vs ${latestVersion}` ); - process.exit(2); + await exitWith(2); + return; } if (order >= 0) { console.log(`Archgate is already up-to-date (${currentVersion}).`); - process.exit(0); + await exitWith(0); + return; } console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); @@ -316,7 +321,7 @@ export function registerUpgradeCommand(program: Command) { success: false, }); logError(err instanceof Error ? err.message : String(err)); - process.exit(1); + await exitWith(1); } }); } diff --git a/src/helpers/exit.ts b/src/helpers/exit.ts new file mode 100644 index 0000000..b16d699 --- /dev/null +++ b/src/helpers/exit.ts @@ -0,0 +1,152 @@ +/** + * exit.ts — Centralized process-exit helper for the CLI. + * + * Every command that needs a non-zero exit (and some that need zero) must go + * through {@link exitWith} instead of calling `process.exit(code)` directly. + * The helper records a `command_completed` telemetry event with the real exit + * code + a high-level outcome tag, then flushes PostHog and Sentry before + * exiting. Calling `process.exit` directly skips the Commander `postAction` + * hook AND the `main()`-level flush, dropping the event on the floor — which + * is exactly why `exit_code` used to be stuck at 0 in the dashboard. + * + * Lifecycle: + * 1. Commander preAction hook calls {@link beginCommand} with the full + * command path so we know which command we're timing. + * 2. The action runs. On the happy path it returns and Commander's + * `postAction` hook calls {@link finalizeCommand}`(cmd, 0, "success")`. + * 3. On an expected failure, the action calls `await exitWith(1, ...)` which + * finalizes + flushes + exits. + * 4. On an unexpected crash, `main().catch()` calls `await exitWith(2, ...)`. + * + * The module-level guard prevents double-counting when both `exitWith` and the + * Commander `postAction` hook fire for the same invocation. + */ + +import { flushSentry } from "./sentry"; +import { flushTelemetry, trackCommandResult } from "./telemetry"; + +export type CommandOutcome = + | "success" + | "user_error" + | "internal_error" + | "cancelled"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let currentCommand: string | null = null; +let commandStartTime: number | null = null; +let completionTracked = false; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Record the start of a command. Called from the Commander `preAction` hook + * once per invocation, before the action runs. + */ +export function beginCommand(fullCommand: string): void { + currentCommand = fullCommand; + commandStartTime = performance.now(); + completionTracked = false; +} + +/** + * Emit the `command_completed` event. Safe to call multiple times — only the + * first call records an event. + */ +export function finalizeCommand( + fullCommand: string, + exitCode: number, + outcome: CommandOutcome, + extra?: { errorKind?: string } +): void { + if (completionTracked) return; + completionTracked = true; + + const name = fullCommand || currentCommand || "unknown"; + const durationMs = + commandStartTime === null + ? 0 + : Math.round(performance.now() - commandStartTime); + + trackCommandResult(name, exitCode, durationMs, { + outcome, + error_kind: extra?.errorKind ?? null, + }); +} + +/** + * Terminate the process after recording + flushing telemetry. + * + * Use this instead of `process.exit(code)` anywhere inside a command action + * or the top-level error boundary. Safe to `await` — the returned promise is + * typed `Promise` because control never returns. + * + * The outcome tag defaults to a sensible value derived from the exit code: + * - 0 → "success" + * - 1 → "user_error" + * - 2 → "internal_error" + * - 130 → "cancelled" + * Override via the `outcome` option when the default doesn't fit. + */ +export async function exitWith( + code: 0 | 1 | 2 | 130, + opts?: { outcome?: CommandOutcome; errorKind?: string } +): Promise { + const outcome = opts?.outcome ?? defaultOutcome(code); + const name = currentCommand ?? "root"; + + try { + finalizeCommand(name, code, outcome, { errorKind: opts?.errorKind }); + } catch { + // Never let telemetry affect exit behavior + } + + try { + await Promise.all([flushTelemetry(), flushSentry()]); + } catch { + // Flush failures are best-effort + } + + process.exit(code); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function defaultOutcome(code: number): CommandOutcome { + switch (code) { + case 0: + return "success"; + case 130: + return "cancelled"; + case 2: + return "internal_error"; + default: + return "user_error"; + } +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** Reset internal state. For testing only. */ +export function _resetExitState(): void { + currentCommand = null; + commandStartTime = null; + completionTracked = false; +} + +/** Inspect internal state. For testing only. */ +export function _getExitState(): { + currentCommand: string | null; + commandStartTime: number | null; + completionTracked: boolean; +} { + return { currentCommand, commandStartTime, completionTracked }; +} diff --git a/src/helpers/install-info.ts b/src/helpers/install-info.ts index 34de04e..cee9f90 100644 --- a/src/helpers/install-info.ts +++ b/src/helpers/install-info.ts @@ -55,7 +55,7 @@ export function detectInstallMethod(): string { } // --------------------------------------------------------------------------- -// Project context (cached) +// Project context // --------------------------------------------------------------------------- export interface ProjectContext { @@ -65,26 +65,27 @@ export interface ProjectContext { domains: string[]; } -let cachedProjectContext: ProjectContext | null = null; - /** * Scan the current working directory for an archgate project. - * Results are cached per process. + * + * This used to be cached per process, but the cache was a source of stale + * data: if the first call happened BEFORE `archgate init` created the project + * (during the Commander `preAction` hook), the post-init `init_completed` + * event reused the pre-init snapshot and incorrectly reported + * `has_project=false, adr_count=0`. The read is a single `readdirSync` — + * cheap enough to re-run on every event, and worth it for accuracy. */ export function getProjectContext(): ProjectContext { - if (cachedProjectContext) return cachedProjectContext; - const adrsDir = join(process.cwd(), ".archgate", "adrs"); const hasProject = existsSync(adrsDir); if (!hasProject) { - cachedProjectContext = { + return { hasProject: false, adrCount: 0, adrWithRulesCount: 0, domains: [], }; - return cachedProjectContext; } try { @@ -98,22 +99,15 @@ export function getProjectContext(): ProjectContext { if (match) domainSet.add(match[1]); } - cachedProjectContext = { + return { hasProject: true, adrCount: mdFiles.length, adrWithRulesCount: rulesFiles.length, domains: [...domainSet].sort(), }; } catch { - cachedProjectContext = { - hasProject: true, - adrCount: 0, - adrWithRulesCount: 0, - domains: [], - }; + return { hasProject: true, adrCount: 0, adrWithRulesCount: 0, domains: [] }; } - - return cachedProjectContext; } // --------------------------------------------------------------------------- @@ -123,5 +117,4 @@ export function getProjectContext(): ProjectContext { /** Reset all caches. For testing only. */ export function _resetInstallInfoCaches(): void { cachedInstallMethod = null; - cachedProjectContext = null; } diff --git a/src/helpers/repo-probe.ts b/src/helpers/repo-probe.ts new file mode 100644 index 0000000..cf98485 --- /dev/null +++ b/src/helpers/repo-probe.ts @@ -0,0 +1,212 @@ +/** + * repo-probe.ts — Unauthenticated API probes to determine whether a Git + * repository is public on its host (GitHub, GitLab, Bitbucket, Azure DevOps). + * + * Why it's a separate module: the probe code is network-y and host-specific, + * while the rest of `repo.ts` is local-only git inspection. Keeping them apart + * keeps each file small and testable, and keeps the network surface out of + * the fast path for every command (the probe is only called from + * `archgate init`). + * + * Privacy rationale: the probe is the gate that decides whether a repo's + * owner / name / remote URL ship on the `project_initialized` event. Only + * repos that a random anonymous user of the host can already see get their + * identity shared. + */ + +import { logDebug } from "./log"; +// `import type` is erased at compile time, so there's no runtime circularity +// with `repo.ts` even though `repo.ts` imports the probe's runtime bindings. +import type { RepoContext, RepoHost } from "./repo"; + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +/** One network call per repo per process, shared across call sites. */ +let cachedPublicProbe: Promise | null = null; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Probe the host's unauthenticated API to determine whether the repo is + * public. Returns: + * - `true` — confirmed public on a recognised host + * - `false` — confirmed private / not visible to anonymous users + * - `null` — couldn't determine (self-hosted, network failure, timeout, + * rate-limited) + * + * Bounded by a 3s timeout — telemetry must not slow down the CLI when the + * network is misbehaving. Errors are swallowed; we never probe again after + * the first call in a given process. + */ +export function isPublicRepo( + repo: Pick +): Promise { + if (cachedPublicProbe) return cachedPublicProbe; + cachedPublicProbe = probePublic(repo).catch(() => null); + return cachedPublicProbe; +} + +/** Reset the cached probe. For testing only. */ +export function _resetPublicProbeCache(): void { + cachedPublicProbe = null; +} + +/** Inject a probe result. For testing only. */ +export function _setPublicProbeForTest(value: boolean | null): void { + cachedPublicProbe = Promise.resolve(value); +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +async function probePublic( + repo: Pick +): Promise { + if (!repo.host || !repo.owner || !repo.name) return null; + const { host, owner, name } = repo as { + host: RepoHost; + owner: string; + name: string; + }; + if (host === "other") return null; + + try { + switch (host) { + case "github": + return await probeGitHub(owner, name); + case "gitlab": + return await probeGitLab(owner, name); + case "bitbucket": + return await probeBitbucket(owner, name); + case "azure-devops": + return await probeAzureDevOps(owner, name); + } + } catch (err) { + logDebug("public-repo probe failed (ignored):", String(err)); + return null; + } +} + +// --------------------------------------------------------------------------- +// HTTP (timeout-bounded) +// --------------------------------------------------------------------------- + +const PROBE_TIMEOUT_MS = 3000; + +async function fetchWithTimeout(url: string): Promise { + try { + return await fetch(url, { + headers: { + "User-Agent": "archgate-cli", + Accept: "application/vnd.github+json, application/json;q=0.9", + }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Per-host probes +// --------------------------------------------------------------------------- + +async function probeGitHub( + owner: string, + name: string +): Promise { + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; + const res = await fetchWithTimeout(url); + if (!res) return null; + if (res.status === 200) { + try { + const data = (await res.json()) as { private?: boolean }; + return data.private === false; + } catch { + return null; + } + } + // 404 = either private or nonexistent — anonymous callers can't see it. + if (res.status === 404) return false; + // 403 = rate-limited; don't treat as private. + if (res.status === 403) return null; + return null; +} + +async function probeGitLab( + owner: string, + name: string +): Promise { + const projectPath = encodeURIComponent(`${owner}/${name}`); + const url = `https://gitlab.com/api/v4/projects/${projectPath}`; + const res = await fetchWithTimeout(url); + if (!res) return null; + if (res.status === 200) { + try { + const data = (await res.json()) as { visibility?: string }; + return data.visibility === "public"; + } catch { + return null; + } + } + if (res.status === 404) return false; + return null; +} + +async function probeBitbucket( + owner: string, + name: string +): Promise { + const url = `https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; + const res = await fetchWithTimeout(url); + if (!res) return null; + if (res.status === 200) { + try { + const data = (await res.json()) as { is_private?: boolean }; + return data.is_private === false; + } catch { + return null; + } + } + if (res.status === 404) return false; + return null; +} + +/** + * Azure DevOps owner is `{organization}/{project}`. We probe the project's + * visibility endpoint — a public Azure DevOps project returns the record + * unauthenticated, a private project responds with 401. + * + * Note: this doesn't try to prove the specific repository is public; Azure + * DevOps project visibility governs repo visibility, and individual repos + * aren't separately togglable to public within a private project. + */ +async function probeAzureDevOps( + owner: string, + _name: string +): Promise { + const [organization, ...projectParts] = owner.split("/"); + const project = projectParts.join("/"); + if (!organization || !project) return null; + + const url = `https://dev.azure.com/${encodeURIComponent(organization)}/_apis/projects/${encodeURIComponent(project)}?api-version=7.0`; + const res = await fetchWithTimeout(url); + if (!res) return null; + if (res.status === 200) { + try { + const data = (await res.json()) as { visibility?: string }; + return data.visibility === "public"; + } catch { + return null; + } + } + // 401 = private project (needs auth); 404 = nonexistent. Either way, the + // repo is invisible to anonymous users — that's what matters for sharing. + if (res.status === 401 || res.status === 404) return false; + return null; +} diff --git a/src/helpers/repo.ts b/src/helpers/repo.ts new file mode 100644 index 0000000..0c63b5e --- /dev/null +++ b/src/helpers/repo.ts @@ -0,0 +1,346 @@ +/** + * repo.ts — Detect the git repository context for telemetry enrichment. + * + * Every event carries: + * - `repo_host`: "github" | "gitlab" | "bitbucket" | "azure-devops" | "other" | null + * - `repo_id`: sha256 hash of the normalized remote URL, truncated to 16 + * hex chars. Stable per repo, but non-reversible — you can count distinct + * repos using the CLI without learning any identity. + * - `repo_is_git`: whether the CWD is a git working tree at all + * - `git_default_branch`: best-effort "main" / "master" / etc. + * + * The raw remote URL and parsed owner/name are *only* sent on the one-time + * `project_initialized` event, and *only* when the repository is confirmed + * public via the host's unauthenticated API. See `repo-probe.ts` for that + * logic; this module stays local-only (git + URL parsing). + * + * Cached per-process because the git remote and default branch are effectively + * immutable over the lifetime of a single CLI invocation. + */ + +import { createHash } from "node:crypto"; + +import { logDebug } from "./log"; +import { + _resetPublicProbeCache, + _setPublicProbeForTest, + isPublicRepo, +} from "./repo-probe"; + +// Re-export the public-visibility probe so commands / telemetry can import +// everything repo-related from one place. +export { isPublicRepo, _setPublicProbeForTest }; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type RepoHost = + | "github" + | "gitlab" + | "bitbucket" + | "azure-devops" + | "other"; + +export interface RepoContext { + /** True if the CWD sits inside a git working tree. */ + isGit: boolean; + /** Detected hosting provider, derived from the remote URL. */ + host: RepoHost | null; + /** Parsed `owner` segment of the remote URL (e.g., `archgate`). */ + owner: string | null; + /** Parsed repo `name` segment of the remote URL (e.g., `cli`). */ + name: string | null; + /** + * Stable identifier derived from `sha256(normalizedRemoteUrl)`, truncated + * to 16 hex chars. Safe to send in every event — lets us count distinct + * repos without learning names. + */ + repoId: string | null; + /** + * Raw `remote.origin.url` string. Present here so callers with explicit + * user consent (the `project_initialized` event on a confirmed-public + * repo) can include it; NEVER sent with common properties. + */ + remoteUrl: string | null; + /** + * Best-effort default branch name (`main`, `master`, ...). May be null if + * the repo has no remote HEAD configured. + */ + defaultBranch: string | null; +} + +export interface ParsedRemote { + host: RepoHost | null; + owner: string | null; + name: string | null; + /** Canonical form used for hashing — lowercase host, `owner/name`, no suffix. */ + normalized: string | null; +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +let cached: RepoContext | null = null; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolve the repo context for the current working directory. + * + * Returns an all-null / `isGit=false` shape if git is missing, the CWD is not + * a repo, or the commands fail. Never throws — telemetry must not break the + * command that spawned it. + */ +export async function getRepoContext(): Promise { + if (cached) return cached; + + const cwd = process.cwd(); + const isGit = await runGitCheck(["rev-parse", "--is-inside-work-tree"], cwd); + if (!isGit) { + cached = emptyContext(false); + return cached; + } + + const remoteUrl = await runGitCapture( + ["config", "--get", "remote.origin.url"], + cwd + ); + const defaultBranch = await resolveDefaultBranch(cwd); + + if (!remoteUrl) { + cached = { + isGit: true, + host: null, + owner: null, + name: null, + repoId: null, + remoteUrl: null, + defaultBranch, + }; + return cached; + } + + const parsed = parseRemoteUrl(remoteUrl); + cached = { + isGit: true, + host: parsed.host, + owner: parsed.owner, + name: parsed.name, + repoId: parsed.normalized ? hashRepoId(parsed.normalized) : null, + remoteUrl, + defaultBranch, + }; + return cached; +} + +/** + * Should the CLI include owner / name / full remote URL in the + * `project_initialized` event? + * + * Rule: share iff the repository is confirmed public on a recognised host. + * Private, unknown, and self-hosted repos always return false. + * + * There's no identity-specific opt-out knob — if a user doesn't want *any* + * telemetry, including the identity event, they disable telemetry itself + * (`ARCHGATE_TELEMETRY=0` or `archgate telemetry disable`). The whole event + * is then suppressed upstream. Adding a separate identity opt-out would be + * redundant and asymmetric with how every other field is gated. + */ +export function shouldShareRepoIdentity(repoPublic: boolean | null): boolean { + return repoPublic === true; +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** + * Parse a git remote URL into host + owner + name. + * + * Handles: + * - GitHub / GitLab / Bitbucket HTTPS and SCP-style SSH URLs + * - GitLab subgroups (`gitlab.com/foo/sub/bar` → owner=`foo/sub`, name=`bar`) + * - Azure DevOps (`dev.azure.com`) HTTPS and SSH URLs, including the + * `_git` path infix and the `v3` SSH prefix + * - Legacy Azure DevOps `{org}.visualstudio.com` URLs where the org is + * encoded in the subdomain rather than the path + */ +export function parseRemoteUrl(raw: string): ParsedRemote { + const trimmed = raw.trim(); + if (!trimmed) + return { host: null, owner: null, name: null, normalized: null }; + + let host: string | null = null; + let path: string | null = null; + + // SCP-like: git@github.com:foo/bar.git + const scpMatch = trimmed.match(/^[^@\s]+@([^:]+):(.+)$/); + if (scpMatch) { + host = scpMatch[1]; + path = scpMatch[2]; + } else { + try { + const url = new URL(trimmed); + host = url.hostname; + path = url.pathname.replace(/^\//, ""); + } catch { + return { host: null, owner: null, name: null, normalized: null }; + } + } + + if (!host || !path) { + return { host: null, owner: null, name: null, normalized: null }; + } + + const lowerHost = host.toLowerCase(); + const classified = classifyHost(lowerHost); + + // Strip trailing .git, .git/, or / + path = path.replace(/\.git\/?$/, "").replace(/\/$/, ""); + let segments = path.split("/").filter(Boolean); + + // Azure DevOps URL quirks: + // - HTTPS (modern): /{org}/{project}/_git/{repo} + // - HTTPS (legacy): /{project}/_git/{repo} on {org}.visualstudio.com + // - SSH (v3 path): v3/{org}/{project}/{repo} + // Strip the structural markers (`_git`, `v3`) and, for legacy URLs, pull + // the org out of the subdomain. + if (classified === "azure-devops") { + segments = segments.filter((s) => s !== "_git" && s !== "v3"); + + const vsHostMatch = lowerHost.match(/^([^.]+)\.visualstudio\.com$/); + if (vsHostMatch && !segments.some((s) => s === vsHostMatch[1])) { + segments = [vsHostMatch[1], ...segments]; + } + } + + if (segments.length < 2) { + return { host: classified, owner: null, name: null, normalized: null }; + } + + const name = segments.at(-1)!; + const owner = segments.slice(0, -1).join("/"); + + // Normalize the host for hashing. Azure DevOps URLs come in three shapes + // (HTTPS `dev.azure.com`, SSH `ssh.dev.azure.com`, legacy + // `{org}.visualstudio.com`) — all three should hash to the same repo_id, + // so collapse them onto a single canonical host string. + const normalizedHost = + classified === "azure-devops" ? "dev.azure.com" : lowerHost; + + return { + host: classified, + owner, + name, + normalized: `${normalizedHost}/${owner.toLowerCase()}/${name.toLowerCase()}`, + }; +} + +function classifyHost(hostname: string): RepoHost { + const h = hostname.toLowerCase(); + if (h === "github.com" || h.endsWith(".github.com")) return "github"; + if (h === "gitlab.com" || h.endsWith(".gitlab.com")) return "gitlab"; + if (h === "bitbucket.org" || h.endsWith(".bitbucket.org")) return "bitbucket"; + if ( + h === "dev.azure.com" || + h === "ssh.dev.azure.com" || + h.endsWith(".visualstudio.com") + ) { + return "azure-devops"; + } + return "other"; +} + +export function hashRepoId(normalized: string): string { + return createHash("sha256").update(normalized).digest("hex").slice(0, 16); +} + +// --------------------------------------------------------------------------- +// Git execution (safe, silent) +// --------------------------------------------------------------------------- + +async function runGitCapture( + args: string[], + cwd: string +): Promise { + try { + 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) return null; + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch (err) { + logDebug("git command failed (ignored):", args.join(" "), String(err)); + return null; + } +} + +async function runGitCheck(args: string[], cwd: string): Promise { + try { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + // Drain stdout so Bun doesn't leak the handle + await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} + +async function resolveDefaultBranch(cwd: string): Promise { + // Prefer the remote HEAD symbolic ref (e.g., `origin/main`) + const symRef = await runGitCapture( + ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + cwd + ); + if (symRef) { + const slash = symRef.indexOf("/"); + return slash >= 0 ? symRef.slice(slash + 1) : symRef; + } + // Fallback: whatever branch is currently checked out. Not strictly the + // "default" branch, but better than null for a single-user local repo. + return runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], cwd); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function emptyContext(isGit: boolean): RepoContext { + return { + isGit, + host: null, + owner: null, + name: null, + repoId: null, + remoteUrl: null, + defaultBranch: null, + }; +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** + * Reset the cached context AND the public-probe cache. For testing only — + * most tests only need one or the other, but resetting both here keeps the + * test setup boilerplate small. + */ +export function _resetRepoContextCache(): void { + cached = null; + _resetPublicProbeCache(); +} diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts index fc4fc8d..70ec74c 100644 --- a/src/helpers/telemetry.ts +++ b/src/helpers/telemetry.ts @@ -12,12 +12,16 @@ * See https://cli.archgate.dev/reference/telemetry for the full privacy policy. */ +import { basename } from "node:path"; + 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 type { RepoContext } from "./repo"; +import { getRepoContext } from "./repo"; import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; // --------------------------------------------------------------------------- @@ -39,25 +43,88 @@ let client: PostHog | null = null; let initialized = false; let distinctId = ""; +/** + * Repo context resolved once at startup. Kept in module state so the sync + * `getCommonProperties()` path doesn't need to await anything — reading git + * config via subprocess isn't expensive but doing it per event would add up. + */ +let repoContextSnapshot: RepoContext | null = null; + +// --------------------------------------------------------------------------- +// Environment enrichment +// --------------------------------------------------------------------------- + +/** + * Best-effort classification of the CI environment. PostHog already tells us + * `is_ci`, but knowing whether a user is on GitHub Actions vs. GitLab CI vs. + * a self-hosted runner is load-bearing context for understanding usage. + */ +function detectCiProvider(): string | null { + if (Bun.env.GITHUB_ACTIONS) return "github-actions"; + if (Bun.env.GITLAB_CI) return "gitlab-ci"; + if (Bun.env.CIRCLECI) return "circleci"; + if (Bun.env.TRAVIS) return "travis"; + if (Bun.env.BUILDKITE) return "buildkite"; + if (Bun.env.JENKINS_URL || Bun.env.JENKINS_HOME) return "jenkins"; + if (Bun.env.BITBUCKET_BUILD_NUMBER) return "bitbucket-pipelines"; + if (Bun.env.TF_BUILD) return "azure-pipelines"; + if (Bun.env.TEAMCITY_VERSION) return "teamcity"; + if (Bun.env.CODEBUILD_BUILD_ID) return "aws-codebuild"; + if (Bun.env.CI) return "other"; + return null; +} + +function detectShell(): string | null { + const shell = Bun.env.SHELL; + if (shell) return basename(shell); + // PowerShell / cmd.exe don't expose SHELL — fall back to PSModulePath / ComSpec + if (Bun.env.PSModulePath) return "powershell"; + if (Bun.env.ComSpec) return basename(Bun.env.ComSpec).toLowerCase(); + return null; +} + +function detectLocale(): string | null { + try { + return Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + return Bun.env.LANG ?? null; + } +} + // --------------------------------------------------------------------------- -// Shared properties (computed once per process) +// Shared properties (recomputed per event for freshness) // --------------------------------------------------------------------------- function getCommonProperties(): Record { const { runtime, isWSL } = getPlatformInfo(); const ctx = getProjectContext(); + const repo = repoContextSnapshot; + return { + // --- CLI / runtime --- cli_version: packageJson.version, os: runtime, arch: process.arch, bun_version: Bun.version, + install_method: detectInstallMethod(), + // --- Environment --- is_ci: Boolean(Bun.env.CI), + ci_provider: detectCiProvider(), is_tty: Boolean(process.stdout.isTTY), is_wsl: isWSL, - install_method: detectInstallMethod(), + shell: detectShell(), + locale: detectLocale(), + // --- Project --- has_project: ctx.hasProject, adr_count: ctx.adrCount, adr_with_rules_count: ctx.adrWithRulesCount, + adr_domains_count: ctx.domains.length, + // --- Repo identity (non-identifying) --- + repo_is_git: repo?.isGit ?? false, + repo_host: repo?.host ?? null, + repo_id: repo?.repoId ?? null, + git_default_branch: repo?.defaultBranch ?? null, + // --- Geo privacy --- // Signal PostHog to resolve geo then discard the IP $ip: null, }; @@ -70,11 +137,15 @@ function getCommonProperties(): Record { /** * Initialize telemetry. Call once at CLI startup. * If telemetry is disabled, this is a no-op and all subsequent calls are too. + * + * Returns a promise that resolves once the async repo-context lookup is done. + * Callers can safely `trackEvent()` before awaiting — events emitted during + * the window before repo resolution just won't include `repo_id` / `repo_host`. */ -export function initTelemetry(): void { +export function initTelemetry(): Promise { if (!isTelemetryEnabled()) { logDebug("Telemetry disabled — skipping init"); - return; + return Promise.resolve(); } distinctId = getInstallId(); @@ -93,6 +164,25 @@ export function initTelemetry(): void { } catch { logDebug("Telemetry init failed (silently ignored)"); } + + // Resolve the repo context asynchronously. The result lands in module state + // so subsequent events pick it up without blocking the CLI startup path. + return getRepoContext() + .then((ctx) => { + repoContextSnapshot = ctx; + }) + .catch((err) => { + logDebug("Repo context resolution failed (ignored):", String(err)); + }); +} + +/** + * Returns true when the process is running under `bun test`. + * Guards against tests emitting real events into the prod PostHog project — + * matches the `NODE_ENV=test` pattern ARCH-005 already requires for Sentry. + */ +function isTestEnvironment(): boolean { + return Bun.env.NODE_ENV === "test"; } /** @@ -104,6 +194,7 @@ export function trackEvent( properties?: Record ): void { if (!initialized || !client) return; + if (isTestEnvironment()) return; try { client.capture({ @@ -130,16 +221,23 @@ export function trackCommand( /** * Track command completion with exit code and duration. + * + * `extra` carries the outcome classification (`success` / `user_error` / + * `internal_error` / `cancelled`) and an optional `error_kind` bucket. This + * keeps the event shape uniform whether the command exits via the Commander + * `postAction` hook (happy path) or via `exitWith()` (failure path). */ export function trackCommandResult( command: string, exitCode: number, - durationMs: number + durationMs: number, + extra?: Record ): void { trackEvent("command_completed", { command, exit_code: exitCode, duration_ms: durationMs, + ...extra, }); } @@ -159,6 +257,9 @@ export function trackCheckResult(properties: { used_staged: boolean; used_file_filter: boolean; used_adr_filter: boolean; + files_scanned?: number; + load_duration_ms?: number; + check_duration_ms?: number; }): void { trackEvent("check_completed", properties); } @@ -175,6 +276,37 @@ export function trackInitResult(properties: { trackEvent("init_completed", properties); } +/** + * Track the `project_initialized` event on `archgate init`. + * + * Identity (raw remote URL / owner / name) ships only when the repo is + * confirmed public on a recognised host AND the user has not opted out via + * `--no-share-repo-identity` or `ARCHGATE_SHARE_REPO_IDENTITY=0`. The hashed + * `repo_id` is always included via common properties — it lets us count + * repos without learning names. + */ +export function trackProjectInitialized(properties: { + editors: string[]; + editor_primary: string; + plugin_installed: boolean; + had_existing_project: boolean; + identity_shared: boolean; + /** Repo host as classified by `parseRemoteUrl`; null if no remote. */ + repo_host: string | null; + repo_is_git: boolean; + /** + * Public-visibility probe: `true`/`false` if determined via the host API, + * `null` for self-hosted, unknown, network failure, or rate-limited. + */ + repo_public: boolean | null; + /** Only populated when `identity_shared` is true. */ + remote_url?: string | null; + repo_owner?: string | null; + repo_name?: string | null; +}): void { + trackEvent("project_initialized", properties); +} + /** * Track the outcome of `archgate upgrade`. */ @@ -183,6 +315,8 @@ export function trackUpgradeResult(properties: { to_version: string; install_method: string; success: boolean; + prompted_by_update_check?: boolean; + failure_reason?: string; }): void { trackEvent("upgrade_completed", properties); } @@ -193,10 +327,21 @@ export function trackUpgradeResult(properties: { export function trackLoginResult(properties: { subcommand: "login" | "logout" | "refresh" | "status"; success: boolean; + failure_reason?: "network" | "tls" | "denied" | "other"; }): void { trackEvent("login_completed", properties); } +/** + * Track preference changes so we can measure opt-out rate. Fires one last + * event right before disabling telemetry, and a fresh event when re-enabling. + */ +export function trackTelemetryPreferenceChange(properties: { + enabled: boolean; +}): void { + trackEvent("telemetry_preference_changed", properties); +} + /** * Flush pending events to PostHog. Call before process exit to ensure * events are delivered. @@ -232,9 +377,15 @@ export function _resetTelemetry(): void { client = null; initialized = false; distinctId = ""; + repoContextSnapshot = null; } /** Get the PostHog client instance. For testing only. */ export function _getClient(): PostHog | null { return client; } + +/** Inject a repo context snapshot. For testing only. */ +export function _setRepoContextSnapshot(ctx: RepoContext | null): void { + repoContextSnapshot = ctx; +} diff --git a/tests/helpers/exit.test.ts b/tests/helpers/exit.test.ts new file mode 100644 index 0000000..c834c3b --- /dev/null +++ b/tests/helpers/exit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; + +import { + beginCommand, + finalizeCommand, + _getExitState, + _resetExitState, +} from "../../src/helpers/exit"; + +describe("exit helper", () => { + let originalNodeEnv: string | undefined; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "test"; + _resetExitState(); + }); + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + _resetExitState(); + }); + + describe("beginCommand", () => { + test("stashes the command name and start time", () => { + beginCommand("adr create"); + const state = _getExitState(); + expect(state.currentCommand).toBe("adr create"); + expect(state.commandStartTime).not.toBeNull(); + expect(state.completionTracked).toBe(false); + }); + + test("resets completion guard across consecutive invocations", () => { + beginCommand("adr create"); + finalizeCommand("adr create", 0, "success"); + expect(_getExitState().completionTracked).toBe(true); + + beginCommand("check"); + expect(_getExitState().completionTracked).toBe(false); + expect(_getExitState().currentCommand).toBe("check"); + }); + }); + + describe("finalizeCommand", () => { + test("flips the completion guard once", () => { + beginCommand("check"); + finalizeCommand("check", 0, "success"); + expect(_getExitState().completionTracked).toBe(true); + + // Second call is a no-op — no way to assert "didn't send" without a mock, + // but we at least verify the guard stays true. + finalizeCommand("check", 0, "success"); + expect(_getExitState().completionTracked).toBe(true); + }); + + test("does not throw when called before beginCommand", () => { + // The Commander postAction hook could race beginCommand if Commander + // ever changes its lifecycle — finalizeCommand must degrade gracefully. + expect(() => finalizeCommand("", 0, "success")).not.toThrow(); + }); + }); +}); diff --git a/tests/helpers/install-info.test.ts b/tests/helpers/install-info.test.ts index 0eacf7d..c78e803 100644 --- a/tests/helpers/install-info.test.ts +++ b/tests/helpers/install-info.test.ts @@ -34,10 +34,12 @@ describe("install-info", () => { expect(ctx.domains.length).toBeGreaterThan(0); }); - test("result is cached across calls", () => { + test("returns equal (not identical) contexts across calls", () => { + // getProjectContext is no longer cached — each call re-reads the + // filesystem so post-init events reflect newly-created ADRs. const first = getProjectContext(); const second = getProjectContext(); - expect(first).toBe(second); + expect(first).toEqual(second); }); test("domains are sorted alphabetically", () => { diff --git a/tests/helpers/repo-probe.test.ts b/tests/helpers/repo-probe.test.ts new file mode 100644 index 0000000..26e6d8c --- /dev/null +++ b/tests/helpers/repo-probe.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test, afterEach, beforeEach } from "bun:test"; + +import { + _resetPublicProbeCache, + isPublicRepo, +} from "../../src/helpers/repo-probe"; + +describe("isPublicRepo", () => { + // ARCH-005: assign `globalThis.fetch` directly. `mock.module("node:fetch")` + // does not intercept Bun's runtime fetch. + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + _resetPublicProbeCache(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + _resetPublicProbeCache(); + }); + + /** + * Helper: build a fake fetch that resolves with a minimal `Response`-ish + * object. Keeps the individual tests focused on the status + body shape + * that matters for the assertion. + */ + function mockFetch(status: number, body: unknown = {}): void { + globalThis.fetch = (() => + Promise.resolve({ + status, + json: () => Promise.resolve(body), + })) as unknown as typeof fetch; + } + + test("returns null for repos with missing host/owner/name", async () => { + expect( + await isPublicRepo({ host: null, owner: null, name: null }) + ).toBeNull(); + expect( + await isPublicRepo({ host: "github", owner: null, name: "bar" }) + ).toBeNull(); + }); + + test("returns null for unrecognised hosts", async () => { + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "other", owner: "foo", name: "bar" }) + ).toBeNull(); + }); + + test("returns true when GitHub API responds with {private: false}", async () => { + mockFetch(200, { private: false }); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "github", owner: "foo", name: "bar" }) + ).toBe(true); + }); + + test("returns false for a 404 (private or nonexistent)", async () => { + mockFetch(404); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "github", owner: "foo", name: "bar" }) + ).toBe(false); + }); + + test("returns null on rate-limit (403)", async () => { + mockFetch(403); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "github", owner: "foo", name: "bar" }) + ).toBeNull(); + }); + + test("returns null on network error", async () => { + globalThis.fetch = (() => + Promise.reject( + new Error("connect ECONNREFUSED") + )) as unknown as typeof fetch; + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "github", owner: "foo", name: "bar" }) + ).toBeNull(); + }); + + test("recognises GitLab visibility=public", async () => { + mockFetch(200, { visibility: "public" }); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "gitlab", owner: "foo", name: "bar" }) + ).toBe(true); + }); + + test("recognises Bitbucket is_private=false", async () => { + mockFetch(200, { is_private: false }); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ host: "bitbucket", owner: "foo", name: "bar" }) + ).toBe(true); + }); + + test("recognises Azure DevOps visibility=public", async () => { + mockFetch(200, { visibility: "public" }); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ + host: "azure-devops", + owner: "myorg/myproject", + name: "myrepo", + }) + ).toBe(true); + }); + + test("Azure DevOps 401 (auth required) is treated as private", async () => { + mockFetch(401); + _resetPublicProbeCache(); + expect( + await isPublicRepo({ + host: "azure-devops", + owner: "myorg/myproject", + name: "myrepo", + }) + ).toBe(false); + }); + + test("Azure DevOps probe returns null when owner isn't org/project", async () => { + // Even if somehow classified, a single-segment owner can't resolve + // to an organization + project pair — refuse to guess. + globalThis.fetch = (() => { + throw new Error("should not be called"); + }) as unknown as typeof fetch; + _resetPublicProbeCache(); + expect( + await isPublicRepo({ + host: "azure-devops", + owner: "onlyorg", + name: "repo", + }) + ).toBeNull(); + }); + + test("caches the result per process (single fetch call)", async () => { + let calls = 0; + globalThis.fetch = (() => { + calls++; + return Promise.resolve({ + status: 200, + json: () => Promise.resolve({ private: false }), + }); + }) as unknown as typeof fetch; + _resetPublicProbeCache(); + + const repo = { host: "github" as const, owner: "foo", name: "bar" }; + await isPublicRepo(repo); + await isPublicRepo(repo); + await isPublicRepo(repo); + expect(calls).toBe(1); + }); +}); diff --git a/tests/helpers/repo.test.ts b/tests/helpers/repo.test.ts new file mode 100644 index 0000000..e864861 --- /dev/null +++ b/tests/helpers/repo.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test, afterEach, beforeEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getRepoContext, + hashRepoId, + parseRemoteUrl, + shouldShareRepoIdentity, + _resetRepoContextCache, +} from "../../src/helpers/repo"; + +describe("repo helper", () => { + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + }); + + afterEach(() => { + process.chdir(originalCwd); + _resetRepoContextCache(); + }); + + describe("parseRemoteUrl", () => { + test("parses HTTPS GitHub remotes", () => { + const parsed = parseRemoteUrl("https://github.com/foo/bar.git"); + expect(parsed.host).toBe("github"); + expect(parsed.owner).toBe("foo"); + expect(parsed.name).toBe("bar"); + expect(parsed.normalized).toBe("github.com/foo/bar"); + }); + + test("parses SCP-style SSH GitHub remotes", () => { + const parsed = parseRemoteUrl("git@github.com:foo/Bar.git"); + expect(parsed.host).toBe("github"); + expect(parsed.owner).toBe("foo"); + expect(parsed.name).toBe("Bar"); + // Normalization lowercases so the hash is stable regardless of URL style + expect(parsed.normalized).toBe("github.com/foo/bar"); + }); + + test("HTTPS and SCP-style URLs for the same repo normalize identically", () => { + const https = parseRemoteUrl("https://github.com/foo/Bar.git"); + const scp = parseRemoteUrl("git@github.com:foo/Bar.git"); + expect(https.normalized).toBe(scp.normalized); + }); + + test("classifies non-GitHub hosts", () => { + expect(parseRemoteUrl("git@gitlab.com:foo/bar.git").host).toBe("gitlab"); + expect(parseRemoteUrl("https://bitbucket.org/foo/bar").host).toBe( + "bitbucket" + ); + expect( + parseRemoteUrl("https://self-hosted.example.com/foo/bar").host + ).toBe("other"); + }); + + test("handles GitLab subgroups (multi-segment owner)", () => { + const parsed = parseRemoteUrl("https://gitlab.com/foo/sub/bar.git"); + expect(parsed.owner).toBe("foo/sub"); + expect(parsed.name).toBe("bar"); + }); + + test("parses Azure DevOps HTTPS URLs (modern dev.azure.com)", () => { + const parsed = parseRemoteUrl( + "https://dev.azure.com/myorg/myproject/_git/myrepo" + ); + expect(parsed.host).toBe("azure-devops"); + // owner encodes both organization and project + expect(parsed.owner).toBe("myorg/myproject"); + expect(parsed.name).toBe("myrepo"); + expect(parsed.normalized).toBe("dev.azure.com/myorg/myproject/myrepo"); + }); + + test("parses Azure DevOps SSH URLs (ssh.dev.azure.com:v3/...)", () => { + const parsed = parseRemoteUrl( + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" + ); + expect(parsed.host).toBe("azure-devops"); + expect(parsed.owner).toBe("myorg/myproject"); + expect(parsed.name).toBe("myrepo"); + }); + + test("parses legacy Azure DevOps visualstudio.com URLs", () => { + const parsed = parseRemoteUrl( + "https://myorg.visualstudio.com/myproject/_git/myrepo" + ); + expect(parsed.host).toBe("azure-devops"); + expect(parsed.owner).toBe("myorg/myproject"); + expect(parsed.name).toBe("myrepo"); + // Legacy host normalises to dev.azure.com so the same repo via both + // URL shapes produces the same repo_id. + expect(parsed.normalized).toBe("dev.azure.com/myorg/myproject/myrepo"); + }); + + test("Azure DevOps HTTPS, SSH, and visualstudio.com hash to the same repo_id", () => { + const https = parseRemoteUrl( + "https://dev.azure.com/myorg/myproject/_git/myrepo" + ); + const ssh = parseRemoteUrl( + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" + ); + const vs = parseRemoteUrl( + "https://myorg.visualstudio.com/myproject/_git/myrepo" + ); + expect(https.normalized).toBe(ssh.normalized); + expect(ssh.normalized).toBe(vs.normalized); + }); + + test("returns all-null on garbage input", () => { + const parsed = parseRemoteUrl("not a url"); + expect(parsed.host).toBeNull(); + expect(parsed.owner).toBeNull(); + expect(parsed.name).toBeNull(); + }); + }); + + describe("hashRepoId", () => { + test("produces a stable 16-char hex id for a given normalized url", () => { + const id = hashRepoId("github.com/foo/bar"); + expect(id).toMatch(/^[0-9a-f]{16}$/); + expect(hashRepoId("github.com/foo/bar")).toBe(id); + }); + + test("differs across different repos", () => { + expect(hashRepoId("github.com/foo/bar")).not.toBe( + hashRepoId("github.com/foo/baz") + ); + }); + }); + + describe("shouldShareRepoIdentity", () => { + test("shares identity for confirmed-public repos", () => { + expect(shouldShareRepoIdentity(true)).toBe(true); + }); + + test("does NOT share for private repos", () => { + expect(shouldShareRepoIdentity(false)).toBe(false); + }); + + test("does NOT share when public status is unknown (self-hosted/error/rate-limited)", () => { + // `null` is the probe's "couldn't determine" signal — we must never + // fall through to sharing on a "maybe". Users who want zero events + // disable telemetry itself; there's no identity-specific opt-out. + expect(shouldShareRepoIdentity(null)).toBe(false); + }); + }); + + describe("getRepoContext", () => { + test("returns isGit=false for a non-git directory", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "archgate-repo-test-")); + try { + process.chdir(tempDir); + _resetRepoContextCache(); + const ctx = await getRepoContext(); + expect(ctx.isGit).toBe(false); + expect(ctx.repoId).toBeNull(); + expect(ctx.host).toBeNull(); + } finally { + // Must leave the temp dir before removing it — Windows refuses to + // delete a directory that is a process's CWD. + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("populates host/repoId when the CWD is a git repo with a remote", async () => { + // Using the CLI's own repo — it has a github.com/archgate/cli remote. + _resetRepoContextCache(); + const ctx = await getRepoContext(); + expect(ctx.isGit).toBe(true); + if (ctx.host) { + expect(ctx.repoId).toMatch(/^[0-9a-f]{16}$/); + } + }); + }); +}); diff --git a/tests/helpers/telemetry.test.ts b/tests/helpers/telemetry.test.ts index 49a07be..556e542 100644 --- a/tests/helpers/telemetry.test.ts +++ b/tests/helpers/telemetry.test.ts @@ -7,13 +7,18 @@ describe("telemetry", () => { let tempDir: string; let originalHome: string | undefined; let originalTelemetryEnv: string | undefined; + let originalNodeEnv: string | undefined; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-telemetry-test-")); originalHome = process.env.HOME; originalTelemetryEnv = process.env.ARCHGATE_TELEMETRY; + originalNodeEnv = process.env.NODE_ENV; process.env.HOME = tempDir; delete process.env.ARCHGATE_TELEMETRY; + // NODE_ENV=test keeps trackEvent from sending real events to the prod + // PostHog project. See ARCH-005 for the equivalent Sentry convention. + process.env.NODE_ENV = "test"; }); afterEach(async () => { @@ -23,6 +28,11 @@ describe("telemetry", () => { } else { process.env.ARCHGATE_TELEMETRY = originalTelemetryEnv; } + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } rmSync(tempDir, { recursive: true, force: true }); const { _resetTelemetry } = await import("../../src/helpers/telemetry"); @@ -37,7 +47,7 @@ describe("telemetry", () => { const { initTelemetry, _getClient } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); expect(_getClient()).not.toBeNull(); }); @@ -47,7 +57,7 @@ describe("telemetry", () => { const { initTelemetry, _getClient } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); expect(_getClient()).toBeNull(); }); }); @@ -57,8 +67,9 @@ describe("telemetry", () => { const { initTelemetry, trackEvent } = await import("../../src/helpers/telemetry"); - initTelemetry(); - // Should not throw — events are queued internally by the SDK + await initTelemetry(); + // Should not throw — events are queued internally by the SDK (and + // suppressed entirely in test mode by trackEvent itself) trackEvent("command_executed", { command: "check" }); }); @@ -75,7 +86,7 @@ describe("telemetry", () => { const { initTelemetry, trackCommand } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); // Should not throw trackCommand("adr create", { json: true }); }); @@ -86,7 +97,7 @@ describe("telemetry", () => { const { initTelemetry, trackCheckResult } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); trackCheckResult({ total_rules: 5, passed: 4, @@ -108,7 +119,7 @@ describe("telemetry", () => { const { initTelemetry, trackInitResult } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); trackInitResult({ editor: "claude", plugin_installed: true, @@ -123,7 +134,7 @@ describe("telemetry", () => { const { initTelemetry, trackUpgradeResult } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); trackUpgradeResult({ from_version: "0.24.0", to_version: "0.25.0", @@ -138,17 +149,66 @@ describe("telemetry", () => { const { initTelemetry, trackLoginResult } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); trackLoginResult({ subcommand: "login", success: true }); }); }); + describe("trackProjectInitialized", () => { + test("captures project_initialized event without throwing", async () => { + const { initTelemetry, trackProjectInitialized } = + await import("../../src/helpers/telemetry"); + + await initTelemetry(); + trackProjectInitialized({ + editors: ["claude"], + editor_primary: "claude", + plugin_installed: false, + had_existing_project: false, + identity_shared: false, + repo_host: "github", + repo_is_git: true, + repo_public: null, + }); + }); + + test("accepts identity fields when sharing", async () => { + const { initTelemetry, trackProjectInitialized } = + await import("../../src/helpers/telemetry"); + + await initTelemetry(); + trackProjectInitialized({ + editors: ["claude"], + editor_primary: "claude", + plugin_installed: false, + had_existing_project: false, + identity_shared: true, + repo_host: "github", + repo_is_git: true, + repo_public: true, + remote_url: "https://github.com/archgate/cli.git", + repo_owner: "archgate", + repo_name: "cli", + }); + }); + }); + + describe("trackTelemetryPreferenceChange", () => { + test("captures telemetry_preference_changed event without throwing", async () => { + const { initTelemetry, trackTelemetryPreferenceChange } = + await import("../../src/helpers/telemetry"); + + await initTelemetry(); + trackTelemetryPreferenceChange({ enabled: false }); + }); + }); + describe("flushTelemetry", () => { test("flushes without throwing when initialized", async () => { const { initTelemetry, flushTelemetry } = await import("../../src/helpers/telemetry"); - initTelemetry(); + await initTelemetry(); // Flush with no pending events — should resolve quickly await flushTelemetry();