skillrepo 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Anonymous CLI telemetry (#1539).
3
+ *
4
+ * The CLI ships zero telemetry by default before this module: when
5
+ * `skillrepo init` fails on a user's machine — paste error, terminal
6
+ * corruption, wrong server URL — nothing reaches the server. The
7
+ * resulting blindspot was the single biggest reason the live diagnostic
8
+ * surfaced in the #1535 epic took days rather than minutes.
9
+ *
10
+ * This module is the OPT-OUT-FRIENDLY counterpart to the server-side
11
+ * `/api/v1/cli/events` endpoint. It does ONE thing — report an init
12
+ * failure — and is designed so that nothing it does can possibly
13
+ * interfere with the user's actual init flow:
14
+ *
15
+ * 1. **Fire-and-forget.** The HTTP call is started without `await`;
16
+ * the calling site continues to the next line immediately.
17
+ * 2. **1-second hard timeout.** Even if the server hangs, the CLI
18
+ * pays at most 1 second of wall-clock for the call.
19
+ * 3. **Never throws.** Every failure mode (network, DNS, timeout,
20
+ * schema-rejection-by-server) is swallowed silently. The user's
21
+ * init flow continues to its own error reporting unchanged.
22
+ * 4. **No key material.** The payload schema is documented at
23
+ * `src/app/api/v1/cli/events/route.ts` — the server uses
24
+ * `.strict()` so any extra field is rejected with 400, and this
25
+ * module's `reportInitFailure` constructs the payload from a
26
+ * closed set of allowed fields rather than spreading a context
27
+ * object that could carry the user's key.
28
+ *
29
+ * Opt-out is honored from three sources, any one of which disables
30
+ * the call entirely:
31
+ *
32
+ * - `SKILLREPO_NO_TELEMETRY=1` (or any non-empty value) in the env.
33
+ * The simplest path for users who don't trust the CLI yet.
34
+ * - `telemetry: false` in the global config (`~/.claude/skillrepo/
35
+ * config.json`). Set interactively at first init, or by hand.
36
+ * - The legacy `DO_NOT_TRACK` env var (community convention from
37
+ * consoledonottrack.com). Honored for parity even though the
38
+ * SkillRepo-specific var is the documented one.
39
+ *
40
+ * Self-hosting note: this module sends data ONLY to the server URL
41
+ * the user explicitly configured via `skillrepo init`. Self-hosters
42
+ * who point at their own SkillRepo instance receive their own
43
+ * telemetry; no traffic leaves their boundary.
44
+ */
45
+
46
+ import { platform as nodePlatform } from "node:os";
47
+ import { getCliVersion } from "./cli-version.mjs";
48
+
49
+ /** Hard cap on how long the CLI waits for the telemetry call. */
50
+ export const TELEMETRY_TIMEOUT_MS = 1000;
51
+
52
+ /**
53
+ * Was telemetry explicitly disabled by env var? Independent of config
54
+ * so a user can disable telemetry without writing a config file (e.g.
55
+ * during the first-ever init, BEFORE the config file exists).
56
+ *
57
+ * Honors:
58
+ * - `SKILLREPO_NO_TELEMETRY` (any truthy value)
59
+ * - `DO_NOT_TRACK` (consoledonottrack.com convention; truthy = opt out)
60
+ *
61
+ * Exported for tests + for the init prompt to skip asking when the env
62
+ * var is already set.
63
+ */
64
+ export function telemetryDisabledByEnv() {
65
+ const skillrepo = process.env.SKILLREPO_NO_TELEMETRY;
66
+ if (skillrepo && skillrepo !== "0" && skillrepo.toLowerCase() !== "false") {
67
+ return true;
68
+ }
69
+ const dnt = process.env.DO_NOT_TRACK;
70
+ if (dnt === "1" || dnt?.toLowerCase() === "true") {
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Is telemetry enabled given the env + a config object? Pure function
78
+ * — takes the config explicitly so it's testable without touching disk.
79
+ * Default is enabled (opt-out, not opt-in) — the user is asked at first
80
+ * init and the prompt's "y" answer is what writes `telemetry: true` to
81
+ * config. Missing-config means brand-new install: the call is allowed
82
+ * (the init flow will land the config on success and persist whatever
83
+ * the user picked at the prompt).
84
+ */
85
+ export function telemetryEnabled(config) {
86
+ if (telemetryDisabledByEnv()) return false;
87
+ // `telemetry: false` explicitly opts out. `telemetry: true` or
88
+ // missing both default to enabled.
89
+ if (config && config.telemetry === false) return false;
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Closed set of init stages. Mirrors the server-side `CliEventBody`
95
+ * Zod schema — sending a value outside this set would be rejected by
96
+ * the server's `.strict()` validator and quietly swallowed by this
97
+ * module, so we constrain at the source.
98
+ */
99
+ export const INIT_STAGES = Object.freeze([
100
+ "pre_paste",
101
+ "post_paste_validate",
102
+ "config_write",
103
+ "library_sync",
104
+ "agent_detection",
105
+ ]);
106
+
107
+ /**
108
+ * Closed set of platforms (mirrors server-side Zod enum). Unknown
109
+ * Node.js `os.platform()` returns are mapped to the closest match or
110
+ * dropped from the payload — the server would reject 'sunos' etc.
111
+ */
112
+ const SUPPORTED_PLATFORMS = new Set([
113
+ "darwin",
114
+ "linux",
115
+ "win32",
116
+ "freebsd",
117
+ "openbsd",
118
+ "aix",
119
+ ]);
120
+
121
+ /**
122
+ * Extract the URL host from a server URL, with no path/query/userinfo.
123
+ * The server's Zod schema rejects anything with a slash or `@`, so a
124
+ * malformed input would fail validation and be silently swallowed —
125
+ * extracting the host on the CLI side keeps the payload clean and
126
+ * matches the privacy contract (no full URLs in payloads).
127
+ */
128
+ function hostnameFrom(serverUrl) {
129
+ try {
130
+ return new URL(serverUrl).host;
131
+ } catch {
132
+ return "unknown";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Report an init failure to the server. Fire-and-forget — the returned
138
+ * promise is the abort/timeout housekeeping, NOT a result the caller
139
+ * should await. Tests await it to assert behavior; production call
140
+ * sites do `void reportInitFailure(...)` and continue immediately.
141
+ *
142
+ * @param {object} params
143
+ * @param {string} params.serverUrl - The SkillRepo server URL the user
144
+ * was trying to init against (used to compute `serverUrlHost`).
145
+ * @param {object|null} params.config - The current global config
146
+ * (or null if no config exists yet). Determines opt-out state.
147
+ * @param {string} params.stage - One of `INIT_STAGES`.
148
+ * @param {number} params.errorCode - CLI exit code or HTTP status.
149
+ * @param {object} [params.deps] - Test seam — inject `fetch` and `now`.
150
+ */
151
+ export async function reportInitFailure({
152
+ serverUrl,
153
+ config,
154
+ stage,
155
+ errorCode,
156
+ deps,
157
+ }) {
158
+ if (!telemetryEnabled(config)) return;
159
+ if (!INIT_STAGES.includes(stage)) return; // Closed enum: silently drop.
160
+
161
+ const platform = nodePlatform();
162
+ if (!SUPPORTED_PLATFORMS.has(platform)) return; // Server would 400.
163
+
164
+ const cliVersion = getCliVersion();
165
+ const payload = {
166
+ stage,
167
+ errorCode,
168
+ cliVersion,
169
+ nodeVersion: process.version,
170
+ platform,
171
+ serverUrlHost: hostnameFrom(serverUrl),
172
+ };
173
+
174
+ // Allow a test-injected fetch (jsdom doesn't have a global fetch by
175
+ // default in some setups, and we want to assert the call shape
176
+ // without hitting the network). Production uses Node's built-in
177
+ // fetch.
178
+ const fetchImpl = (deps && deps.fetch) || globalThis.fetch;
179
+ if (typeof fetchImpl !== "function") return; // Defensive — should never happen on Node ≥18.
180
+
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
183
+
184
+ try {
185
+ await fetchImpl(`${serverUrl.replace(/\/+$/, "")}/api/v1/cli/events`, {
186
+ method: "POST",
187
+ headers: {
188
+ "Content-Type": "application/json",
189
+ "User-Agent": `skillrepo-cli/${cliVersion} telemetry`,
190
+ },
191
+ body: JSON.stringify(payload),
192
+ signal: controller.signal,
193
+ });
194
+ } catch {
195
+ // Silent — telemetry must NEVER raise into the user's init flow.
196
+ // The user already sees the underlying init failure; piling a
197
+ // "telemetry call failed" warning on top would erode trust.
198
+ } finally {
199
+ clearTimeout(timeoutId);
200
+ }
201
+ }
@@ -233,6 +233,91 @@ describe("runInit — credential resolution", () => {
233
233
  });
234
234
  });
235
235
 
236
+ // ── Telemetry consent wiring (#1539, #1535 round-1 audit) ──────────────
237
+ //
238
+ // The first-init consent prompt has four branches:
239
+ // 1. SKILLREPO_NO_TELEMETRY env var → telemetryEnabledForRun=false
240
+ // 2. existing config has explicit telemetry flag → use that
241
+ // 3. --yes OR non-TTY stdin → opt-out-friendly default (true)
242
+ // 4. interactive TTY → confirm() prompt
243
+ //
244
+ // All tests run under `--yes` (process.stdin.isTTY is falsy in node:test
245
+ // regardless), so they exercise branches 1, 2, and 3. Branch 4 (TTY
246
+ // interactive prompt) is integration-level — not unit-testable without
247
+ // a pty harness.
248
+
249
+ describe("runInit — telemetry consent (#1539)", () => {
250
+ let originalNoTelemetry;
251
+ let originalDoNotTrack;
252
+
253
+ beforeEach(async () => {
254
+ await setup();
255
+ originalNoTelemetry = process.env.SKILLREPO_NO_TELEMETRY;
256
+ originalDoNotTrack = process.env.DO_NOT_TRACK;
257
+ delete process.env.SKILLREPO_NO_TELEMETRY;
258
+ delete process.env.DO_NOT_TRACK;
259
+ });
260
+
261
+ afterEach(async () => {
262
+ if (originalNoTelemetry === undefined) {
263
+ delete process.env.SKILLREPO_NO_TELEMETRY;
264
+ } else {
265
+ process.env.SKILLREPO_NO_TELEMETRY = originalNoTelemetry;
266
+ }
267
+ if (originalDoNotTrack === undefined) {
268
+ delete process.env.DO_NOT_TRACK;
269
+ } else {
270
+ process.env.DO_NOT_TRACK = originalDoNotTrack;
271
+ }
272
+ await teardown();
273
+ });
274
+
275
+ it("--yes (no existing config, no env var) defaults telemetry to true", async () => {
276
+ await runInit(
277
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
278
+ { stdout, stderr },
279
+ );
280
+ const cfg = readConfig();
281
+ assert.equal(cfg.telemetry, true);
282
+ });
283
+
284
+ it("SKILLREPO_NO_TELEMETRY=1 short-circuits to telemetry=false (even under --yes)", async () => {
285
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
286
+ await runInit(
287
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
288
+ { stdout, stderr },
289
+ );
290
+ const cfg = readConfig();
291
+ assert.equal(cfg.telemetry, false);
292
+ });
293
+
294
+ it("DO_NOT_TRACK=1 (community convention) short-circuits to telemetry=false", async () => {
295
+ process.env.DO_NOT_TRACK = "1";
296
+ await runInit(
297
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
298
+ { stdout, stderr },
299
+ );
300
+ const cfg = readConfig();
301
+ assert.equal(cfg.telemetry, false);
302
+ });
303
+
304
+ it("existing config.telemetry=false is preserved across re-init", async () => {
305
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
306
+ writeFileSync(
307
+ join(process.env.HOME, ".claude", "skillrepo", "config.json"),
308
+ JSON.stringify({
309
+ schemaVersion: 1,
310
+ apiKey: VALID_KEY,
311
+ serverUrl,
312
+ telemetry: false,
313
+ }),
314
+ );
315
+ await runInit(["--yes"], { stdout, stderr });
316
+ const cfg = readConfig();
317
+ assert.equal(cfg.telemetry, false);
318
+ });
319
+ });
320
+
236
321
  // ── Error paths ────────────────────────────────────────────────────────
237
322
 
238
323
  describe("runInit — error paths", () => {