skillrepo 4.2.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/bin/skillrepo.mjs +14 -1
- package/package.json +1 -1
- package/src/commands/init.mjs +60 -2
- package/src/commands/publish.mjs +125 -0
- package/src/commands/unpublish.mjs +129 -0
- package/src/lib/config.mjs +6 -0
- package/src/lib/http.mjs +189 -0
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/publish.test.mjs +420 -0
- package/src/test/e2e/mock-server.mjs +110 -0
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/telemetry.test.mjs +289 -0
package/src/lib/http.mjs
CHANGED
|
@@ -1055,3 +1055,192 @@ export async function searchSkills(serverUrl, apiKey, opts = {}) {
|
|
|
1055
1055
|
pagination: body.pagination ?? { total: 0, limit: opts.limit ?? 20, offset: opts.offset ?? 0 },
|
|
1056
1056
|
};
|
|
1057
1057
|
}
|
|
1058
|
+
|
|
1059
|
+
// ── /api/v1/library/[owner]/[name]/publish + /unpublish (#1444 R3 #1449) ──
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* @typedef {Object} VisibilityResultPublished
|
|
1063
|
+
* @property {"published"} action
|
|
1064
|
+
* @property {any} skill
|
|
1065
|
+
*
|
|
1066
|
+
* @typedef {Object} VisibilityResultUnpublished
|
|
1067
|
+
* @property {"unpublished"} action
|
|
1068
|
+
* @property {number} notifiedSubscriberCount
|
|
1069
|
+
* @property {any} skill
|
|
1070
|
+
*
|
|
1071
|
+
* @typedef {Object} VisibilityResultUnchanged
|
|
1072
|
+
* @property {"unchanged"} action
|
|
1073
|
+
* @property {any} skill
|
|
1074
|
+
*
|
|
1075
|
+
* @typedef {Object} VisibilityResultForbidden
|
|
1076
|
+
* @property {"forbidden"} action
|
|
1077
|
+
* @property {"publish_not_permitted" | "unpublish_not_permitted"} code
|
|
1078
|
+
* @property {string} reason
|
|
1079
|
+
*
|
|
1080
|
+
* @typedef {Object} VisibilityResultBlocked
|
|
1081
|
+
* @property {"publish-blocked"} action
|
|
1082
|
+
* @property {"namespace_unset" | "analysis_pending" | "safety_grade_too_low"} code
|
|
1083
|
+
* @property {string} reason
|
|
1084
|
+
*
|
|
1085
|
+
* @typedef {Object} VisibilityResultNotFound
|
|
1086
|
+
* @property {"not-found"} action
|
|
1087
|
+
* @property {string} owner
|
|
1088
|
+
* @property {string} name
|
|
1089
|
+
*
|
|
1090
|
+
* @typedef {VisibilityResultPublished | VisibilityResultUnpublished | VisibilityResultUnchanged
|
|
1091
|
+
* | VisibilityResultForbidden | VisibilityResultBlocked | VisibilityResultNotFound} VisibilityResult
|
|
1092
|
+
*/
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Shared helper used by `publishLibrarySkill` and `unpublishLibrarySkill`.
|
|
1096
|
+
* The two endpoints differ only in:
|
|
1097
|
+
* - URL suffix (/publish vs /unpublish)
|
|
1098
|
+
* - 200 `action` value (published vs unpublished)
|
|
1099
|
+
* - 200 response includes `notifiedSubscriberCount` only on unpublish
|
|
1100
|
+
* - 403 `code` (publish_not_permitted vs unpublish_not_permitted)
|
|
1101
|
+
* - 422 `code` set differs (publish has product-rule blocked codes;
|
|
1102
|
+
* unpublish has only `idempotency_key_reused` which routes
|
|
1103
|
+
* through `mapErrorResponse`)
|
|
1104
|
+
*
|
|
1105
|
+
* Both return a discriminated `VisibilityResult` so the command layer
|
|
1106
|
+
* can map outcomes to user-facing exit codes without duplicating the
|
|
1107
|
+
* status-code dispatch.
|
|
1108
|
+
*
|
|
1109
|
+
* Idempotency-Key: auto-generated unless supplied via `options.idempotencyKey`.
|
|
1110
|
+
* `retry: true` is safe because the server caches responses for 24h
|
|
1111
|
+
* keyed by `(accountId, key, route)`.
|
|
1112
|
+
*/
|
|
1113
|
+
async function setSkillVisibility(serverUrl, apiKey, owner, name, target, options = {}) {
|
|
1114
|
+
const action = target === "global" ? "publish" : "unpublish";
|
|
1115
|
+
const url = `${normalizeUrl(serverUrl)}/api/v1/library/${encodeURIComponent(
|
|
1116
|
+
owner,
|
|
1117
|
+
)}/${encodeURIComponent(name)}/${action}`;
|
|
1118
|
+
const idempotencyKey = options.idempotencyKey ?? generateIdempotencyKey();
|
|
1119
|
+
|
|
1120
|
+
const res = await safeFetch(url, {
|
|
1121
|
+
method: "POST",
|
|
1122
|
+
headers: {
|
|
1123
|
+
...(await authHeaders(apiKey)),
|
|
1124
|
+
"Idempotency-Key": idempotencyKey,
|
|
1125
|
+
},
|
|
1126
|
+
retry: true,
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
if (res.status === 200) {
|
|
1130
|
+
const body = await parseJsonOrThrow(res, url);
|
|
1131
|
+
if (body?.action === "published") {
|
|
1132
|
+
return { action: "published", skill: body.skill };
|
|
1133
|
+
}
|
|
1134
|
+
if (body?.action === "unpublished") {
|
|
1135
|
+
return {
|
|
1136
|
+
action: "unpublished",
|
|
1137
|
+
notifiedSubscriberCount: body.notifiedSubscriberCount ?? 0,
|
|
1138
|
+
skill: body.skill,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
if (body?.action === "unchanged") {
|
|
1142
|
+
return { action: "unchanged", skill: body.skill };
|
|
1143
|
+
}
|
|
1144
|
+
throw networkError(
|
|
1145
|
+
`Library ${action} returned 200 with unexpected action "${body?.action}" from ${url}`,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (res.status === 404) {
|
|
1150
|
+
await res.json().catch(() => undefined);
|
|
1151
|
+
return { action: "not-found", owner, name };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// 403 / 422 require body inspection. Once we read the stream we
|
|
1155
|
+
// CANNOT fall through to `mapErrorResponse` (which re-reads the
|
|
1156
|
+
// body and would get an empty object — a `plan_limit` 403 would
|
|
1157
|
+
// mis-classify as authError exit 2 instead of validationError
|
|
1158
|
+
// exit 5 with billing hint). Pre-parse once, then dispatch every
|
|
1159
|
+
// code path inline mirroring `mapErrorResponse`'s shape so the
|
|
1160
|
+
// CliError types stay consistent across endpoints.
|
|
1161
|
+
if (res.status === 403 || res.status === 422) {
|
|
1162
|
+
let body = null;
|
|
1163
|
+
try {
|
|
1164
|
+
body = await res.json();
|
|
1165
|
+
} catch {
|
|
1166
|
+
// body stays null — fall through to the no-code branches below
|
|
1167
|
+
}
|
|
1168
|
+
const code = typeof body?.code === "string" ? body.code : null;
|
|
1169
|
+
const message = body?.error || `${res.status} ${res.statusText}`;
|
|
1170
|
+
|
|
1171
|
+
if (res.status === 403) {
|
|
1172
|
+
// Our endpoint-specific permission failure.
|
|
1173
|
+
if (code === "publish_not_permitted" || code === "unpublish_not_permitted") {
|
|
1174
|
+
return { action: "forbidden", code, reason: message };
|
|
1175
|
+
}
|
|
1176
|
+
// Mirror `mapErrorResponse`'s 403 taxonomy so a 403 here behaves
|
|
1177
|
+
// identically to a 403 on any other endpoint:
|
|
1178
|
+
// - `plan_limit` → validationError (exit 5) with billing hint
|
|
1179
|
+
// - any `*scope*` code → scopeError (exit 4)
|
|
1180
|
+
// - everything else → authError (exit 2, "suspended" hint)
|
|
1181
|
+
if (code === "plan_limit") {
|
|
1182
|
+
throw validationError(message, {
|
|
1183
|
+
hint: "Upgrade your plan at /app/settings/billing or remove an existing skill from your library.",
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (code && code.toLowerCase().includes("scope")) {
|
|
1187
|
+
throw scopeError(message, {
|
|
1188
|
+
hint: "Create a write-scoped key at /app/settings/access-keys.",
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
throw authError(message, {
|
|
1192
|
+
hint: "If your account is suspended, contact support.",
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// res.status === 422
|
|
1197
|
+
if (target === "global") {
|
|
1198
|
+
if (
|
|
1199
|
+
code === "namespace_unset" ||
|
|
1200
|
+
code === "analysis_pending" ||
|
|
1201
|
+
code === "safety_grade_too_low"
|
|
1202
|
+
) {
|
|
1203
|
+
return { action: "publish-blocked", code, reason: message };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
// `idempotency_key_reused` and any other 422 code surface as
|
|
1207
|
+
// validationError so the exit code matches the rest of v1.
|
|
1208
|
+
throw validationError(message);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const err = await mapErrorResponse(res, url);
|
|
1212
|
+
if (err === null) {
|
|
1213
|
+
throw validationError(
|
|
1214
|
+
`Library ${action} returned ${res.status} with no documented meaning.`,
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
throw err;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* POST /api/v1/library/{owner}/{name}/publish (#1449).
|
|
1222
|
+
*
|
|
1223
|
+
* @param {string} serverUrl
|
|
1224
|
+
* @param {string} apiKey
|
|
1225
|
+
* @param {string} owner
|
|
1226
|
+
* @param {string} name
|
|
1227
|
+
* @param {{ idempotencyKey?: string }} [options]
|
|
1228
|
+
* @returns {Promise<VisibilityResult>}
|
|
1229
|
+
*/
|
|
1230
|
+
export async function publishLibrarySkill(serverUrl, apiKey, owner, name, options = {}) {
|
|
1231
|
+
return setSkillVisibility(serverUrl, apiKey, owner, name, "global", options);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* POST /api/v1/library/{owner}/{name}/unpublish (#1449).
|
|
1236
|
+
*
|
|
1237
|
+
* @param {string} serverUrl
|
|
1238
|
+
* @param {string} apiKey
|
|
1239
|
+
* @param {string} owner
|
|
1240
|
+
* @param {string} name
|
|
1241
|
+
* @param {{ idempotencyKey?: string }} [options]
|
|
1242
|
+
* @returns {Promise<VisibilityResult>}
|
|
1243
|
+
*/
|
|
1244
|
+
export async function unpublishLibrarySkill(serverUrl, apiKey, owner, name, options = {}) {
|
|
1245
|
+
return setSkillVisibility(serverUrl, apiKey, owner, name, "private", options);
|
|
1246
|
+
}
|
|
@@ -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", () => {
|