From 71b22e8b9ed84960b54a1a9d8653dead74977f8b Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:29:47 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20Sentry=20telemetr?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pattern from theholocron/holocron. Adds src/telemetry.ts with init, startCommand, captureException, endSession, and flush. Wires into cli.ts with middleware for per-command spans, try/catch for unhandled errors, and token scrubbing in beforeSend. DSN is empty by default — set it to your Sentry project key to enable. Opt out at any time with NO_CLI_TEMPLATE_TELEMETRY=1. Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 3 ++ src/cli.ts | 77 +++++++++++++++++++++++++++++------------------- src/telemetry.ts | 61 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 30 deletions(-) create mode 100644 src/telemetry.ts 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.ts b/src/telemetry.ts new file mode 100644 index 0000000..6db7ede --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,61 @@ +import type { ErrorEvent, EventHint } from "@sentry/node"; +import * as Sentry from "@sentry/node"; + +// Replace with your DSN from Sentry → Settings → Client Keys. +// Empty string = telemetry silently disabled — safe to ship before the project exists. +const DSN: string = ""; + +function isEnabled(): boolean { + return !process.env.NO_CLI_TEMPLATE_TELEMETRY && DSN !== ""; +} + +export function init(version: string): void { + if (!isEnabled()) return; + Sentry.init({ + dsn: 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; +} From 3df4104614f6583fd1859ead61b935b57583c483 Mon Sep 17 00:00:00 2001 From: "super-linterbot[bot]" <310376783+super-linterbot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:32:25 +0000 Subject: [PATCH 2/8] chore: fix linting issues Signed-off-by: super-linterbot[bot] <310376783+super-linterbot[bot]@users.noreply.github.com> --- CHANGELOG.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 From 538294e8f0c46c7e1c1f4dbe35cc257804aca3f5 Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:33:32 -0700 Subject: [PATCH 3/8] =?UTF-8?q?test:=20=E2=9C=85=20add=20telemetry=20tests?= =?UTF-8?q?=20and=20switch=20DSN=20to=20env=20var?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read SENTRY_DSN from the environment instead of a hardcoded constant so tests can enable the enabled paths and users configure via .env without editing source. Adds full test coverage for all exports and the scrubError beforeSend callback. Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- src/telemetry.test.ts | 203 ++++++++++++++++++++++++++++++++++++++++++ src/telemetry.ts | 8 +- 2 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 src/telemetry.test.ts diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts new file mode 100644 index 0000000..134eab3 --- /dev/null +++ b/src/telemetry.test.ts @@ -0,0 +1,203 @@ +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), +})); + +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, NO_CLI_TEMPLATE_TELEMETRY: undefined, SENTRY_DSN: "https://test@sentry.io/1" }; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.env = originalEnv; +}); + +// ── opt-out ─────────────────────────────────────────────────────────────────── + +describe("when NO_CLI_TEMPLATE_TELEMETRY is set", () => { + beforeEach(() => { + process.env["NO_CLI_TEMPLATE_TELEMETRY"] = "1"; + }); + + 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 SENTRY_DSN is not set", () => { + beforeEach(() => { + delete process.env["SENTRY_DSN"]; + }); + + 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", () => { + 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 index 6db7ede..9acef44 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,18 +1,16 @@ import type { ErrorEvent, EventHint } from "@sentry/node"; import * as Sentry from "@sentry/node"; -// Replace with your DSN from Sentry → Settings → Client Keys. +// Set SENTRY_DSN in your environment (or .env) to enable telemetry. // Empty string = telemetry silently disabled — safe to ship before the project exists. -const DSN: string = ""; - function isEnabled(): boolean { - return !process.env.NO_CLI_TEMPLATE_TELEMETRY && DSN !== ""; + return !process.env.NO_CLI_TEMPLATE_TELEMETRY && Boolean(process.env.SENTRY_DSN); } export function init(version: string): void { if (!isEnabled()) return; Sentry.init({ - dsn: DSN, + dsn: process.env.SENTRY_DSN, release: `cli-template@${version}`, environment: process.env.CI ? "ci" : "local", tracesSampleRate: 1.0, From 390189a67b6281c254955551a3db8640c36d803f Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:57:37 -0700 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=F0=9F=90=9B=20clear=20CI=20env=20va?= =?UTF-8?q?r=20before=20asserting=20ci=20tag=20is=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- src/telemetry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts index 134eab3..b7d202b 100644 --- a/src/telemetry.test.ts +++ b/src/telemetry.test.ts @@ -100,6 +100,7 @@ describe("init", () => { }); 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); From 5f56e514cf2ebed8f98543baa54519135f526ab0 Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:06:22 -0700 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20use=20env-?= =?UTF-8?q?utils=20parser=20for=20telemetry=20env=20var=20lookups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace process.env.NO_CLI_TEMPLATE_TELEMETRY and process.env.SENTRY_DSN with env.parser.get() so telemetry respects the namespace cascade and .env file loading. Import from @/utils/env directly to avoid pulling the logger barrel into the module. Mock the parser in tests. Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- src/telemetry.test.ts | 24 +++++++++++++++++++----- src/telemetry.ts | 10 ++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts index b7d202b..2b4d914 100644 --- a/src/telemetry.test.ts +++ b/src/telemetry.test.ts @@ -12,6 +12,12 @@ vi.mock("@sentry/node", () => ({ 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 }; @@ -24,8 +30,12 @@ function lastSpan(): MockSpan { const originalEnv = process.env; beforeEach(() => { - process.env = { ...originalEnv, NO_CLI_TEMPLATE_TELEMETRY: undefined, SENTRY_DSN: "https://test@sentry.io/1" }; + 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(() => { @@ -34,9 +44,13 @@ afterEach(() => { // ── opt-out ─────────────────────────────────────────────────────────────────── -describe("when NO_CLI_TEMPLATE_TELEMETRY is set", () => { +describe("when CLI_TEMPLATE_NO_TELEMETRY is set", () => { beforeEach(() => { - process.env["NO_CLI_TEMPLATE_TELEMETRY"] = "1"; + 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", () => { @@ -66,9 +80,9 @@ describe("when NO_CLI_TEMPLATE_TELEMETRY is set", () => { }); }); -describe("when SENTRY_DSN is not set", () => { +describe("when CLI_TEMPLATE_SENTRY_DSN is not set", () => { beforeEach(() => { - delete process.env["SENTRY_DSN"]; + mockGet.mockReturnValue(undefined); }); it("init: skips Sentry.init", () => { diff --git a/src/telemetry.ts b/src/telemetry.ts index 9acef44..0c39fcb 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,16 +1,18 @@ import type { ErrorEvent, EventHint } from "@sentry/node"; import * as Sentry from "@sentry/node"; -// Set SENTRY_DSN in your environment (or .env) to enable telemetry. -// Empty string = telemetry silently disabled — safe to ship before the project exists. +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 !process.env.NO_CLI_TEMPLATE_TELEMETRY && Boolean(process.env.SENTRY_DSN); + return !env.parser.get("no_telemetry") && Boolean(env.parser.get("sentry_dsn")); } export function init(version: string): void { if (!isEnabled()) return; Sentry.init({ - dsn: process.env.SENTRY_DSN, + dsn: String(env.parser.get("sentry_dsn")), release: `cli-template@${version}`, environment: process.env.CI ? "ci" : "local", tracesSampleRate: 1.0, From bc2c00dfea20454c1488b72b72b7f0282c17d01b Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:10:20 -0700 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20HOLOCRON=20namesp?= =?UTF-8?q?ace=20as=20org-wide=20fallback=20for=20env=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parser.get("sentry_dsn") now resolves HOLOCRON_SENTRY_DSN first, then CLI_TEMPLATE_SENTRY_DSN as an override — matching the cascade pattern documented in env.ts. Keeps holocron.config.ts in sync. Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- holocron.config.ts | 5 ++--- src/utils/env/env.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) 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/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", From 50ad2a3121831f1361224d936b5054c32ce695b0 Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:20:59 -0700 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=F0=9F=93=9D=20document=20Sentry=20?= =?UTF-8?q?telemetry=20and=20fix=20stale=20content=20in=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SENTRY_DSN and NO_TELEMETRY env vars to README and docs index. Update namespace cascade description to reflect HOLOCRON → CLI_TEMPLATE. Add Sentry to What's Included and Features. Remove stale sound, open, and dotenv references from docs/content/index.md. Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- README.md | 35 +++++++++++++++++++---------------- docs/content/index.md | 25 ++++++++++++++----------- 2 files changed, 33 insertions(+), 27 deletions(-) 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..17e3129 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 | +| `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 ``` From 612f7b0be8b20e863c8bfd18dba1f239a8fe660e Mon Sep 17 00:00:00 2001 From: Newton <5769156+iamnewton@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:23:14 -0700 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=F0=9F=93=9D=20add=20telemetry=20pa?= =?UTF-8?q?ge=20explaining=20DSN=20setup=20and=20opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Newton <5769156+iamnewton@users.noreply.github.com> --- docs/content/index.md | 12 ++++++------ docs/content/telemetry.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 docs/content/telemetry.md diff --git a/docs/content/index.md b/docs/content/index.md index 17e3129..0b23350 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -42,12 +42,12 @@ CLI_TEMPLATE_DEBUG=true cli-template log 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_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 | +| 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 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.