diff --git a/.archgate/adrs/ARCH-014-prefer-bun-env.md b/.archgate/adrs/ARCH-014-prefer-bun-env.md new file mode 100644 index 00000000..a272d9f1 --- /dev/null +++ b/.archgate/adrs/ARCH-014-prefer-bun-env.md @@ -0,0 +1,131 @@ +--- +id: ARCH-014 +title: Prefer Bun.env over process.env +domain: architecture +rules: true +files: + - "src/**/*.ts" +--- + +## Context + +The CLI runs exclusively on Bun (`>=1.2.21`), never on Node.js. Bun provides `Bun.env` as its native environment variable accessor, while `process.env` is a Node.js compatibility shim that Bun maintains for backward compatibility. + +Using `process.env` in a Bun-only codebase has several drawbacks: + +1. **Type inconsistency** — `process.env` returns `string | undefined` for every key, requiring manual type narrowing. `Bun.env` behaves identically at runtime but signals intent: this code is Bun-native, not a Node.js port. +2. **Misleading provenance** — When a developer reads `process.env`, they assume Node.js semantics and may reach for Node.js documentation. `Bun.env` makes the runtime dependency explicit. +3. **Inconsistent codebase style** — A mix of `process.env` and `Bun.env` across files creates confusion about which accessor is canonical. New contributors copy whichever pattern they encounter first. +4. **Alignment with project philosophy** — [ARCH-006 (Dependency Policy)](./ARCH-006-dependency-policy.md) establishes a "prefer Bun built-ins" principle. Environment variable access is no different: Bun provides a native API, and the CLI should use it consistently. + +**Alternatives considered:** + +- **Continue using `process.env`** — The most familiar option for developers with Node.js background. However, it obscures the Bun-native nature of the project and creates style inconsistency as new code adopts `Bun.env`. +- **Wrapper helper (e.g., `getEnv()`)** — Centralizing env access through a helper would add indirection for no practical benefit. `Bun.env` is already a clean, well-typed API — wrapping it would violate the project's minimal-abstraction philosophy. +- **Allow both interchangeably** — Permitting both `process.env` and `Bun.env` would perpetuate the inconsistency that prompted this decision. A single canonical accessor is easier to enforce and review. + +For Archgate, the CLI entry point already validates `typeof Bun !== "undefined"` and rejects non-Bun runtimes. Every source file in `src/` can safely assume Bun is available, making `Bun.env` the natural choice. + +## Decision + +All environment variable access in `src/` MUST use `Bun.env` instead of `process.env`. The `process.env` object MUST NOT be used in source files. + +**Scope:** This ADR covers all TypeScript source files under `src/`. It does NOT cover: + +- Test files (`tests/**/*.ts`) — tests may use `process.env` for setup/teardown (e.g., overriding `HOME`) since test harness compatibility matters +- Build scripts and configuration files outside `src/` +- Third-party code in `node_modules/` + +**Key constraints:** + +1. **`Bun.env` for all env reads** — Replace `process.env.FOO` with `Bun.env.FOO` everywhere in `src/` +2. **`Bun.env` for all env writes** — Replace `process.env.FOO = "bar"` with `Bun.env.FOO = "bar"` (Bun.env is writable) +3. **No `process.env` references** — Not even in comments that suggest using it (e.g., "// Use process.env.DEBUG to enable") + +## Do's and Don'ts + +### Do + +- **DO** use `Bun.env.FOO` to read environment variables in all source files under `src/` +- **DO** use `Bun.env.FOO = "value"` to set environment variables when needed +- **DO** use nullish coalescing for defaults: `Bun.env.NODE_ENV ?? "production"` +- **DO** use `Boolean(Bun.env.CI)` for truthy checks on environment flags +- **DO** keep `process.env` in test files (`tests/`) where test harness compatibility is needed + +### Don't + +- **DON'T** use `process.env` in any file under `src/` — use `Bun.env` instead +- **DON'T** create wrapper functions around `Bun.env` — access it directly +- **DON'T** destructure `Bun.env` (e.g., `const { HOME } = Bun.env`) — the proxy-based implementation may not support it reliably across versions; access properties individually + +## Implementation Pattern + +### Good Example + +```typescript +// src/helpers/paths.ts — reading env vars +const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? homedir(); + +// src/helpers/output.ts — checking CI flag +export function isAgentContext(): boolean { + return !process.stdout.isTTY && !Bun.env.CI; +} + +// src/helpers/log.ts — debug flag +export function logDebug(...args: Parameters) { + if (Bun.env.DEBUG) { + console.warn(header, ...args); + } +} +``` + +### Bad Example + +```typescript +// BAD: using process.env in source files +const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); + +// BAD: mixing process.env and Bun.env in the same file +const debug = process.env.DEBUG; +const ci = Bun.env.CI; +``` + +## Consequences + +### Positive + +- **Consistent codebase style** — A single canonical env accessor eliminates style debates and makes grep/search reliable +- **Clear runtime signal** — `Bun.env` immediately communicates that this code is Bun-native, not a Node.js port +- **Aligned with ARCH-006** — Follows the established "prefer Bun built-ins" principle for all APIs +- **Automated enforcement** — The companion rule catches violations in CI, preventing regression + +### Negative + +- **Unfamiliar to Node.js developers** — Contributors with Node.js background will instinctively reach for `process.env`. The linting rule provides immediate feedback. +- **Test/source divergence** — Tests use `process.env` while source uses `Bun.env`. This is intentional but may confuse contributors unfamiliar with the distinction. + +### Risks + +- **Bun.env behavioral differences** — `Bun.env` is a Proxy object, not a plain object like `process.env`. Edge cases (e.g., `Object.keys()`, `JSON.stringify()`, spread) may behave differently. + - **Mitigation:** The CLI accesses env vars by name (`Bun.env.FOO`), never iterates or serializes the entire env object. This usage pattern is well-tested and stable. +- **Contributors bypass the rule** — New contributors may not know about `Bun.env` and use `process.env` out of habit. + - **Mitigation:** The automated rule (`ARCH-014/no-process-env`) flags violations at check time. CI blocks merging non-compliant code. + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** `ARCH-014/no-process-env`: Scans all source files under `src/` (excluding test files and `.archgate/`) for `process.env` usage and flags violations. Severity: `error`. + +### Manual Enforcement + +Code reviewers MUST verify: + +1. New source files use `Bun.env` exclusively — no `process.env` references +2. Refactored code migrates `process.env` to `Bun.env` when touched + +## References + +- [Bun.env documentation](https://bun.sh/docs/runtime/env) +- [ARCH-006 — Dependency Policy](./ARCH-006-dependency-policy.md) — Establishes the "prefer Bun built-ins" principle +- [ARCH-009 — Centralized Platform Detection](./ARCH-009-platform-detection-helper.md) — Similar pattern: centralizing a runtime API behind a project convention diff --git a/.archgate/adrs/ARCH-014-prefer-bun-env.rules.ts b/.archgate/adrs/ARCH-014-prefer-bun-env.rules.ts new file mode 100644 index 00000000..215b1ca3 --- /dev/null +++ b/.archgate/adrs/ARCH-014-prefer-bun-env.rules.ts @@ -0,0 +1,30 @@ +/// + +export default { + rules: { + "no-process-env": { + description: + "Source files must use Bun.env instead of process.env for environment variable access", + async check(ctx) { + const files = ctx.scopedFiles.filter( + (f) => !f.includes("tests/") && !f.includes(".archgate/") + ); + + const matches = await Promise.all( + files.map((file) => ctx.grep(file, /process\.env\b/)) + ); + for (const fileMatches of matches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + "Use Bun.env instead of process.env — this is a Bun-native CLI (see ARCH-014).", + file: m.file, + line: m.line, + fix: "Replace process.env.VAR with Bun.env.VAR", + }); + } + } + }, + }, + }, +} satisfies RuleSet; diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 544229d8..12770e5c 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -57,6 +57,10 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **Gitignore and prettierignore rules** — `packages/*/bin/archgate` is gitignored (binary injected by CI); `packages/*/bin/` is prettier-ignored (binary files must not be formatted). - **npm OIDC publishing** — Uses `id-token: write` permission + `actions/setup-node@v4` with `registry-url: 'https://registry.npmjs.org'`. No `NODE_AUTH_TOKEN` secret needed for OIDC trusted publishing — matches the pattern in `release.yml`. +## Telemetry Strategy + +- [Telemetry & Analytics Strategy](project_telemetry_strategy.md) — Decisions on PostHog analytics, Sentry error tracking, plugin surveys (2026-03-22) + ## MCP Tools Structure - MCP tools live in `src/mcp/tools/` — one file per tool (check, list-adrs, review-context, claude-code-session-context, cursor-session-context) diff --git a/bun.lock b/bun.lock index d0b37dcf..d1e81d4a 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@commitlint/cz-commitlint": "20.5.0", + "@sentry/node-core": "10.45.0", "@simple-release/npm": "2.3.0", "@types/bun": "1.3.11", "commitizen": "4.3.1", @@ -16,6 +17,7 @@ "inquirer": "13.3.2", "oxfmt": "0.41.0", "oxlint": "1.56.0", + "posthog-node": "5.28.5", "zod": "4.3.6", }, "optionalDependencies": { @@ -105,6 +107,18 @@ "@inquirer/type": ["@inquirer/type@4.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.41.0", "", { "os": "android", "cpu": "arm64" }, "sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g=="], @@ -181,6 +195,14 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="], + "@posthog/core": ["@posthog/core@1.24.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-e8AciAnc6MRFws89ux8lJKFAaI03yEon0ASDoUO7yS91FVqbUGXYekObUUR3LHplcg+pmyiJBI0jolY0SFbGRA=="], + + "@sentry/core": ["@sentry/core@10.45.0", "", {}, "sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q=="], + + "@sentry/node-core": ["@sentry/node-core@10.45.0", "", { "dependencies": { "@sentry/core": "10.45.0", "@sentry/opentelemetry": "10.45.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/context-async-hooks", "@opentelemetry/core", "@opentelemetry/instrumentation", "@opentelemetry/resources", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-KQZEvLKM344+EqXiA9HIzWbW5hzq6/9nnFUQ8niaBPoOgR9AiJhrccfIscfgb8vjkriiEtzE03OW/4h1CTgZ3Q=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.45.0", "", { "dependencies": { "@sentry/core": "10.45.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-PmuGO+p/gC3ZQ8ddOeJ5P9ApnTTm35i12Bpuyb13AckCbNSJFvG2ggZda35JQOmiFU0kKYiwkoFAa8Mvj9od3Q=="], + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.1", "", { "dependencies": { "@simple-libs/stream-utils": "^1.1.0", "@types/node": "^22.0.0" } }, "sha512-3nWd8irxvDI6v856wpPCHZ+08iQR0oHTZfzAZmnbsLzf+Sf1odraP6uKOHDZToXq3RPRV/LbqGVlSCogm9cJjg=="], "@simple-libs/hosted-git-info": ["@simple-libs/hosted-git-info@1.0.2", "", {}, "sha512-aAmGQdMH+ZinytKuA2832u0ATeOFNYNk4meBEXtB5xaPotUgggYNhq5tYU/v17wEbmTW5P9iHNqNrFyrhnqBAg=="], @@ -199,6 +221,10 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], @@ -235,6 +261,8 @@ "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -279,6 +307,8 @@ "cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.1.0", "", { "dependencies": { "jiti": "^2.4.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cz-conventional-changelog": ["cz-conventional-changelog@3.3.0", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "conventional-commit-types": "^3.0.0", "lodash.map": "^4.5.1", "longest": "^2.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw=="], "dedent": ["dedent@0.7.0", "", {}, "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA=="], @@ -359,6 +389,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-in-the-middle": ["import-in-the-middle@3.0.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -447,6 +479,8 @@ "minimist": ["minimist@1.2.7", "", {}, "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -473,10 +507,14 @@ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "posthog-node": ["posthog-node@5.28.5", "", { "dependencies": { "@posthog/core": "1.24.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-+8H7rMPB48cwKhzZmq0EQXDFWjdT6yQrecq+f7c7m19HMzyvYtm54I7vNncEr9lrkzczc4kcePxwLD4qgt/YXg=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -499,6 +537,10 @@ "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -553,7 +595,7 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -607,6 +649,8 @@ "global-prefix/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "handlebars/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 18e54d08..14f0a309 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -228,6 +228,7 @@ export default defineConfig({ { label: "CLI Commands", slug: "reference/cli-commands" }, { label: "Rule API", slug: "reference/rule-api" }, { label: "ADR Schema", slug: "reference/adr-schema" }, + { label: "Telemetry", slug: "reference/telemetry" }, ], }, { diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index b75b35f5..3af4e10a 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -3798,6 +3798,100 @@ type RuleSet = { rules: Record }; --- +## Reference: Telemetry + +Source: https://cli.archgate.dev/reference/telemetry/ + +Archgate collects **anonymous usage data** to help us understand how the CLI is used, prioritize features, and fix crashes. This page explains exactly what is collected, what is not, and how to opt out. + +## What we collect + +### Usage analytics (PostHog) + +When you run an Archgate command, we record: + +- **Command name** and **flags used** (e.g., `check --json`, not flag values) +- **Exit code** (0, 1, or 2) and **execution duration** +- **Environment**: OS, architecture, Bun version, Archgate version, CI detection, TTY detection +- **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 + +### Error tracking (Sentry) + +When the CLI crashes (exit code 2), we send: + +- **Error type, message, and stack trace** (file paths are stripped to relative paths like `src/cli.ts`) +- **Runtime context**: OS, architecture, Bun version, Archgate version +- **Anonymous install ID** (same random UUID as analytics) + +## 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 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 + +## IP anonymization + +Archgate uses PostHog's built-in IP anonymization: + +1. Your CLI sends an event to PostHog with `$ip: null` +2. PostHog resolves your IP to a **country and region** (e.g., "US", "California") server-side +3. The IP address is then **discarded** — it is never stored in PostHog + +For Sentry error tracking, the project has **"Prevent Storing of IP Addresses"** enabled, so IPs are stripped before storage. + +## How to opt out + +You can disable all telemetry (both analytics and error tracking) in two ways: + +### Environment variable + +```bash +export ARCHGATE_TELEMETRY=0 +``` + +Accepted values: `0`, `false`, `no`, `off` (case-insensitive). + +Add this to your shell profile (`.bashrc`, `.zshrc`, etc.) to disable permanently. + +### CLI command + +```bash +archgate telemetry disable +``` + +To re-enable: + +```bash +archgate telemetry enable +``` + +To check current status: + +```bash +archgate telemetry status +``` + +The environment variable takes precedence over the CLI setting. If `ARCHGATE_TELEMETRY=0` is set, telemetry is disabled regardless of the CLI config. + +## Where data is stored + +- **Analytics**: PostHog Cloud (US region). Data retained per PostHog's standard retention policy. +- **Error tracking**: Sentry Cloud (US region). Error events retained for 90 days. +- **Local config**: `~/.archgate/config.json` stores your telemetry preference and anonymous install ID. + +## Open source + +The telemetry implementation is fully open source. You can inspect exactly what data is collected by reading: + +- [`src/helpers/telemetry.ts`](https://github.com/archgate/cli/blob/main/src/helpers/telemetry.ts) — PostHog event tracking +- [`src/helpers/sentry.ts`](https://github.com/archgate/cli/blob/main/src/helpers/sentry.ts) — Sentry error capture +- [`src/helpers/telemetry-config.ts`](https://github.com/archgate/cli/blob/main/src/helpers/telemetry-config.ts) — Config and opt-out logic + +--- + ## Examples: clean-architecture-layers Source: https://cli.archgate.dev/examples/clean-architecture-layers/ diff --git a/docs/src/content/docs/pt-br/reference/telemetry.mdx b/docs/src/content/docs/pt-br/reference/telemetry.mdx new file mode 100644 index 00000000..d21c2e85 --- /dev/null +++ b/docs/src/content/docs/pt-br/reference/telemetry.mdx @@ -0,0 +1,92 @@ +--- +title: Telemetria +description: Quais dados de uso anônimos o Archgate coleta, como desativar e nossos compromissos de privacidade. +--- + +O Archgate coleta **dados de uso anônimos** para nos ajudar a entender como a CLI é utilizada, priorizar funcionalidades e corrigir falhas. Esta página explica exatamente o que é coletado, o que não é, e como desativar. + +## O que coletamos + +### Análise de uso (PostHog) + +Quando você executa um comando do Archgate, registramos: + +- **Nome do comando** e **flags utilizadas** (ex: `check --json`, não os valores das flags) +- **Código de saída** (0, 1 ou 2) e **duração da execução** +- **Ambiente**: SO, arquitetura, versão do Bun, versão do Archgate, detecção de CI, detecção de TTY +- **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 + +### Rastreamento de erros (Sentry) + +Quando a CLI falha (código de saída 2), enviamos: + +- **Tipo do erro, mensagem e stack trace** (caminhos de arquivos são reduzidos para caminhos relativos como `src/cli.ts`) +- **Contexto de execução**: SO, arquitetura, versão do Bun, versão do Archgate +- **ID de instalação anônimo** (mesmo UUID aleatório da análise) + +## 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 +- **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 + +## Anonimização de IP + +O Archgate usa a anonimização de IP nativa do PostHog: + +1. Sua CLI envia um evento ao PostHog com `$ip: null` +2. O PostHog resolve seu IP para um **país e região** (ex: "BR", "São Paulo") no servidor +3. O endereço IP é então **descartado** — nunca é armazenado no PostHog + +Para o rastreamento de erros do Sentry, o projeto tem **"Prevent Storing of IP Addresses"** habilitado, então os IPs são removidos antes do armazenamento. + +## Como desativar + +Você pode desativar toda a telemetria (análise e rastreamento de erros) de duas formas: + +### Variável de ambiente + +```bash +export ARCHGATE_TELEMETRY=0 +``` + +Valores aceitos: `0`, `false`, `no`, `off` (sem diferenciação de maiúsculas/minúsculas). + +Adicione ao seu perfil de shell (`.bashrc`, `.zshrc`, etc.) para desativar permanentemente. + +### Comando da CLI + +```bash +archgate telemetry disable +``` + +Para reativar: + +```bash +archgate telemetry enable +``` + +Para verificar o status atual: + +```bash +archgate telemetry status +``` + +A variável de ambiente tem prioridade sobre a configuração da CLI. Se `ARCHGATE_TELEMETRY=0` estiver definida, a telemetria é desativada independentemente da configuração da CLI. + +## Onde os dados são armazenados + +- **Análise**: PostHog Cloud (região dos EUA). Dados retidos conforme a política de retenção padrão do PostHog. +- **Rastreamento de erros**: Sentry Cloud (região dos EUA). Eventos de erro retidos por 90 dias. +- **Configuração local**: `~/.archgate/config.json` armazena sua preferência de telemetria e o ID de instalação anônimo. + +## Código aberto + +A implementação da telemetria é totalmente open source. Você pode inspecionar exatamente quais dados são coletados lendo: + +- [`src/helpers/telemetry.ts`](https://github.com/archgate/cli/blob/main/src/helpers/telemetry.ts) — Rastreamento de eventos PostHog +- [`src/helpers/sentry.ts`](https://github.com/archgate/cli/blob/main/src/helpers/sentry.ts) — Captura de erros Sentry +- [`src/helpers/telemetry-config.ts`](https://github.com/archgate/cli/blob/main/src/helpers/telemetry-config.ts) — Configuração e lógica de desativação diff --git a/docs/src/content/docs/reference/telemetry.mdx b/docs/src/content/docs/reference/telemetry.mdx new file mode 100644 index 00000000..3aac7e01 --- /dev/null +++ b/docs/src/content/docs/reference/telemetry.mdx @@ -0,0 +1,92 @@ +--- +title: Telemetry +description: What anonymous usage data Archgate collects, how to opt out, and our privacy commitments. +--- + +Archgate collects **anonymous usage data** to help us understand how the CLI is used, prioritize features, and fix crashes. This page explains exactly what is collected, what is not, and how to opt out. + +## What we collect + +### Usage analytics (PostHog) + +When you run an Archgate command, we record: + +- **Command name** and **flags used** (e.g., `check --json`, not flag values) +- **Exit code** (0, 1, or 2) and **execution duration** +- **Environment**: OS, architecture, Bun version, Archgate version, CI detection, TTY detection +- **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 + +### Error tracking (Sentry) + +When the CLI crashes (exit code 2), we send: + +- **Error type, message, and stack trace** (file paths are stripped to relative paths like `src/cli.ts`) +- **Runtime context**: OS, architecture, Bun version, Archgate version +- **Anonymous install ID** (same random UUID as analytics) + +## 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 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 + +## IP anonymization + +Archgate uses PostHog's built-in IP anonymization: + +1. Your CLI sends an event to PostHog with `$ip: null` +2. PostHog resolves your IP to a **country and region** (e.g., "US", "California") server-side +3. The IP address is then **discarded** — it is never stored in PostHog + +For Sentry error tracking, the project has **"Prevent Storing of IP Addresses"** enabled, so IPs are stripped before storage. + +## How to opt out + +You can disable all telemetry (both analytics and error tracking) in two ways: + +### Environment variable + +```bash +export ARCHGATE_TELEMETRY=0 +``` + +Accepted values: `0`, `false`, `no`, `off` (case-insensitive). + +Add this to your shell profile (`.bashrc`, `.zshrc`, etc.) to disable permanently. + +### CLI command + +```bash +archgate telemetry disable +``` + +To re-enable: + +```bash +archgate telemetry enable +``` + +To check current status: + +```bash +archgate telemetry status +``` + +The environment variable takes precedence over the CLI setting. If `ARCHGATE_TELEMETRY=0` is set, telemetry is disabled regardless of the CLI config. + +## Where data is stored + +- **Analytics**: PostHog Cloud (US region). Data retained per PostHog's standard retention policy. +- **Error tracking**: Sentry Cloud (US region). Error events retained for 90 days. +- **Local config**: `~/.archgate/config.json` stores your telemetry preference and anonymous install ID. + +## Open source + +The telemetry implementation is fully open source. You can inspect exactly what data is collected by reading: + +- [`src/helpers/telemetry.ts`](https://github.com/archgate/cli/blob/main/src/helpers/telemetry.ts) — PostHog event tracking +- [`src/helpers/sentry.ts`](https://github.com/archgate/cli/blob/main/src/helpers/sentry.ts) — Sentry error capture +- [`src/helpers/telemetry-config.ts`](https://github.com/archgate/cli/blob/main/src/helpers/telemetry-config.ts) — Config and opt-out logic diff --git a/package.json b/package.json index 9411cd5b..6321c19c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@commitlint/cz-commitlint": "20.5.0", + "@sentry/node-core": "10.45.0", "@simple-release/npm": "2.3.0", "@types/bun": "1.3.11", "commitizen": "4.3.1", @@ -67,6 +68,7 @@ "inquirer": "13.3.2", "oxfmt": "0.41.0", "oxlint": "1.56.0", + "posthog-node": "5.28.5", "zod": "4.3.6" }, "peerDependencies": { diff --git a/src/cli.ts b/src/cli.ts index 67338adf..42216ec5 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,11 +11,18 @@ import { registerLoginCommand } from "./commands/login"; import { registerPluginCommand } from "./commands/plugin/index"; import { registerReviewContextCommand } from "./commands/review-context"; import { registerSessionContextCommand } from "./commands/session-context/index"; +import { registerTelemetryCommand } from "./commands/telemetry"; import { registerUpgradeCommand } from "./commands/upgrade"; import { installGit } from "./helpers/git"; import { logError } from "./helpers/log"; import { createPathIfNotExists, paths } from "./helpers/paths"; import { isSupportedPlatform } from "./helpers/platform"; +import { + addBreadcrumb, + captureException, + flushSentry, + initSentry, +} from "./helpers/sentry"; import { checkForUpdatesIfNeeded } from "./helpers/update-check"; if (typeof Bun === "undefined") @@ -34,11 +41,20 @@ createPathIfNotExists(paths.cacheFolder); async function main() { await installGit(); + // Initialize error tracking (no-op if opted out) + initSentry(); + const program = new Command() .name("archgate") .version(packageJson.version) .description("AI governance for software development"); + // Add Sentry breadcrumb for each command execution + program.hook("preAction", (thisCommand) => { + const fullCommand = getFullCommandName(thisCommand); + addBreadcrumb("command", `Running: ${fullCommand}`); + }); + registerInitCommand(program); registerLoginCommand(program); registerAdrCommand(program); @@ -48,6 +64,7 @@ async function main() { registerPluginCommand(program); registerUpgradeCommand(program); registerCleanCommand(program); + registerTelemetryCommand(program); const isUpgrade = process.argv.includes("upgrade"); const updateCheckPromise = isUpgrade @@ -56,9 +73,31 @@ async function main() { await program.parseAsync(process.argv); const notice = await updateCheckPromise; if (notice) console.log(notice); + + // Flush Sentry events before exit + await flushSentry(); +} + +/** + * Reconstruct the full command name from Commander's command chain. + * E.g., "adr create" from the "create" subcommand of "adr". + */ +function getFullCommandName(command: Command): string { + const parts: string[] = []; + let current: Command | null = command; + while (current) { + const name = current.name(); + if (name && name !== "archgate") { + parts.unshift(name); + } + current = current.parent as Command | null; + } + return parts.join(" ") || "root"; } -main().catch((err: unknown) => { +main().catch(async (err: unknown) => { + captureException(err, { command: "main" }); + await flushSentry(); logError(String(err)); process.exit(2); }); diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts new file mode 100644 index 00000000..d287d480 --- /dev/null +++ b/src/commands/telemetry.ts @@ -0,0 +1,76 @@ +import type { Command } from "@commander-js/extra-typings"; + +import { logError } from "../helpers/log"; +import { + isTelemetryEnabled, + isEnvTelemetryDisabled, + setTelemetryEnabled, +} from "../helpers/telemetry-config"; + +export function registerTelemetryCommand(program: Command) { + const telemetry = program + .command("telemetry") + .description("Manage anonymous usage data collection"); + + telemetry + .command("status") + .description("Show current telemetry status") + .action(() => { + const enabled = isTelemetryEnabled(); + const envOverride = isEnvTelemetryDisabled(); + + if (envOverride) { + console.log( + "Telemetry is disabled (ARCHGATE_TELEMETRY environment variable)." + ); + } else if (enabled) { + console.log("Telemetry is enabled."); + console.log( + "Anonymous usage data helps improve Archgate. No personal information is collected." + ); + console.log( + "\nTo disable: `archgate telemetry disable` or set ARCHGATE_TELEMETRY=0" + ); + console.log("Learn more: https://cli.archgate.dev/reference/telemetry"); + } else { + console.log("Telemetry is disabled."); + console.log("To enable: `archgate telemetry enable`"); + } + }); + + telemetry + .command("enable") + .description("Enable anonymous usage data collection") + .action(async () => { + try { + if (isEnvTelemetryDisabled()) { + console.log( + "Note: ARCHGATE_TELEMETRY environment variable is set to disable telemetry." + ); + console.log( + "Remove the environment variable for this setting to take effect." + ); + } + await setTelemetryEnabled(true); + console.log( + "Telemetry enabled. Thank you for helping improve Archgate." + ); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + telemetry + .command("disable") + .description("Disable anonymous usage data collection") + .action(async () => { + try { + 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); + } + }); +} diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 7d8f2249..ad6d9208 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -61,8 +61,8 @@ function isBinaryInstall(): boolean { } function getProtoHome(): string { - const home = process.env.HOME ?? process.env.USERPROFILE ?? "~"; - return process.env.PROTO_HOME ?? join(home, ".proto"); + const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? "~"; + return Bun.env.PROTO_HOME ?? join(home, ".proto"); } function isProtoInstall(): boolean { diff --git a/src/helpers/log.ts b/src/helpers/log.ts index 05ce1b63..9f917874 100644 --- a/src/helpers/log.ts +++ b/src/helpers/log.ts @@ -1,11 +1,11 @@ import { styleText } from "node:util"; export function logDebug(...args: Parameters) { - if (process.env.DEBUG) { + if (Bun.env.DEBUG) { const header = styleText("bgWhite", "DEBUG:"); console.warn(header, ...args); } - if (process.env.TRACE) console.trace(); + if (Bun.env.TRACE) console.trace(); } export function logInfo(...args: Parameters) { diff --git a/src/helpers/output.ts b/src/helpers/output.ts index 35efc108..9c0f7033 100644 --- a/src/helpers/output.ts +++ b/src/helpers/output.ts @@ -12,7 +12,7 @@ * stdout is not a TTY AND not running in a CI environment. */ export function isAgentContext(): boolean { - return !process.stdout.isTTY && !process.env.CI; + return !process.stdout.isTTY && !Bun.env.CI; } /** diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 601614f0..22ec33c0 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -5,9 +5,9 @@ import { join, dirname } from "node:path"; import { logDebug } from "./log"; export function internalPath(...path: string[]) { - // Use process.env.HOME/USERPROFILE first (testable via env override), + // Use Bun.env.HOME/USERPROFILE first (testable via env override), // fall back to os.homedir() which handles platform-specific resolution. - const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); + const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? homedir(); const internalFolder = join(home, ".archgate"); return join(internalFolder, ...path); } diff --git a/src/helpers/platform.ts b/src/helpers/platform.ts index 437729b1..db2fa5d3 100644 --- a/src/helpers/platform.ts +++ b/src/helpers/platform.ts @@ -35,13 +35,13 @@ export function getPlatformInfo(): PlatformInfo { } // Check WSL2 env vars first (fastest) - const distroName = process.env.WSL_DISTRO_NAME; + const distroName = Bun.env.WSL_DISTRO_NAME; if (distroName) { cachedPlatformInfo = { runtime, isWSL: true, wslDistro: distroName }; return cachedPlatformInfo; } - if (process.env.WSL_INTEROP) { + if (Bun.env.WSL_INTEROP) { cachedPlatformInfo = { runtime, isWSL: true, wslDistro: null }; return cachedPlatformInfo; } diff --git a/src/helpers/sentry.ts b/src/helpers/sentry.ts new file mode 100644 index 00000000..54e90e2c --- /dev/null +++ b/src/helpers/sentry.ts @@ -0,0 +1,210 @@ +/** + * sentry.ts — Error tracking via @sentry/node-core light mode. + * + * Uses Sentry's lightweight "light" SDK variant which excludes all + * OpenTelemetry auto-instrumentation — ideal for a CLI that only needs + * error capture with breadcrumbs. This avoids pulling in 600+ modules + * of OTel instrumentation for MongoDB, Redis, Express, etc. + * + * IP anonymization: the Sentry project has "Prevent Storing of IP Addresses" + * enabled server-side. + * + * Sentry is only initialized when telemetry is enabled. All Sentry calls + * are wrapped to never affect CLI behavior or exit codes. + */ + +import { join } from "node:path"; + +import * as Sentry from "@sentry/node-core/light"; + +import packageJson from "../../package.json"; +import { logDebug } from "./log"; +import { internalPath } from "./paths"; +import { getPlatformInfo } from "./platform"; +import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/** + * Sentry DSN (write-only ingest URL, safe to embed in client code). + */ +const SENTRY_DSN = + "https://bb693c2cbc4238dbcd6efac609062402@o4511085517340672.ingest.de.sentry.io/4511085521469520"; + +// --------------------------------------------------------------------------- +// Install method detection +// --------------------------------------------------------------------------- + +/** + * The path to the archgate executable or script. + * - Compiled binary: process.execPath IS the archgate binary + * - bun run / bunx: Bun.main is the entry script (src/cli.ts or similar) + * - proto: process.argv[1] points to the shim + */ +function getArchgatePath(): string { + // When compiled, process.execPath is the archgate binary itself + // (not the bun runtime). Detect by checking if execPath contains "bun". + const execPath = process.execPath; + if (!execPath.includes("bun")) return execPath; + + // When running via `bun run`, Bun.main is the entry script + return Bun.main; +} + +function detectInstallMethod(): string { + const archgatePath = getArchgatePath(); + const binDir = internalPath("bin"); + + if (archgatePath.startsWith(binDir)) return "binary"; + + const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? "~"; + const protoHome = Bun.env.PROTO_HOME ?? join(home, ".proto"); + const protoToolDir = join(protoHome, "tools", "archgate"); + if (archgatePath.startsWith(protoToolDir)) return "proto"; + + if (archgatePath.includes("node_modules")) return "local"; + + return "package-manager"; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let initialized = false; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Initialize Sentry error tracking. Call once at CLI startup. + * No-op if telemetry is disabled. + */ +export function initSentry(): void { + if (!isTelemetryEnabled()) { + logDebug("Sentry disabled — telemetry is off"); + return; + } + + const cliVersion = packageJson.version; + const { runtime } = getPlatformInfo(); + + try { + Sentry.init({ + dsn: SENTRY_DSN, + release: cliVersion, + environment: Bun.env.NODE_ENV ?? "production", + // Do not send default PII (hostnames, IPs, etc.) + sendDefaultPii: false, + // Set the anonymous install ID as the user + initialScope: { + user: { id: getInstallId() }, + tags: { + cli_version: cliVersion, + os: runtime, + arch: process.arch, + is_ci: String(Boolean(Bun.env.CI)), + is_tty: String(Boolean(process.stdout.isTTY)), + install_method: detectInstallMethod(), + install_path: getArchgatePath(), + }, + contexts: { + // Override the default "node" runtime that @sentry/node-core sets + runtime: { name: "bun", version: Bun.version }, + }, + }, + // Override the default nodeContextIntegration which reports runtime as "node" + integrations: (defaults) => + defaults.filter((i) => i.name !== "NodeContext"), + // Limit breadcrumbs to keep payloads small + maxBreadcrumbs: 50, + }); + + initialized = true; + logDebug("Sentry initialized"); + } catch { + logDebug("Sentry init failed (silently ignored)"); + } +} + +/** + * Add a breadcrumb to the current Sentry scope. + * Breadcrumbs are attached to the next error event, providing context + * about the sequence of operations leading to a crash. + * + * @param category Short category name (e.g., "command", "config", "check") + * @param message Human-readable description + * @param data Optional structured data + * @param level Breadcrumb severity level (default: "info") + */ +export function addBreadcrumb( + category: string, + message: string, + data?: Record, + level: "debug" | "info" | "warning" | "error" = "info" +): void { + if (!initialized) return; + + try { + Sentry.addBreadcrumb({ + category, + message, + data, + level, + timestamp: Date.now() / 1000, + }); + } catch { + // Never let breadcrumb failures affect CLI behavior + } +} + +/** + * Capture an exception and send it to Sentry. + * + * @param error The error to capture + * @param context Optional extra context (command name, options, etc.) + */ +export function captureException( + error: unknown, + context?: Record +): void { + if (!initialized) return; + + try { + Sentry.captureException(error, { contexts: { cli: context } }); + } catch { + logDebug("Sentry capture failed (silently ignored)"); + } +} + +/** + * Flush pending Sentry events. Call before process exit to ensure + * error events are sent. + * + * @param timeoutMs Maximum time to wait for flush (default: 2000ms) + */ +export async function flushSentry(timeoutMs = 2000): Promise { + if (!initialized) return; + + try { + await Sentry.flush(timeoutMs); + logDebug("Sentry flushed"); + } catch { + logDebug("Sentry flush failed (silently ignored)"); + } +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** Reset Sentry state. For testing only. */ +export function _resetSentry(): void { + if (initialized) { + Sentry.close(); + } + initialized = false; +} diff --git a/src/helpers/telemetry-config.ts b/src/helpers/telemetry-config.ts new file mode 100644 index 00000000..ec08e46f --- /dev/null +++ b/src/helpers/telemetry-config.ts @@ -0,0 +1,155 @@ +/** + * telemetry-config.ts — Manages telemetry preferences in ~/.archgate/config.json. + * + * Telemetry is opt-out: enabled by default, users disable via: + * - ARCHGATE_TELEMETRY=0 environment variable + * - `archgate telemetry disable` command + * + * An anonymous installId (UUID v4) is generated on first use for aggregate + * counting — it is not derived from any user data. + */ + +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; + +import { logDebug } from "./log"; +import { internalPath, createPathIfNotExists } from "./paths"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TelemetryConfig { + /** Whether telemetry is enabled (default: true). */ + telemetry: boolean; + /** Random UUID generated on first use — not derived from any user data. */ + installId: string; + /** ISO date of first telemetry config creation. */ + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const CONFIG_FILE = "config.json"; + +function configPath(): string { + return internalPath(CONFIG_FILE); +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let cachedConfig: TelemetryConfig | null = null; + +// --------------------------------------------------------------------------- +// Environment variable override +// --------------------------------------------------------------------------- + +/** + * Returns true if the ARCHGATE_TELEMETRY env var explicitly disables telemetry. + * Accepted values: "0", "false", "no", "off" (case-insensitive). + */ +export function isEnvTelemetryDisabled(): boolean { + const envVal = Bun.env.ARCHGATE_TELEMETRY; + if (envVal === undefined) return false; + return ["0", "false", "no", "off"].includes(envVal.toLowerCase()); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Returns true if telemetry should be active for this invocation. + * Checks env var first (fastest), then persisted config. + */ +export function isTelemetryEnabled(): boolean { + if (isEnvTelemetryDisabled()) return false; + const config = loadTelemetryConfig(); + return config.telemetry; +} + +/** + * Load or create the telemetry config from ~/.archgate/config.json. + * The result is cached per process. + */ +export function loadTelemetryConfig(): TelemetryConfig { + if (cachedConfig) return cachedConfig; + + // Synchronous read for startup-path performance + try { + const path = configPath(); + if (existsSync(path)) { + const text = readFileSync(path, "utf-8"); + const parsed = JSON.parse(text) as Partial; + if (parsed.installId && typeof parsed.telemetry === "boolean") { + cachedConfig = { + telemetry: parsed.telemetry, + installId: parsed.installId, + createdAt: parsed.createdAt ?? new Date().toISOString(), + }; + logDebug("Telemetry config loaded:", cachedConfig.installId); + return cachedConfig; + } + } + } catch { + // File doesn't exist or is malformed — create a new one + } + + // First run: generate fresh config + cachedConfig = { + telemetry: true, + installId: randomUUID(), + createdAt: new Date().toISOString(), + }; + + // Persist asynchronously — don't block CLI startup + saveTelemetryConfigAsync(cachedConfig); + logDebug("Telemetry config created:", cachedConfig.installId); + return cachedConfig; +} + +/** + * Update telemetry enabled/disabled state and persist to disk. + */ +export async function setTelemetryEnabled(enabled: boolean): Promise { + const config = loadTelemetryConfig(); + config.telemetry = enabled; + cachedConfig = config; + await saveTelemetryConfig(config); +} + +/** + * Get the anonymous install ID for this CLI installation. + */ +export function getInstallId(): string { + return loadTelemetryConfig().installId; +} + +// --------------------------------------------------------------------------- +// Persistence (internal) +// --------------------------------------------------------------------------- + +async function saveTelemetryConfig(config: TelemetryConfig): Promise { + createPathIfNotExists(internalPath()); + await Bun.write(configPath(), JSON.stringify(config, null, 2) + "\n"); + logDebug("Telemetry config saved"); +} + +function saveTelemetryConfigAsync(config: TelemetryConfig): void { + saveTelemetryConfig(config).catch(() => { + // Silently ignore — telemetry config persistence is best-effort + }); +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** Reset cached config. For testing only. */ +export function _resetConfigCache(): void { + cachedConfig = null; +} diff --git a/src/helpers/telemetry.ts b/src/helpers/telemetry.ts new file mode 100644 index 00000000..a33e2f60 --- /dev/null +++ b/src/helpers/telemetry.ts @@ -0,0 +1,178 @@ +/** + * telemetry.ts — Anonymous usage analytics via PostHog Node SDK. + * + * Uses the official posthog-node SDK for event capture with automatic + * batching and flush. Events are captured during command execution and + * flushed before process exit. + * + * IP anonymization: the CLI sends `$ip: null` on every event to signal + * PostHog to resolve geo server-side then discard the IP. The project + * also has "Discard client IP data" enabled in PostHog settings. + * + * See https://cli.archgate.dev/reference/telemetry for the full privacy policy. + */ + +import { PostHog } from "posthog-node"; + +import packageJson from "../../package.json"; +import { logDebug } from "./log"; +import { getPlatformInfo } from "./platform"; +import { getInstallId, isTelemetryEnabled } from "./telemetry-config"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/** + * PostHog project API key (write-only, safe to embed in client code). + * This key can only ingest events — it cannot read data or manage the project. + */ +const POSTHOG_API_KEY = "phc_placeholder"; +const POSTHOG_HOST = "https://us.i.posthog.com"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let client: PostHog | null = null; +let initialized = false; +let distinctId = ""; + +// --------------------------------------------------------------------------- +// Shared properties (computed once per process) +// --------------------------------------------------------------------------- + +function getCommonProperties(): Record { + const { runtime } = getPlatformInfo(); + return { + cli_version: packageJson.version, + os: runtime, + arch: process.arch, + bun_version: Bun.version, + is_ci: Boolean(Bun.env.CI), + is_tty: Boolean(process.stdout.isTTY), + // Signal PostHog to resolve geo then discard the IP + $ip: null, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Initialize telemetry. Call once at CLI startup. + * If telemetry is disabled, this is a no-op and all subsequent calls are too. + */ +export function initTelemetry(): void { + if (!isTelemetryEnabled()) { + logDebug("Telemetry disabled — skipping init"); + return; + } + + distinctId = getInstallId(); + + try { + client = new PostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + // Disable polling for feature flags — we don't use them in the CLI + disableGeoip: false, + flushAt: 20, + flushInterval: 10_000, + }); + + initialized = true; + logDebug("Telemetry initialized:", distinctId); + } catch { + logDebug("Telemetry init failed (silently ignored)"); + } +} + +/** + * Track a named event with optional properties. + * No-op if telemetry is disabled. + */ +export function trackEvent( + event: string, + properties?: Record +): void { + if (!initialized || !client) return; + + try { + client.capture({ + distinctId, + event, + properties: { ...getCommonProperties(), ...properties }, + }); + logDebug("Telemetry event captured:", event); + } catch { + // Silently ignore — telemetry must never affect CLI behavior + } +} + +/** + * Track a CLI command invocation. + */ +export function trackCommand( + command: string, + options?: Record +): void { + trackEvent("command_executed", { command, ...options }); +} + +/** + * Track command completion with exit code and duration. + */ +export function trackCommandResult( + command: string, + exitCode: number, + durationMs: number +): void { + trackEvent("command_completed", { + command, + exit_code: exitCode, + duration_ms: durationMs, + }); +} + +/** + * Flush pending events to PostHog. Call before process exit to ensure + * events are delivered. + */ +export async function flushTelemetry(timeoutMs = 3000): Promise { + if (!initialized || !client) return; + + try { + logDebug("Flushing telemetry events"); + // Race shutdown against a timeout to prevent hanging on exit + await Promise.race([ + client.shutdown(), + new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }), + ]); + logDebug("Telemetry flushed"); + } catch { + // Silently ignore — telemetry must never affect CLI behavior + logDebug("Telemetry flush failed (silently ignored)"); + } +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** Reset telemetry state. For testing only. */ +export function _resetTelemetry(): void { + if (client) { + client.shutdown().catch(() => {}); + } + client = null; + initialized = false; + distinctId = ""; +} + +/** Get the PostHog client instance. For testing only. */ +export function _getClient(): PostHog | null { + return client; +} diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index c974b347..f130f9f7 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -71,8 +71,7 @@ export function mergeMarketplaceUrl( */ export async function getVscodeUserSettingsPath(): Promise { if (isWindows()) { - const appData = - process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); + const appData = Bun.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); return join(appData, "Code", "User", "settings.json"); } if (isMacOS()) { diff --git a/tests/helpers/sentry.test.ts b/tests/helpers/sentry.test.ts new file mode 100644 index 00000000..74730102 --- /dev/null +++ b/tests/helpers/sentry.test.ts @@ -0,0 +1,109 @@ +import { describe, test, beforeEach, afterEach, mock } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("sentry", () => { + let tempDir: string; + let originalHome: string | undefined; + let originalTelemetryEnv: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-sentry-test-")); + originalHome = process.env.HOME; + originalTelemetryEnv = process.env.ARCHGATE_TELEMETRY; + process.env.HOME = tempDir; + delete process.env.ARCHGATE_TELEMETRY; + }); + + afterEach(async () => { + process.env.HOME = originalHome; + if (originalTelemetryEnv === undefined) { + delete process.env.ARCHGATE_TELEMETRY; + } else { + process.env.ARCHGATE_TELEMETRY = originalTelemetryEnv; + } + rmSync(tempDir, { recursive: true, force: true }); + + const { _resetSentry } = await import("../../src/helpers/sentry"); + _resetSentry(); + const { _resetConfigCache } = + await import("../../src/helpers/telemetry-config"); + _resetConfigCache(); + mock.restore(); + }); + + describe("initSentry", () => { + test("initializes Sentry SDK when telemetry is enabled", async () => { + const { initSentry } = await import("../../src/helpers/sentry"); + + // Should not throw — Sentry.init is called internally + initSentry(); + }); + + test("does not initialize when telemetry is disabled", async () => { + process.env.ARCHGATE_TELEMETRY = "0"; + + const { initSentry, captureException } = + await import("../../src/helpers/sentry"); + + initSentry(); + // captureException should be a no-op + captureException(new Error("should not send")); + }); + }); + + describe("captureException", () => { + test("is a no-op when not initialized", async () => { + const { captureException } = await import("../../src/helpers/sentry"); + + // Should not throw + captureException(new Error("should not send")); + }); + + test("handles non-Error values without throwing", async () => { + const { initSentry, captureException } = + await import("../../src/helpers/sentry"); + + initSentry(); + // Should not throw + captureException("string error", { command: "init" }); + }); + }); + + describe("addBreadcrumb", () => { + test("is a no-op when not initialized", async () => { + const { addBreadcrumb } = await import("../../src/helpers/sentry"); + + // Should not throw + addBreadcrumb("test", "test breadcrumb"); + }); + + test("adds breadcrumb when initialized", async () => { + const { initSentry, addBreadcrumb } = + await import("../../src/helpers/sentry"); + + initSentry(); + // Should not throw + addBreadcrumb("command", "Running: check", { staged: true }); + }); + }); + + describe("flushSentry", () => { + test("is a no-op when not initialized", async () => { + const { flushSentry } = await import("../../src/helpers/sentry"); + + // Should not throw + await flushSentry(); + }); + + test("flushes when initialized", async () => { + const { initSentry, flushSentry } = + await import("../../src/helpers/sentry"); + + initSentry(); + // Should not throw + await flushSentry(100); + }); + }); +}); diff --git a/tests/helpers/telemetry-config.test.ts b/tests/helpers/telemetry-config.test.ts new file mode 100644 index 00000000..81b67075 --- /dev/null +++ b/tests/helpers/telemetry-config.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("telemetry-config", () => { + let tempDir: string; + let originalHome: string | undefined; + let originalTelemetryEnv: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-telemetry-test-")); + originalHome = process.env.HOME; + originalTelemetryEnv = process.env.ARCHGATE_TELEMETRY; + process.env.HOME = tempDir; + delete process.env.ARCHGATE_TELEMETRY; + }); + + afterEach(async () => { + process.env.HOME = originalHome; + if (originalTelemetryEnv === undefined) { + delete process.env.ARCHGATE_TELEMETRY; + } else { + process.env.ARCHGATE_TELEMETRY = originalTelemetryEnv; + } + rmSync(tempDir, { recursive: true, force: true }); + + // Reset cached config between tests + const { _resetConfigCache } = + await import("../../src/helpers/telemetry-config"); + _resetConfigCache(); + }); + + describe("loadTelemetryConfig", () => { + test("creates config with telemetry enabled on first run", async () => { + const { loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + const config = loadTelemetryConfig(); + expect(config.telemetry).toBe(true); + expect(config.installId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + expect(config.createdAt).toBeTruthy(); + }); + + test("returns cached config on subsequent calls", async () => { + const { loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + const first = loadTelemetryConfig(); + const second = loadTelemetryConfig(); + expect(first).toBe(second); // Same reference (cached) + }); + + test("reads existing config from disk", async () => { + const { mkdirSync } = await import("node:fs"); + const configDir = join(tempDir, ".archgate"); + mkdirSync(configDir, { recursive: true }); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + telemetry: false, + installId: "test-uuid-1234", + createdAt: "2026-01-01T00:00:00.000Z", + }) + ); + + const { loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + const config = loadTelemetryConfig(); + expect(config.telemetry).toBe(false); + expect(config.installId).toBe("test-uuid-1234"); + }); + + test("creates new config when file is malformed", async () => { + const { mkdirSync } = await import("node:fs"); + const configDir = join(tempDir, ".archgate"); + mkdirSync(configDir, { recursive: true }); + await Bun.write(join(configDir, "config.json"), "not-json"); + + const { loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + const config = loadTelemetryConfig(); + expect(config.telemetry).toBe(true); + expect(config.installId).toBeTruthy(); + }); + }); + + describe("isEnvTelemetryDisabled", () => { + test("returns false when env var is not set", async () => { + const { isEnvTelemetryDisabled } = + await import("../../src/helpers/telemetry-config"); + expect(isEnvTelemetryDisabled()).toBe(false); + }); + + test.each(["0", "false", "no", "off", "FALSE", "No", "OFF"])( + "returns true for %s", + async (value) => { + process.env.ARCHGATE_TELEMETRY = value; + const { isEnvTelemetryDisabled } = + await import("../../src/helpers/telemetry-config"); + expect(isEnvTelemetryDisabled()).toBe(true); + } + ); + + test("returns false for other values like '1' or 'true'", async () => { + process.env.ARCHGATE_TELEMETRY = "1"; + const { isEnvTelemetryDisabled } = + await import("../../src/helpers/telemetry-config"); + expect(isEnvTelemetryDisabled()).toBe(false); + }); + }); + + describe("isTelemetryEnabled", () => { + test("returns true by default", async () => { + const { isTelemetryEnabled } = + await import("../../src/helpers/telemetry-config"); + expect(isTelemetryEnabled()).toBe(true); + }); + + test("returns false when env var disables it", async () => { + process.env.ARCHGATE_TELEMETRY = "0"; + const { isTelemetryEnabled } = + await import("../../src/helpers/telemetry-config"); + expect(isTelemetryEnabled()).toBe(false); + }); + + test("returns false when config disables it", async () => { + const { mkdirSync } = await import("node:fs"); + const configDir = join(tempDir, ".archgate"); + mkdirSync(configDir, { recursive: true }); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + telemetry: false, + installId: "test-uuid", + createdAt: "2026-01-01", + }) + ); + + const { isTelemetryEnabled } = + await import("../../src/helpers/telemetry-config"); + expect(isTelemetryEnabled()).toBe(false); + }); + }); + + describe("setTelemetryEnabled", () => { + test("persists disabled state to disk", async () => { + const { setTelemetryEnabled, loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + // Initialize first + loadTelemetryConfig(); + + // Wait for async first-run save + await Bun.sleep(100); + + await setTelemetryEnabled(false); + + // Read from disk to verify persistence + const configPath = join(tempDir, ".archgate", "config.json"); + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw); + expect(parsed.telemetry).toBe(false); + }); + + test("persists enabled state to disk", async () => { + const { setTelemetryEnabled, loadTelemetryConfig } = + await import("../../src/helpers/telemetry-config"); + + loadTelemetryConfig(); + await Bun.sleep(100); + + await setTelemetryEnabled(false); + await setTelemetryEnabled(true); + + const configPath = join(tempDir, ".archgate", "config.json"); + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw); + expect(parsed.telemetry).toBe(true); + }); + }); + + describe("getInstallId", () => { + test("returns the same ID across calls", async () => { + const { getInstallId } = + await import("../../src/helpers/telemetry-config"); + + const id1 = getInstallId(); + const id2 = getInstallId(); + expect(id1).toBe(id2); + }); + }); +}); diff --git a/tests/helpers/telemetry.test.ts b/tests/helpers/telemetry.test.ts new file mode 100644 index 00000000..8bced011 --- /dev/null +++ b/tests/helpers/telemetry.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("telemetry", () => { + let tempDir: string; + let originalHome: string | undefined; + let originalTelemetryEnv: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-telemetry-test-")); + originalHome = process.env.HOME; + originalTelemetryEnv = process.env.ARCHGATE_TELEMETRY; + process.env.HOME = tempDir; + delete process.env.ARCHGATE_TELEMETRY; + }); + + afterEach(async () => { + process.env.HOME = originalHome; + if (originalTelemetryEnv === undefined) { + delete process.env.ARCHGATE_TELEMETRY; + } else { + process.env.ARCHGATE_TELEMETRY = originalTelemetryEnv; + } + rmSync(tempDir, { recursive: true, force: true }); + + const { _resetTelemetry } = await import("../../src/helpers/telemetry"); + _resetTelemetry(); + const { _resetConfigCache } = + await import("../../src/helpers/telemetry-config"); + _resetConfigCache(); + }); + + describe("initTelemetry", () => { + test("initializes PostHog client when telemetry is enabled", async () => { + const { initTelemetry, _getClient } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + expect(_getClient()).not.toBeNull(); + }); + + test("skips init when telemetry is disabled via env", async () => { + process.env.ARCHGATE_TELEMETRY = "0"; + + const { initTelemetry, _getClient } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + expect(_getClient()).toBeNull(); + }); + }); + + describe("trackEvent", () => { + test("captures event via PostHog client without throwing", async () => { + const { initTelemetry, trackEvent } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + // Should not throw — events are queued internally by the SDK + trackEvent("command_executed", { command: "check" }); + }); + + test("is a no-op when not initialized", async () => { + const { trackEvent } = await import("../../src/helpers/telemetry"); + + // Should not throw + trackEvent("should_not_capture"); + }); + }); + + describe("trackCommand", () => { + test("captures a command_executed event without throwing", async () => { + const { initTelemetry, trackCommand } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + // Should not throw + trackCommand("adr create", { json: true }); + }); + }); + + describe("flushTelemetry", () => { + test("flushes without throwing when initialized", async () => { + const { initTelemetry, flushTelemetry } = + await import("../../src/helpers/telemetry"); + + initTelemetry(); + + // Flush with no pending events — should resolve quickly + await flushTelemetry(); + }); + + test("is a no-op when not initialized", async () => { + const { flushTelemetry } = await import("../../src/helpers/telemetry"); + + // Should not throw + await flushTelemetry(); + }); + }); +}); diff --git a/tests/integration/cli-harness.ts b/tests/integration/cli-harness.ts index 3c9236af..6f7e61b7 100644 --- a/tests/integration/cli-harness.ts +++ b/tests/integration/cli-harness.ts @@ -29,7 +29,13 @@ export async function runCli( cwd, stdout: "pipe", stderr: "pipe", - env: { ...process.env, NO_COLOR: "1", CI: "1", ...env }, + env: { + ...process.env, + NO_COLOR: "1", + CI: "1", + ARCHGATE_TELEMETRY: "0", + ...env, + }, }); const [stdout, stderr, exitCode] = await Promise.all([