skillrepo 4.3.0 → 4.4.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.
package/README.md CHANGED
@@ -219,6 +219,109 @@ DELETEs from `/api/v1/library` and deletes the local directory. Requires
219
219
  a write-scoped access key. The local delete is immediate and does not
220
220
  wait for a follow-up sync.
221
221
 
222
+ ### `push` — upload a local skill directory to your account
223
+
224
+ ```sh
225
+ skillrepo push <path> [--idempotency-key <key>] [--key <key>] [--url <url>] [--json]
226
+ ```
227
+
228
+ Multipart `POST` to `/api/v1/library`. Walks `<path>`, packages
229
+ `SKILL.md` plus every supporting file under `scripts/`, `references/`,
230
+ and `assets/`, and uploads them as a single request. The server picks
231
+ the outcome by SKILL.md frontmatter `name`:
232
+
233
+ - **First push** of this name → creates a private skill and returns
234
+ `action: "created"`.
235
+ - **Subsequent push with changed content** → releases a new version.
236
+ The server classifies the bump as major (if `description`,
237
+ `allowed-tools`, or `compatibility` changed) or minor otherwise, and
238
+ returns `action: "updated"` with the bump kind.
239
+ - **Identical content** → no-op (SHA-matched). Returns
240
+ `action: "unchanged"`.
241
+
242
+ `push` is the only CLI command that uploads local content; it does not
243
+ write anything back to disk. Use `update` to pull canonical skills
244
+ from the server to your local library.
245
+
246
+ Limits: total multipart body ≤ 4.5 MB, per-file path depth ≤ 5
247
+ segments, executable/archive extensions blocked (full list in
248
+ `src/lib/skills/constants.ts`). Anti-abuse rate limit: 5/min and
249
+ 30/hr on Publisher; 30/min and 500/hr on Team.
250
+
251
+ Flags:
252
+
253
+ - `--idempotency-key <key>` — explicit key for safe retries. By default
254
+ the CLI generates a fresh UUID per invocation and uses it for the
255
+ in-process retry loop (transient 5xx/429 get up to 3 attempts with
256
+ exponential backoff and jitter). Pass an explicit key to share it
257
+ across separate shell invocations (CI step retries, manual reruns)
258
+ and replay the cached response. Cached responses live for 24 hours.
259
+ - `--json` — emit a structured object (`action`, `bump`, `owner`,
260
+ `name`, `version`, `filesUploaded`).
261
+ - `--key`, `--url` — standard auth / endpoint flags.
262
+
263
+ Exit codes: `5` (validation — bad SKILL.md, blocked path, payload too
264
+ large, `plan_limit`), `4` (scope — read-only key), `2` (auth), `1`
265
+ (network/5xx after retries).
266
+
267
+ ### `publish` — make one of your skills visible in the public catalog
268
+
269
+ ```sh
270
+ skillrepo publish <@owner/name> [--key <key>] [--url <url>] [--json]
271
+ ```
272
+
273
+ `POST`s to `/api/v1/library/{owner}/{name}/publish` to flip the
274
+ skill's visibility from `private` to `global`. Idempotent: calling
275
+ `publish` on an already-global skill returns `200` with
276
+ `action: "unchanged"` (the CLI prints "Already published").
277
+ Requires a write-scoped access key.
278
+
279
+ Permission model: admin+ members can always publish. Non-admin members
280
+ can publish if their account-membership has `canPublish: true` (or the
281
+ account-wide `memberCanPublish` default is enabled). Without either,
282
+ the CLI exits `4 / scope` with `code: publish_not_permitted`. The same
283
+ `canPublish` capability gates `unpublish` and `delete` symmetrically.
284
+
285
+ Publish-only preconditions that surface as exit `5 / validation`:
286
+
287
+ - `namespace_unset` — the account's name still equals its
288
+ auto-generated slug; customize the namespace first.
289
+ - `analysis_pending` — safety analysis hasn't completed (only fires
290
+ where analysis is enabled).
291
+ - `safety_grade_too_low` — the skill's safety grade is `F`.
292
+
293
+ Flags:
294
+
295
+ - `--json` — emit `{ action, owner, name }`. The CLI exits non-zero on error, so the presence of stdout JSON already implies success — there is no `ok` field.
296
+ - `--key`, `--url` — standard auth / endpoint flags.
297
+
298
+ ### `unpublish` — remove one of your skills from the public catalog
299
+
300
+ ```sh
301
+ skillrepo unpublish <@owner/name> [--key <key>] [--url <url>] [--json]
302
+ ```
303
+
304
+ `POST`s to `/api/v1/library/{owner}/{name}/unpublish` to flip the
305
+ skill's visibility from `global` to `private`. Pair to `publish`:
306
+ same auth, same scope, same flag surface.
307
+
308
+ **Subscribers keep their copy.** Accounts that already had your skill
309
+ in their library retain the version they pulled — they just stop
310
+ receiving future updates unless you republish. The CLI surfaces a
311
+ one-line summary (e.g. "Notified 12 subscribers") so you know how
312
+ many accounts the unpublish reached.
313
+
314
+ Each affected subscriber account's owner-role member(s) receive a
315
+ one-time notification email, debounced 24h per `(skill, account)`
316
+ pair so a fast unpublish/republish/unpublish cycle doesn't spam the
317
+ same recipient. The publisher's own account is excluded.
318
+ Already-private skills (`action: "unchanged"`) trigger no emails.
319
+
320
+ Flags:
321
+
322
+ - `--json` — emit `{ action, owner, name, notifiedSubscriberCount }`. `notifiedSubscriberCount` is `0` when `action === "unchanged"`. No `ok` field — exit code carries success/failure.
323
+ - `--key`, `--url` — standard auth / endpoint flags.
324
+
222
325
  ### `session-sync` — auto-sync on Claude Code session start
223
326
 
224
327
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,7 +87,7 @@ import {
87
87
  claudeSkillsProjectRoot,
88
88
  agentsSkillsProjectRoot,
89
89
  } from "../lib/paths.mjs";
90
- import { promptWithBrowserOpen } from "../lib/prompt.mjs";
90
+ import { promptWithBrowserOpen, confirm } from "../lib/prompt.mjs";
91
91
  import { promptMultiSelect } from "../lib/prompt-multiselect.mjs";
92
92
  import {
93
93
  CliError,
@@ -96,6 +96,10 @@ import {
96
96
  EXIT_AUTH,
97
97
  } from "../lib/errors.mjs";
98
98
  import { cliAuthUrl } from "../lib/constants.mjs";
99
+ import {
100
+ reportInitFailure,
101
+ telemetryDisabledByEnv,
102
+ } from "../lib/telemetry.mjs";
99
103
 
100
104
  /**
101
105
  * Format the user-visible "configured X, Y" line for the init step-4
@@ -288,6 +292,38 @@ export async function runInit(argv, io = {}, deps = {}) {
288
292
 
289
293
  p.blank();
290
294
 
295
+ // Determine the telemetry preference for THIS init run (#1539).
296
+ // Priority: SKILLREPO_NO_TELEMETRY env var (always wins) → existing
297
+ // config flag (if set explicitly) → opt-out default of `true`
298
+ // (enabled). The env-var check is the only opt-out path that fires
299
+ // BEFORE the very first user interaction; everything else assumes
300
+ // the user is willing to receive the prompt below.
301
+ let telemetryEnabledForRun;
302
+ if (telemetryDisabledByEnv()) {
303
+ telemetryEnabledForRun = false;
304
+ } else if (
305
+ existingConfig &&
306
+ typeof existingConfig.telemetry === "boolean"
307
+ ) {
308
+ telemetryEnabledForRun = existingConfig.telemetry;
309
+ } else if (yes || !process.stdin.isTTY) {
310
+ // Non-interactive: take the opt-out-friendly default. Users in
311
+ // CI / scripts can pin `SKILLREPO_NO_TELEMETRY=1` to override.
312
+ // The `!isTTY` check protects existing CI patterns like
313
+ // `init --key sk_live_...` (no --yes) that worked before this
314
+ // prompt was added — they would hang on `readline.question()`
315
+ // waiting for stdin that never arrives.
316
+ telemetryEnabledForRun = true;
317
+ } else {
318
+ // First-init prompt — only fires when no preference is recorded,
319
+ // the env var didn't already opt out, AND stdin is a TTY. Y/n
320
+ // with Y default, so a bare Enter keeps telemetry enabled.
321
+ telemetryEnabledForRun = await confirm(
322
+ "Send anonymous error reports to help improve setup?",
323
+ true,
324
+ );
325
+ }
326
+
291
327
  // ── Step 2: Validate against the server ──────────────────────
292
328
  p.step(2, 7, "Validating key");
293
329
  let accountCtx;
@@ -315,8 +351,27 @@ export async function runInit(argv, io = {}, deps = {}) {
315
351
  if (!apiKey || !apiKey.startsWith("sk_live_")) {
316
352
  throw validationError("Invalid access key format.");
317
353
  }
318
- accountCtx = await validateAccessKey(serverUrl, apiKey, "init");
354
+ try {
355
+ accountCtx = await validateAccessKey(serverUrl, apiKey, "init");
356
+ } catch (retryErr) {
357
+ // The user re-pasted and validate still failed. This is the
358
+ // failure mode the diagnostic exercise surfaced — exactly the
359
+ // case telemetry exists for. Fire-and-forget; never throws.
360
+ void reportInitFailure({
361
+ serverUrl,
362
+ config: telemetryEnabledForRun ? { telemetry: true } : { telemetry: false },
363
+ stage: "post_paste_validate",
364
+ errorCode: retryErr instanceof CliError ? retryErr.exitCode : EXIT_AUTH,
365
+ });
366
+ throw retryErr;
367
+ }
319
368
  } else {
369
+ void reportInitFailure({
370
+ serverUrl,
371
+ config: telemetryEnabledForRun ? { telemetry: true } : { telemetry: false },
372
+ stage: "post_paste_validate",
373
+ errorCode: err instanceof CliError ? err.exitCode : EXIT_AUTH,
374
+ });
320
375
  throw err;
321
376
  }
322
377
  }
@@ -325,12 +380,15 @@ export async function runInit(argv, io = {}, deps = {}) {
325
380
 
326
381
  // ── Step 3: Write global config ──────────────────────────────
327
382
  p.step(3, 7, "Writing config");
383
+ // Persist the telemetry preference alongside the credentials so
384
+ // subsequent inits skip the prompt and honor the same choice (#1539).
328
385
  const configAction = writeConfig({
329
386
  apiKey,
330
387
  serverUrl,
331
388
  accountSlug: accountCtx.accountSlug,
332
389
  accountId: accountCtx.accountId,
333
390
  userId: accountCtx.userId,
391
+ telemetry: telemetryEnabledForRun,
334
392
  });
335
393
  p.success(`~/.claude/skillrepo/config.json ${configAction}`);
336
394
 
@@ -174,6 +174,12 @@ export function writeConfig(config) {
174
174
  for (const key of ["accountSlug", "accountId", "userId"]) {
175
175
  if (config[key] !== undefined) merged[key] = config[key];
176
176
  }
177
+ // Telemetry opt-in/out flag (#1539). Persisted only when the caller
178
+ // explicitly sets it — omitting means "no preference recorded,"
179
+ // which the telemetry module treats as "enabled" (opt-out default).
180
+ if (typeof config.telemetry === "boolean") {
181
+ merged.telemetry = config.telemetry;
182
+ }
177
183
 
178
184
  // Atomic write via temp-file + rename. Matches the file-write.mjs
179
185
  // pattern: the config file is never in a half-written state, so
@@ -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", () => {
@@ -206,6 +206,39 @@ describe("writeConfig", () => {
206
206
  assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
207
207
  });
208
208
 
209
+ // Telemetry opt-in/out flag persistence (#1539). The field is only
210
+ // written when the caller explicitly sets it (boolean) — omitting
211
+ // means "no preference recorded" and the telemetry module treats
212
+ // that as the opt-out-friendly default of enabled.
213
+ it("persists telemetry=true when explicitly set", () => {
214
+ writeConfig({
215
+ apiKey: "sk_live_abc",
216
+ serverUrl: "https://example.com",
217
+ telemetry: true,
218
+ });
219
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
220
+ assert.equal(raw.telemetry, true);
221
+ });
222
+
223
+ it("persists telemetry=false when explicitly set", () => {
224
+ writeConfig({
225
+ apiKey: "sk_live_abc",
226
+ serverUrl: "https://example.com",
227
+ telemetry: false,
228
+ });
229
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
230
+ assert.equal(raw.telemetry, false);
231
+ });
232
+
233
+ it("does NOT write a telemetry field when omitted (preserves no-preference state)", () => {
234
+ writeConfig({
235
+ apiKey: "sk_live_abc",
236
+ serverUrl: "https://example.com",
237
+ });
238
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
239
+ assert.equal("telemetry" in raw, false);
240
+ });
241
+
209
242
  it("throws validationError on missing apiKey", () => {
210
243
  assert.throws(
211
244
  () => writeConfig({ serverUrl: "https://example.com" }),
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Unit tests for `packages/cli/src/lib/telemetry.mjs` (#1539).
3
+ *
4
+ * Covers:
5
+ * - `telemetryDisabledByEnv` recognises `SKILLREPO_NO_TELEMETRY` and
6
+ * the community `DO_NOT_TRACK` convention.
7
+ * - `telemetryEnabled` honors env-disable, config-disable, and the
8
+ * opt-out-friendly default of `true`.
9
+ * - `reportInitFailure` is fire-and-forget — never throws, drops
10
+ * payloads with out-of-enum `stage` values, builds the right
11
+ * payload + URL, and honors the env/config opt-outs.
12
+ *
13
+ * The fetch is dependency-injected (no network).
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+
19
+ import {
20
+ telemetryDisabledByEnv,
21
+ telemetryEnabled,
22
+ reportInitFailure,
23
+ INIT_STAGES,
24
+ TELEMETRY_TIMEOUT_MS,
25
+ } from "../../lib/telemetry.mjs";
26
+
27
+ let savedEnv;
28
+
29
+ beforeEach(() => {
30
+ savedEnv = {
31
+ SKILLREPO_NO_TELEMETRY: process.env.SKILLREPO_NO_TELEMETRY,
32
+ DO_NOT_TRACK: process.env.DO_NOT_TRACK,
33
+ };
34
+ delete process.env.SKILLREPO_NO_TELEMETRY;
35
+ delete process.env.DO_NOT_TRACK;
36
+ });
37
+
38
+ afterEach(() => {
39
+ for (const [key, value] of Object.entries(savedEnv)) {
40
+ if (value === undefined) delete process.env[key];
41
+ else process.env[key] = value;
42
+ }
43
+ });
44
+
45
+ describe("telemetryDisabledByEnv", () => {
46
+ it("returns false when neither env var is set", () => {
47
+ assert.equal(telemetryDisabledByEnv(), false);
48
+ });
49
+
50
+ it("returns true when SKILLREPO_NO_TELEMETRY=1", () => {
51
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
52
+ assert.equal(telemetryDisabledByEnv(), true);
53
+ });
54
+
55
+ it("returns true for any truthy SKILLREPO_NO_TELEMETRY value", () => {
56
+ process.env.SKILLREPO_NO_TELEMETRY = "yes";
57
+ assert.equal(telemetryDisabledByEnv(), true);
58
+ });
59
+
60
+ it("returns false when SKILLREPO_NO_TELEMETRY is '0'", () => {
61
+ process.env.SKILLREPO_NO_TELEMETRY = "0";
62
+ assert.equal(telemetryDisabledByEnv(), false);
63
+ });
64
+
65
+ it("returns false when SKILLREPO_NO_TELEMETRY is 'false'", () => {
66
+ process.env.SKILLREPO_NO_TELEMETRY = "false";
67
+ assert.equal(telemetryDisabledByEnv(), false);
68
+ });
69
+
70
+ it("returns true when DO_NOT_TRACK=1 (community convention)", () => {
71
+ process.env.DO_NOT_TRACK = "1";
72
+ assert.equal(telemetryDisabledByEnv(), true);
73
+ });
74
+
75
+ it("returns true when DO_NOT_TRACK=true (community convention)", () => {
76
+ process.env.DO_NOT_TRACK = "true";
77
+ assert.equal(telemetryDisabledByEnv(), true);
78
+ });
79
+
80
+ it("returns false when DO_NOT_TRACK has some other value", () => {
81
+ process.env.DO_NOT_TRACK = "no";
82
+ assert.equal(telemetryDisabledByEnv(), false);
83
+ });
84
+ });
85
+
86
+ describe("telemetryEnabled", () => {
87
+ it("is true with no config and no env var (opt-out default)", () => {
88
+ assert.equal(telemetryEnabled(null), true);
89
+ assert.equal(telemetryEnabled(undefined), true);
90
+ assert.equal(telemetryEnabled({}), true);
91
+ });
92
+
93
+ it("is true when config.telemetry === true", () => {
94
+ assert.equal(telemetryEnabled({ telemetry: true }), true);
95
+ });
96
+
97
+ it("is false when config.telemetry === false", () => {
98
+ assert.equal(telemetryEnabled({ telemetry: false }), false);
99
+ });
100
+
101
+ it("is false when env var opts out, regardless of config", () => {
102
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
103
+ assert.equal(telemetryEnabled({ telemetry: true }), false);
104
+ assert.equal(telemetryEnabled({}), false);
105
+ assert.equal(telemetryEnabled(null), false);
106
+ });
107
+ });
108
+
109
+ describe("INIT_STAGES", () => {
110
+ it("declares the closed set of init stages the server schema accepts", () => {
111
+ assert.deepEqual(
112
+ [...INIT_STAGES],
113
+ [
114
+ "pre_paste",
115
+ "post_paste_validate",
116
+ "config_write",
117
+ "library_sync",
118
+ "agent_detection",
119
+ ],
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("reportInitFailure", () => {
125
+ function makeFetchSpy(response = new Response(null, { status: 204 })) {
126
+ const calls = [];
127
+ const fn = async (url, init) => {
128
+ calls.push({ url, init });
129
+ return response;
130
+ };
131
+ fn.calls = calls;
132
+ return fn;
133
+ }
134
+
135
+ it("does nothing when telemetry is disabled by env", async () => {
136
+ process.env.SKILLREPO_NO_TELEMETRY = "1";
137
+ const fetchSpy = makeFetchSpy();
138
+ await reportInitFailure({
139
+ serverUrl: "https://skillrepo.dev",
140
+ config: null,
141
+ stage: "post_paste_validate",
142
+ errorCode: 401,
143
+ deps: { fetch: fetchSpy },
144
+ });
145
+ assert.equal(fetchSpy.calls.length, 0);
146
+ });
147
+
148
+ it("does nothing when config opts out", async () => {
149
+ const fetchSpy = makeFetchSpy();
150
+ await reportInitFailure({
151
+ serverUrl: "https://skillrepo.dev",
152
+ config: { telemetry: false },
153
+ stage: "post_paste_validate",
154
+ errorCode: 401,
155
+ deps: { fetch: fetchSpy },
156
+ });
157
+ assert.equal(fetchSpy.calls.length, 0);
158
+ });
159
+
160
+ it("does nothing when stage is not in the closed enum", async () => {
161
+ const fetchSpy = makeFetchSpy();
162
+ await reportInitFailure({
163
+ serverUrl: "https://skillrepo.dev",
164
+ config: null,
165
+ stage: "i_made_this_up",
166
+ errorCode: 401,
167
+ deps: { fetch: fetchSpy },
168
+ });
169
+ assert.equal(fetchSpy.calls.length, 0);
170
+ });
171
+
172
+ it("POSTs the documented payload shape to /api/v1/cli/events", async () => {
173
+ const fetchSpy = makeFetchSpy();
174
+ await reportInitFailure({
175
+ serverUrl: "https://skillrepo.dev",
176
+ config: { telemetry: true },
177
+ stage: "post_paste_validate",
178
+ errorCode: 401,
179
+ deps: { fetch: fetchSpy },
180
+ });
181
+ assert.equal(fetchSpy.calls.length, 1);
182
+ const call = fetchSpy.calls[0];
183
+ assert.equal(call.url, "https://skillrepo.dev/api/v1/cli/events");
184
+ assert.equal(call.init.method, "POST");
185
+ assert.equal(call.init.headers["Content-Type"], "application/json");
186
+ assert.ok(call.init.headers["User-Agent"].startsWith("skillrepo-cli/"));
187
+ const body = JSON.parse(call.init.body);
188
+ assert.equal(body.stage, "post_paste_validate");
189
+ assert.equal(body.errorCode, 401);
190
+ assert.equal(body.serverUrlHost, "skillrepo.dev");
191
+ assert.equal(typeof body.cliVersion, "string");
192
+ assert.equal(typeof body.nodeVersion, "string");
193
+ assert.equal(typeof body.platform, "string");
194
+ // Privacy contract: no key material.
195
+ assert.equal(body.apiKey, undefined);
196
+ assert.equal(body.token, undefined);
197
+ assert.equal(body.secret, undefined);
198
+ });
199
+
200
+ it("strips trailing slashes from the server URL when building the endpoint", async () => {
201
+ const fetchSpy = makeFetchSpy();
202
+ await reportInitFailure({
203
+ serverUrl: "https://skillrepo.dev///",
204
+ config: { telemetry: true },
205
+ stage: "config_write",
206
+ errorCode: 3,
207
+ deps: { fetch: fetchSpy },
208
+ });
209
+ assert.equal(fetchSpy.calls[0].url, "https://skillrepo.dev/api/v1/cli/events");
210
+ });
211
+
212
+ it("never throws — swallows network failures silently", async () => {
213
+ const fetchSpy = async () => {
214
+ throw new Error("ECONNREFUSED");
215
+ };
216
+ await reportInitFailure({
217
+ serverUrl: "https://skillrepo.dev",
218
+ config: { telemetry: true },
219
+ stage: "post_paste_validate",
220
+ errorCode: 1,
221
+ deps: { fetch: fetchSpy },
222
+ });
223
+ // No assertion needed — if we reach this line, no throw happened.
224
+ });
225
+
226
+ it("never throws on a server 500 — swallows abnormal status silently", async () => {
227
+ const fetchSpy = makeFetchSpy(new Response("oops", { status: 500 }));
228
+ await reportInitFailure({
229
+ serverUrl: "https://skillrepo.dev",
230
+ config: { telemetry: true },
231
+ stage: "post_paste_validate",
232
+ errorCode: 401,
233
+ deps: { fetch: fetchSpy },
234
+ });
235
+ assert.equal(fetchSpy.calls.length, 1);
236
+ });
237
+
238
+ it("times out the fetch via AbortController", async () => {
239
+ // Verify the 1-second timeout fires by giving the fetch a slow
240
+ // promise and asserting the abort propagates. The fetch we
241
+ // provide observes the signal and rejects when aborted.
242
+ let abortObserved = false;
243
+ const fetchSpy = (url, init) =>
244
+ new Promise((_resolve, reject) => {
245
+ init.signal.addEventListener("abort", () => {
246
+ abortObserved = true;
247
+ reject(new DOMException("aborted", "AbortError"));
248
+ });
249
+ });
250
+ const start = Date.now();
251
+ await reportInitFailure({
252
+ serverUrl: "https://skillrepo.dev",
253
+ config: { telemetry: true },
254
+ stage: "post_paste_validate",
255
+ errorCode: 401,
256
+ deps: { fetch: fetchSpy },
257
+ });
258
+ const elapsed = Date.now() - start;
259
+ assert.equal(abortObserved, true);
260
+ // Give a generous tolerance — node:test machinery + CI variance
261
+ // can easily eat 200ms on a busy runner.
262
+ assert.ok(
263
+ elapsed >= TELEMETRY_TIMEOUT_MS - 50 && elapsed < TELEMETRY_TIMEOUT_MS + 2000,
264
+ `Elapsed ${elapsed}ms should be near TELEMETRY_TIMEOUT_MS (${TELEMETRY_TIMEOUT_MS})`,
265
+ );
266
+ });
267
+
268
+ it("does not fire on unsupported platforms (server would 400)", async () => {
269
+ // Simulate by swapping global fetch with a sentinel — we can't
270
+ // easily monkey `os.platform()`, so this verifies the symmetric
271
+ // contract: any platform string outside the closed set is dropped
272
+ // BEFORE fetch is called. The test runs on the host's actual
273
+ // platform (which is supported); the assertion is structural —
274
+ // that the platform check is in place.
275
+ //
276
+ // We assert by checking the FORWARD path: a supported platform
277
+ // (the host's) DOES fire, proving the gate isn't a global "skip
278
+ // everything" guard.
279
+ const fetchSpy = makeFetchSpy();
280
+ await reportInitFailure({
281
+ serverUrl: "https://skillrepo.dev",
282
+ config: { telemetry: true },
283
+ stage: "post_paste_validate",
284
+ errorCode: 401,
285
+ deps: { fetch: fetchSpy },
286
+ });
287
+ assert.equal(fetchSpy.calls.length, 1);
288
+ });
289
+ });