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 +103 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +60 -2
- package/src/lib/config.mjs +6 -0
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/telemetry.test.mjs +289 -0
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
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/lib/config.mjs
CHANGED
|
@@ -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
|
+
});
|