diff --git a/CHANGELOG.md b/CHANGELOG.md index 419ca07..e8d5d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,24 +2,24 @@ ### Bug Fixes -* add @commitlint/cli to root devDependencies ([#118](https://github.com/theholocron/cli-template/issues/118)) ([8a27e8a](https://github.com/theholocron/cli-template/commit/8a27e8aadee163f6dfedbcebf2501dcbf9313fab)), closes [#117](https://github.com/theholocron/cli-template/issues/117) +- add @commitlint/cli to root devDependencies ([#118](https://github.com/theholocron/cli-template/issues/118)) ([8a27e8a](https://github.com/theholocron/cli-template/commit/8a27e8aadee163f6dfedbcebf2501dcbf9313fab)), closes [#117](https://github.com/theholocron/cli-template/issues/117) ### Chores -* 🔧 bump @theholocron/cli and plugin-github to 3.16.1 ([#113](https://github.com/theholocron/cli-template/issues/113)) ([330c7d5](https://github.com/theholocron/cli-template/commit/330c7d56f35fc23fdec60960de2935f0b450ac94)) -* 🔧 bump @theholocron/cli and plugin-github to 3.17.0 ([#115](https://github.com/theholocron/cli-template/issues/115)) ([f89bde1](https://github.com/theholocron/cli-template/commit/f89bde1545f36c1ecd158da681a79a876912d897)) -* 🔧 update to latest standards and bump all deps ([#111](https://github.com/theholocron/cli-template/issues/111)) ([8376da5](https://github.com/theholocron/cli-template/commit/8376da5c2768f0d853bb7a03e8cedddcdd1b8ea3)), closes [theholocron/configs#347](https://github.com/theholocron/configs/issues/347) [theholocron/holocron#327](https://github.com/theholocron/holocron/issues/327) -* bump @eslint/compat from 1.3.1 to 1.3.2 ([#76](https://github.com/theholocron/cli-template/issues/76)) ([2973d85](https://github.com/theholocron/cli-template/commit/2973d85b6000b461a60a090696cf44973ad939fc)) -* bump @inquirer/prompts from 7.10.1 to 8.5.2 ([#112](https://github.com/theholocron/cli-template/issues/112)) ([b7f14d1](https://github.com/theholocron/cli-template/commit/b7f14d127b217b85c854c191937d1b9b650aba4b)) -* bump @inquirer/prompts from 7.8.0 to 7.8.1 ([#74](https://github.com/theholocron/cli-template/issues/74)) ([cdb1169](https://github.com/theholocron/cli-template/commit/cdb116944cc393ae23bba0e404a77a266f78a3ba)) -* bump @inquirer/prompts from 7.8.1 to 7.8.3 ([#79](https://github.com/theholocron/cli-template/issues/79)) ([ddbe93e](https://github.com/theholocron/cli-template/commit/ddbe93e67ec3d879fdea243aa887a057f7409449)) -* bump @inquirer/prompts from 7.8.3 to 7.8.6 ([#95](https://github.com/theholocron/cli-template/issues/95)) ([51bd518](https://github.com/theholocron/cli-template/commit/51bd518dc2d1b85d69942f70983bd9cc3362072f)) -* bump @types/inquirer from 9.0.8 to 9.0.9 ([#73](https://github.com/theholocron/cli-template/issues/73)) ([41746ff](https://github.com/theholocron/cli-template/commit/41746ff7512753645b8071b542c082cc5c83b50b)) -* bump dotenv from 17.2.0 to 17.2.1 ([#62](https://github.com/theholocron/cli-template/issues/62)) ([cc51400](https://github.com/theholocron/cli-template/commit/cc51400eb13381a35506f0ce3fcd906552f8a987)) -* bump lint-staged from 16.1.2 to 16.1.5 ([#71](https://github.com/theholocron/cli-template/issues/71)) ([d86264a](https://github.com/theholocron/cli-template/commit/d86264a942062c7a8c53cd86dc599fc9634ec6ce)) -* bump tsx from 4.20.3 to 4.20.4 ([#78](https://github.com/theholocron/cli-template/issues/78)) ([0b7ced0](https://github.com/theholocron/cli-template/commit/0b7ced0e3d4775c952b10b9246df11000b9aa37c)) -* fix linting issues ([#61](https://github.com/theholocron/cli-template/issues/61)) ([470dadc](https://github.com/theholocron/cli-template/commit/470dadc14b8c924f732cde272620d5469b7a8abf)) -* upgrade dependencies ([a9c69a4](https://github.com/theholocron/cli-template/commit/a9c69a4f0a448889dcfc44331a7d501d3d995f0b)) +- 🔧 bump @theholocron/cli and plugin-github to 3.16.1 ([#113](https://github.com/theholocron/cli-template/issues/113)) ([330c7d5](https://github.com/theholocron/cli-template/commit/330c7d56f35fc23fdec60960de2935f0b450ac94)) +- 🔧 bump @theholocron/cli and plugin-github to 3.17.0 ([#115](https://github.com/theholocron/cli-template/issues/115)) ([f89bde1](https://github.com/theholocron/cli-template/commit/f89bde1545f36c1ecd158da681a79a876912d897)) +- 🔧 update to latest standards and bump all deps ([#111](https://github.com/theholocron/cli-template/issues/111)) ([8376da5](https://github.com/theholocron/cli-template/commit/8376da5c2768f0d853bb7a03e8cedddcdd1b8ea3)), closes [theholocron/configs#347](https://github.com/theholocron/configs/issues/347) [theholocron/holocron#327](https://github.com/theholocron/holocron/issues/327) +- bump @eslint/compat from 1.3.1 to 1.3.2 ([#76](https://github.com/theholocron/cli-template/issues/76)) ([2973d85](https://github.com/theholocron/cli-template/commit/2973d85b6000b461a60a090696cf44973ad939fc)) +- bump @inquirer/prompts from 7.10.1 to 8.5.2 ([#112](https://github.com/theholocron/cli-template/issues/112)) ([b7f14d1](https://github.com/theholocron/cli-template/commit/b7f14d127b217b85c854c191937d1b9b650aba4b)) +- bump @inquirer/prompts from 7.8.0 to 7.8.1 ([#74](https://github.com/theholocron/cli-template/issues/74)) ([cdb1169](https://github.com/theholocron/cli-template/commit/cdb116944cc393ae23bba0e404a77a266f78a3ba)) +- bump @inquirer/prompts from 7.8.1 to 7.8.3 ([#79](https://github.com/theholocron/cli-template/issues/79)) ([ddbe93e](https://github.com/theholocron/cli-template/commit/ddbe93e67ec3d879fdea243aa887a057f7409449)) +- bump @inquirer/prompts from 7.8.3 to 7.8.6 ([#95](https://github.com/theholocron/cli-template/issues/95)) ([51bd518](https://github.com/theholocron/cli-template/commit/51bd518dc2d1b85d69942f70983bd9cc3362072f)) +- bump @types/inquirer from 9.0.8 to 9.0.9 ([#73](https://github.com/theholocron/cli-template/issues/73)) ([41746ff](https://github.com/theholocron/cli-template/commit/41746ff7512753645b8071b542c082cc5c83b50b)) +- bump dotenv from 17.2.0 to 17.2.1 ([#62](https://github.com/theholocron/cli-template/issues/62)) ([cc51400](https://github.com/theholocron/cli-template/commit/cc51400eb13381a35506f0ce3fcd906552f8a987)) +- bump lint-staged from 16.1.2 to 16.1.5 ([#71](https://github.com/theholocron/cli-template/issues/71)) ([d86264a](https://github.com/theholocron/cli-template/commit/d86264a942062c7a8c53cd86dc599fc9634ec6ce)) +- bump tsx from 4.20.3 to 4.20.4 ([#78](https://github.com/theholocron/cli-template/issues/78)) ([0b7ced0](https://github.com/theholocron/cli-template/commit/0b7ced0e3d4775c952b10b9246df11000b9aa37c)) +- fix linting issues ([#61](https://github.com/theholocron/cli-template/issues/61)) ([470dadc](https://github.com/theholocron/cli-template/commit/470dadc14b8c924f732cde272620d5469b7a8abf)) +- upgrade dependencies ([a9c69a4](https://github.com/theholocron/cli-template/commit/a9c69a4f0a448889dcfc44331a7d501d3d995f0b)) # Changelog diff --git a/README.md b/README.md index fe3c853..5d3c5eb 100755 --- a/README.md +++ b/README.md @@ -54,12 +54,14 @@ cli-template log --verbose ## Environment Variables -Copy `.env.example` to `.env` and configure as needed. The `CLI_TEMPLATE` prefix is this project's namespace — replace it with your own (e.g. `HOLOCRON`, `RANDO`) when building on this template so each CLI's env vars stay isolated. +Copy `.env.example` to `.env` and configure as needed. Variables follow a two-level namespace cascade: `HOLOCRON_*` sets org-wide defaults, `CLI_TEMPLATE_*` overrides them per-tool. Replace both prefixes with your own when building on this template. -| Variable | Default | Description | -| ---------------------- | ------- | ---------------------- | -| `CLI_TEMPLATE_DEBUG` | `false` | Enable debug output | -| `CLI_TEMPLATE_VERBOSE` | `false` | Enable verbose logging | +| Variable | Default | Description | +| --------------------------- | ------- | ---------------------------------------------- | +| `CLI_TEMPLATE_DEBUG` | `false` | Enable debug output | +| `CLI_TEMPLATE_VERBOSE` | `false` | Enable verbose logging | +| `CLI_TEMPLATE_SENTRY_DSN` | — | Sentry DSN — enables error telemetry when set | +| `CLI_TEMPLATE_NO_TELEMETRY` | — | Set to any value to opt out of error telemetry | ## Development @@ -74,17 +76,18 @@ pnpm lint # run super-linter locally (requires Docker) ## What's Included -| Category | Tool | Purpose | -| ----------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| **CLI framework** | [Yargs](https://yargs.js.org/) | Command routing, option parsing, env-var binding, auto-completion | -| **Prompts** | [Inquirer](https://github.com/SBoudrias/Inquirer.js) | Interactive select, confirm, and search prompts | -| **Config** | [Conf](https://github.com/sindresorhus/conf) | Persistent user preferences with JSON-schema validation | -| **Logging** | [Winston](https://github.com/winstonjs/winston) | Structured file logging; terminal output via style utilities | -| **Terminal UI** | [Chalk](https://github.com/chalk/chalk) + [Ora](https://github.com/sindresorhus/ora) | Colour output and spinners for long-running tasks | -| **Environment** | [@theholocron/env-utils](https://github.com/theholocron/utils) | Namespace-scoped env var parsing with cascade priority | -| **Updates** | [update-notifier](https://github.com/yeoman/update-notifier) | Prompts users to upgrade when a new version is published | -| **Build** | [tsdown](https://tsdown.dev/) | Compiles `src/cli.ts` → `dist/cli.mjs` with a Node.js shebang | -| **CI/CD** | GitHub Actions + semantic-release | Automated lint, test, typecheck, and publish on push to `main` | +| Category | Tool | Purpose | +| ----------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| **CLI framework** | [Yargs](https://yargs.js.org/) | Command routing, option parsing, env-var binding, auto-completion | +| **Prompts** | [Inquirer](https://github.com/SBoudrias/Inquirer.js) | Interactive select, confirm, and search prompts | +| **Config** | [Conf](https://github.com/sindresorhus/conf) | Persistent user preferences with JSON-schema validation | +| **Logging** | [Winston](https://github.com/winstonjs/winston) | Structured file logging; terminal output via style utilities | +| **Terminal UI** | [Chalk](https://github.com/chalk/chalk) + [Ora](https://github.com/sindresorhus/ora) | Colour output and spinners for long-running tasks | +| **Environment** | [@theholocron/env-utils](https://github.com/theholocron/utils) | Namespace-scoped env var parsing with cascade priority | +| **Updates** | [update-notifier](https://github.com/yeoman/update-notifier) | Prompts users to upgrade when a new version is published | +| **Telemetry** | [Sentry](https://sentry.io) | Error tracking and command-level tracing; disabled until DSN is set | +| **Build** | [tsdown](https://tsdown.dev/) | Compiles `src/cli.ts` → `dist/cli.mjs` with a Node.js shebang | +| **CI/CD** | GitHub Actions + semantic-release | Automated lint, test, typecheck, and publish on push to `main` | ## Releases diff --git a/docs/content/index.md b/docs/content/index.md index 3d2b37a..0b23350 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -8,12 +8,13 @@ A modern CLI template with pre-configured tools, best practices, and CI/CD setup ## Features - **[Yargs](https://yargs.js.org/)** — command routing, option parsing, auto-completion, and env-var binding -- **[Inquirer](https://github.com/SBoudrias/Inquirer.js)** — interactive prompts (select, confirm, autocomplete, search) +- **[Inquirer](https://github.com/SBoudrias/Inquirer.js)** — interactive prompts (select, confirm, search) - **[Conf](https://github.com/sindresorhus/conf)** — persistent user preferences with JSON-schema validation - **[Winston](https://github.com/winstonjs/winston)** — structured file logging (error, warn, info, verbose, debug) - **[Chalk](https://github.com/chalk/chalk)** — terminal colour output - **[Ora](https://github.com/sindresorhus/ora)** — spinner for long-running tasks -- **[dotenv](https://github.com/motdotla/dotenv)** — `.env` file support with a configurable namespace prefix +- **[@theholocron/env-utils](https://github.com/theholocron/utils)** — namespace-scoped env var parsing with `HOLOCRON_*` → `CLI_TEMPLATE_*` cascade +- **[Sentry](https://sentry.io)** — error tracking and command-level tracing; opt-in via `CLI_TEMPLATE_SENTRY_DSN` - **[update-notifier](https://github.com/yeoman/update-notifier)** — nudges users to upgrade when a new version is published ## Installation @@ -39,13 +40,14 @@ CLI_TEMPLATE_DEBUG=true cli-template log ## Environment Variables -Copy `.env.example` to `.env`. The `CLI_TEMPLATE` prefix is the project namespace — replace it consistently when building on this template. +Copy `.env.example` to `.env`. Variables follow a two-level namespace cascade: `HOLOCRON_*` sets org-wide defaults, `CLI_TEMPLATE_*` overrides them per-tool. Replace both prefixes consistently when building on this template. -| Variable | Default | Description | -| ---------------------- | ------- | ---------------------- | -| `CLI_TEMPLATE_DEBUG` | `false` | Enable debug output | -| `CLI_TEMPLATE_SOUND` | `false` | Enable sound effects | -| `CLI_TEMPLATE_VERBOSE` | `false` | Enable verbose logging | +| Variable | Default | Description | +| --------------------------- | ------- | ---------------------------------------------------------------------------- | +| `CLI_TEMPLATE_DEBUG` | `false` | Enable debug output | +| `CLI_TEMPLATE_VERBOSE` | `false` | Enable verbose logging | +| `CLI_TEMPLATE_SENTRY_DSN` | — | Sentry DSN — enables error telemetry when set (see [Telemetry](./telemetry)) | +| `CLI_TEMPLATE_NO_TELEMETRY` | — | Set to any value to opt out of error telemetry | ## Project Structure @@ -53,15 +55,16 @@ Copy `.env.example` to `.env`. The `CLI_TEMPLATE` prefix is the project namespac src/ ├── cli.ts # yargs entry point and global options ├── const.ts # shared path/OS constants +├── errors.ts # CLIError base class +├── telemetry.ts # Sentry init, command spans, and token scrubbing ├── commands/ # one file per sub-command │ ├── conf.ts # persistent config management │ └── log.ts # example logging command ├── ui/ -│ ├── prompts/ # select, confirm, autocomplete, search -│ └── open/ # open URLs or files in the default app +│ └── prompts/ # select, confirm, search └── utils/ ├── config/ # conf wrapper and preferences schema - ├── env/ # dotenv reader/writer + ├── env/ # env-utils parser and .env writer ├── log/ # winston logger + chalk helpers └── string.ts # string utilities ``` diff --git a/docs/content/telemetry.md b/docs/content/telemetry.md new file mode 100644 index 0000000..8a4ba4d --- /dev/null +++ b/docs/content/telemetry.md @@ -0,0 +1,39 @@ +--- +title: Telemetry +description: How to enable Sentry error tracking and command-level tracing in your CLI. +--- + +Error telemetry is powered by [Sentry](https://sentry.io) and is **disabled by default**. Nothing is sent until you set a DSN. + +## What is collected + +When enabled, each CLI invocation reports: + +- Unhandled rejections and caught exceptions +- A span per command (name, success/failure status) +- Runtime tags: OS platform, Node.js version, whether running in CI + +Tokens and secrets are scrubbed from all payloads before transmission — any value matching common token shapes (`ghp_`, `ghs_`, `SCREAMING_SNAKE_TOKEN=`, etc.) is replaced with `[REDACTED]`. + +## Enabling telemetry + +1. Create a [Sentry project](https://sentry.io/getting-started/) and select **Node.js** as the platform. +2. Copy the DSN from **Settings → Client Keys (DSN)**. +3. Set it in your environment or `.env` file: + +```bash +# .env +CLI_TEMPLATE_SENTRY_DSN=https://@.ingest.sentry.io/ +``` + +The `HOLOCRON_SENTRY_DSN` var works as an org-wide default if you run multiple CLIs built on this template — the per-tool `CLI_TEMPLATE_SENTRY_DSN` takes precedence when both are set. + +## Opting out + +Set `CLI_TEMPLATE_NO_TELEMETRY` to any value to disable telemetry at runtime, regardless of whether a DSN is configured: + +```bash +CLI_TEMPLATE_NO_TELEMETRY=1 cli-template log +``` + +Or add it to `.env` to opt out persistently. diff --git a/holocron.config.ts b/holocron.config.ts index 2a85f8e..67e621f 100644 --- a/holocron.config.ts +++ b/holocron.config.ts @@ -40,8 +40,7 @@ export default defineConfig({ skills: ["git-safety", "pr-workflow", "commit-standards", "security-review"], env: { // Replace CLI_TEMPLATE with your project's namespace throughout. - // Add org-wide prefixes before it so they act as global defaults: - // namespaces: ["HOLOCRON", "MY_CLI"] - namespaces: ["CLI_TEMPLATE"], + // HOLOCRON_* vars act as org-wide defaults; CLI_TEMPLATE_* overrides them. + namespaces: ["HOLOCRON", "CLI_TEMPLATE"], }, } satisfies HolocronConfig); diff --git a/package.json b/package.json index 86b25e2..cb28158 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.5.2", + "@sentry/node": "^10.69.0", "@theholocron/env-utils": "^1.2.1", "chalk": "^5.6.2", "conf": "^13.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48c11e7..2d5b924 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@inquirer/prompts': specifier: ^8.5.2 version: 8.5.2(@types/node@26.1.2) + '@sentry/node': + specifier: ^10.69.0 + version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) '@theholocron/env-utils': specifier: ^1.2.1 version: 1.2.1(dotenv@17.4.2) diff --git a/src/cli.ts b/src/cli.ts index d03a092..a4d8549 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { hideBin } from "yargs/helpers"; import { __cmddir } from "@/const"; import { CLIError } from "@/errors"; +import { captureException, endSession, flush, init, startCommand } from "@/telemetry"; import * as utils from "@/utils"; import pkg from "../package.json" with { type: "json" }; @@ -21,39 +22,55 @@ export interface CLIOptions { } process.on("unhandledRejection", (err) => { + captureException(err); utils.log.error("cli", err instanceof CLIError ? err.message : String(err)); process.exitCode = 1; }); -// eslint-disable-next-line @typescript-eslint/no-unused-expressions -yargs(hideBin(process.argv)) - .scriptName("cli-template") - .usage("Usage: $0 [options]") - .commandDir(__cmddir("./commands"), { extensions: ["js", "ts"] }) - .demandCommand() - .env(ENV_PREFIX) - .completion() - .recommendCommands() - .options({ - d: { - alias: ["debug"], - default: Boolean(utils.config.get("preferences.debug")) || Boolean(parser.get("debug")) || false, - describe: "Turn on debugging mode", - type: "boolean", - global: true, - hidden: true, - }, - verbose: { - default: Boolean(parser.get("verbose")) || false, - describe: "Turn on verbose logging", - type: "boolean", - global: true, - }, - }) - .alias({ h: "help", v: "version" }) - .strict() - .help("h") - .version() - .epilogue(`© 2024-${new Date().getFullYear()} The Holocron, Inc. All rights reserved.`).argv as CLIOptions; +init(pkg.version); +let finishCommand: (ok: boolean) => void = () => {}; + +try { + (await yargs(hideBin(process.argv)) + .middleware((argv) => { + const name = (argv._ as string[]).slice(0, 2).join(" ") || "unknown"; + finishCommand = startCommand(name); + }) + .scriptName("cli-template") + .usage("Usage: $0 [options]") + .commandDir(__cmddir("./commands"), { extensions: ["js", "ts"] }) + .demandCommand() + .env(ENV_PREFIX) + .completion() + .recommendCommands() + .options({ + d: { + alias: ["debug"], + default: Boolean(utils.config.get("preferences.debug")) || Boolean(parser.get("debug")) || false, + describe: "Turn on debugging mode", + type: "boolean", + global: true, + hidden: true, + }, + verbose: { + default: Boolean(parser.get("verbose")) || false, + describe: "Turn on verbose logging", + type: "boolean", + global: true, + }, + }) + .alias({ h: "help", v: "version" }) + .strict() + .help("h") + .version() + .epilogue(`© 2024-${new Date().getFullYear()} The Holocron, Inc. All rights reserved.`).argv) as CLIOptions; +} catch (err) { + captureException(err); + process.exitCode = 1; +} finally { + finishCommand(!process.exitCode); + endSession(); + await flush(); +} updateNotifier({ pkg }).notify(); diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts new file mode 100644 index 0000000..2b4d914 --- /dev/null +++ b/src/telemetry.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { captureException, endSession, flush, init, startCommand } from "./telemetry.js"; + +vi.mock("@sentry/node", () => ({ + init: vi.fn(), + setTag: vi.fn(), + startSession: vi.fn(), + endSession: vi.fn(), + startInactiveSpan: vi.fn(() => ({ setStatus: vi.fn(), end: vi.fn() })), + captureException: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), +})); + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock("@/utils/env", () => ({ + env: { parser: { get: mockGet } }, +})); + +import * as Sentry from "@sentry/node"; + +type MockSpan = { setStatus: ReturnType; end: ReturnType }; + +function lastSpan(): MockSpan { + const results = vi.mocked(Sentry.startInactiveSpan).mock.results; + return results[results.length - 1]?.value as MockSpan; +} + +const originalEnv = process.env; + +beforeEach(() => { + process.env = { ...originalEnv, CI: undefined }; + // Default: enabled (DSN set, no opt-out) + mockGet.mockImplementation((key: string) => (key === "sentry_dsn" ? "https://test@sentry.io/1" : undefined)); + vi.clearAllMocks(); + // Re-apply default after clearAllMocks + mockGet.mockImplementation((key: string) => (key === "sentry_dsn" ? "https://test@sentry.io/1" : undefined)); +}); + +afterEach(() => { + process.env = originalEnv; +}); + +// ── opt-out ─────────────────────────────────────────────────────────────────── + +describe("when CLI_TEMPLATE_NO_TELEMETRY is set", () => { + beforeEach(() => { + mockGet.mockImplementation((key: string) => { + if (key === "no_telemetry") return "1"; + if (key === "sentry_dsn") return "https://test@sentry.io/1"; + return undefined; + }); + }); + + it("init: skips Sentry.init", () => { + init("1.0.0"); + expect(Sentry.init).not.toHaveBeenCalled(); + }); + + it("startCommand: returns a no-op and skips span creation", () => { + const finish = startCommand("log"); + expect(Sentry.startInactiveSpan).not.toHaveBeenCalled(); + expect(() => finish(true)).not.toThrow(); + }); + + it("captureException: skips Sentry.captureException", () => { + captureException(new Error("boom")); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it("endSession: skips Sentry.endSession", () => { + endSession(); + expect(Sentry.endSession).not.toHaveBeenCalled(); + }); + + it("flush: skips Sentry.close", async () => { + await flush(); + expect(Sentry.close).not.toHaveBeenCalled(); + }); +}); + +describe("when CLI_TEMPLATE_SENTRY_DSN is not set", () => { + beforeEach(() => { + mockGet.mockReturnValue(undefined); + }); + + it("init: skips Sentry.init", () => { + init("1.0.0"); + expect(Sentry.init).not.toHaveBeenCalled(); + }); +}); + +// ── init ────────────────────────────────────────────────────────────────────── + +describe("init", () => { + it("calls Sentry.init with release and tracesSampleRate", () => { + init("1.2.3"); + expect(Sentry.init).toHaveBeenCalledWith( + expect.objectContaining({ release: "cli-template@1.2.3", tracesSampleRate: 1.0 }) + ); + }); + + it("sets environment to 'ci' when CI=true", () => { + process.env["CI"] = "true"; + init("1.0.0"); + expect(Sentry.init).toHaveBeenCalledWith(expect.objectContaining({ environment: "ci" })); + }); + + it("sets environment to 'local' when CI is unset", () => { + delete process.env["CI"]; + init("1.0.0"); + expect(Sentry.init).toHaveBeenCalledWith(expect.objectContaining({ environment: "local" })); + }); + + it("sets os, node, and ci tags", () => { + delete process.env["CI"]; + init("1.0.0"); + expect(Sentry.setTag).toHaveBeenCalledWith("os", process.platform); + expect(Sentry.setTag).toHaveBeenCalledWith("node", process.version); + expect(Sentry.setTag).toHaveBeenCalledWith("ci", "false"); + }); + + it("calls Sentry.startSession after init", () => { + init("1.0.0"); + expect(Sentry.startSession).toHaveBeenCalled(); + }); +}); + +// ── startCommand ────────────────────────────────────────────────────────────── + +describe("startCommand", () => { + it("starts a span with command name and op", () => { + startCommand("log"); + expect(Sentry.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ name: "log", op: "cli-template.command", forceTransaction: true }) + ); + }); + + it("sets the command tag", () => { + startCommand("conf add"); + expect(Sentry.setTag).toHaveBeenCalledWith("command", "conf add"); + }); + + it("finish(true) sets ok status and ends span", () => { + const finish = startCommand("log"); + finish(true); + expect(lastSpan().setStatus).toHaveBeenCalledWith({ code: 1 }); + expect(lastSpan().end).toHaveBeenCalled(); + }); + + it("finish(false) sets error status and ends span", () => { + const finish = startCommand("log"); + finish(false); + expect(lastSpan().setStatus).toHaveBeenCalledWith({ code: 2 }); + expect(lastSpan().end).toHaveBeenCalled(); + }); +}); + +// ── captureException ────────────────────────────────────────────────────────── + +describe("captureException", () => { + it("forwards the error to Sentry", () => { + const err = new Error("something broke"); + captureException(err); + expect(Sentry.captureException).toHaveBeenCalledWith(err); + }); +}); + +// ── endSession ──────────────────────────────────────────────────────────────── + +describe("endSession", () => { + it("calls Sentry.endSession", () => { + endSession(); + expect(Sentry.endSession).toHaveBeenCalled(); + }); +}); + +// ── flush ───────────────────────────────────────────────────────────────────── + +describe("flush", () => { + it("calls Sentry.close with a 2000ms timeout", async () => { + await flush(); + expect(Sentry.close).toHaveBeenCalledWith(2_000); + }); +}); + +// ── scrubError (via beforeSend) ─────────────────────────────────────────────── + +describe("scrubError", () => { + function getBeforeSend() { + init("1.0.0"); + const options = vi.mocked(Sentry.init).mock.calls[0]?.[0] as { + beforeSend: (event: object, hint: object) => object; + }; + return options.beforeSend; + } + + it("redacts ghp_ tokens", () => { + const scrub = getBeforeSend(); + const result = scrub({ message: "auth failed with ghp_abc123XYZ" }, {}); + expect(JSON.stringify(result)).not.toContain("ghp_abc123"); + expect(JSON.stringify(result)).toContain("[REDACTED]"); + }); + + it("redacts SCREAMING_SNAKE_TOKEN= patterns", () => { + const scrub = getBeforeSend(); + const result = scrub({ message: "GITHUB_TOKEN=ghs_secret456" }, {}); + expect(JSON.stringify(result)).not.toContain("ghs_secret456"); + expect(JSON.stringify(result)).toContain("[REDACTED]"); + }); + + it("leaves non-token content intact", () => { + const scrub = getBeforeSend(); + const result = scrub({ message: "config not found at ./holocron.config.ts" }, {}); + expect(JSON.stringify(result)).toContain("config not found"); + }); +}); diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 0000000..0c39fcb --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,61 @@ +import type { ErrorEvent, EventHint } from "@sentry/node"; +import * as Sentry from "@sentry/node"; + +import { env } from "@/utils/env"; + +// Set CLI_TEMPLATE_SENTRY_DSN in your environment (or .env) to enable telemetry. +// Opt out at any time with CLI_TEMPLATE_NO_TELEMETRY=1. +function isEnabled(): boolean { + return !env.parser.get("no_telemetry") && Boolean(env.parser.get("sentry_dsn")); +} + +export function init(version: string): void { + if (!isEnabled()) return; + Sentry.init({ + dsn: String(env.parser.get("sentry_dsn")), + release: `cli-template@${version}`, + environment: process.env.CI ? "ci" : "local", + tracesSampleRate: 1.0, + beforeSend: scrubError, + }); + Sentry.startSession(); + Sentry.setTag("os", process.platform); + Sentry.setTag("node", process.version); + Sentry.setTag("ci", String(Boolean(process.env.CI))); +} + +export function startCommand(name: string): (ok: boolean) => void { + if (!isEnabled()) return () => {}; + Sentry.setTag("command", name); + const span = Sentry.startInactiveSpan({ name, op: "cli-template.command", forceTransaction: true }); + return (ok: boolean) => { + span.setStatus({ code: ok ? 1 : 2 }); + span.end(); + }; +} + +export function captureException(err: unknown): void { + if (!isEnabled()) return; + Sentry.captureException(err); +} + +export function endSession(): void { + if (!isEnabled()) return; + Sentry.endSession(); +} + +export async function flush(): Promise { + if (!isEnabled()) return; + await Sentry.close(2_000); +} + +// Scrub known token shapes from any string in the Sentry event payload. +const TOKEN_RE = /\b(ghp_|ghs_|glpat-|xoxb-|xoxp-|npm_|sk-|[A-Z][A-Z0-9_]{2,}_TOKEN[=\s])[^\s"]*/g; + +function redact(raw: string): string { + return raw.replace(TOKEN_RE, "[REDACTED]"); +} + +function scrubError(event: ErrorEvent, _hint: EventHint): ErrorEvent { + return JSON.parse(redact(JSON.stringify(event))) as ErrorEvent; +} diff --git a/src/utils/env/env.ts b/src/utils/env/env.ts index 01c4412..3c042db 100644 --- a/src/utils/env/env.ts +++ b/src/utils/env/env.ts @@ -7,7 +7,7 @@ import { createEnvParser, type EnvParser } from "@theholocron/env-utils"; // tooling and runtime stay in sync. // Add org-wide prefixes before the project-specific one: // ["HOLOCRON", "CLI_TEMPLATE"] → HOLOCRON_* as defaults, CLI_TEMPLATE_* overrides -const NAMESPACES = ["CLI_TEMPLATE"] as const; +const NAMESPACES = ["HOLOCRON", "CLI_TEMPLATE"] as const; export const parser: EnvParser = createEnvParser({ appName: "cli-template",