skillrepo 2.0.0 → 3.1.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 +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global skillrepo config reader/writer — ~/.claude/skillrepo/config.json
|
|
3
|
+
*
|
|
4
|
+
* Built for PR3b's `init` rewrite (#673). The config file is the
|
|
5
|
+
* single source of truth for credentials that every command resolves
|
|
6
|
+
* from when `--key`/`--url` are not provided. The existing
|
|
7
|
+
* `cli-config.mjs` helper already READS this file (via the
|
|
8
|
+
* `readGlobalConfig` helper inside `resolveFlags`); this module adds
|
|
9
|
+
* the WRITE side so `init` can persist credentials after a
|
|
10
|
+
* successful validation.
|
|
11
|
+
*
|
|
12
|
+
* Schema:
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "schemaVersion": 1,
|
|
16
|
+
* "apiKey": "sk_live_...",
|
|
17
|
+
* "serverUrl": "https://skillrepo.dev",
|
|
18
|
+
* "accountSlug": "alice",
|
|
19
|
+
* "accountId": "acc_...",
|
|
20
|
+
* "userId": "user_...",
|
|
21
|
+
* "writtenAt": "2026-04-15T00:00:00Z"
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Only `apiKey` and `serverUrl` are structurally required by the
|
|
25
|
+
* command layer (everything else is metadata for diagnostics). A
|
|
26
|
+
* missing field is treated as a corrupt-but-recoverable state — the
|
|
27
|
+
* reader returns null and the command layer falls back to env vars
|
|
28
|
+
* or prompts.
|
|
29
|
+
*
|
|
30
|
+
* File permissions: the config file contains a live access key.
|
|
31
|
+
* chmod 0600 on POSIX (owner read/write only). Skipped on Windows
|
|
32
|
+
* (where 0600 is a no-op anyway and chmod doesn't prevent
|
|
33
|
+
* Administrator reads).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
chmodSync,
|
|
42
|
+
renameSync,
|
|
43
|
+
unlinkSync,
|
|
44
|
+
} from "node:fs";
|
|
45
|
+
import { dirname } from "node:path";
|
|
46
|
+
import { platform } from "node:os";
|
|
47
|
+
|
|
48
|
+
import { globalConfigPath } from "./paths.mjs";
|
|
49
|
+
import { diskError, validationError } from "./errors.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Current schema version. Bump this on any structural change.
|
|
53
|
+
* Readers treat unknown future versions as "ignore this file,
|
|
54
|
+
* do a full re-init" rather than crashing.
|
|
55
|
+
*/
|
|
56
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} GlobalConfig
|
|
60
|
+
* @property {number} schemaVersion
|
|
61
|
+
* @property {string} apiKey
|
|
62
|
+
* @property {string} serverUrl
|
|
63
|
+
* @property {string} [accountSlug]
|
|
64
|
+
* @property {string} [accountId]
|
|
65
|
+
* @property {string} [userId]
|
|
66
|
+
* @property {string} [writtenAt]
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the global config file. Returns null if the file does not
|
|
71
|
+
* exist, is corrupt, or has an unrecognized schema version.
|
|
72
|
+
*
|
|
73
|
+
* Corrupt cases return null rather than throwing because the command
|
|
74
|
+
* layer has a well-defined fallback (env vars, interactive prompt)
|
|
75
|
+
* and we don't want a garbled config file to crash the CLI.
|
|
76
|
+
*
|
|
77
|
+
* @returns {GlobalConfig | null}
|
|
78
|
+
*/
|
|
79
|
+
export function readConfig() {
|
|
80
|
+
const path = globalConfigPath();
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(path, "utf-8");
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
98
|
+
|
|
99
|
+
// Reject unknown future schema versions. A null return here
|
|
100
|
+
// triggers the caller's prompt-for-new-config path — acceptable
|
|
101
|
+
// degradation for an old CLI reading a new-format file.
|
|
102
|
+
if (parsed.schemaVersion !== undefined && parsed.schemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Legacy fallback: the v2.0.0 CLI wrote config files WITHOUT a
|
|
107
|
+
// schemaVersion field. Accept those as schemaVersion 1 so the new
|
|
108
|
+
// CLI can transparently upgrade. The next writeConfig() call will
|
|
109
|
+
// add the explicit version field.
|
|
110
|
+
if (parsed.schemaVersion === undefined) {
|
|
111
|
+
parsed.schemaVersion = CONFIG_SCHEMA_VERSION;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Required fields
|
|
115
|
+
if (typeof parsed.apiKey !== "string" || !parsed.apiKey) return null;
|
|
116
|
+
if (typeof parsed.serverUrl !== "string" || !parsed.serverUrl) return null;
|
|
117
|
+
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write the global config file atomically. Creates the parent
|
|
123
|
+
* directory if missing. Chmods the file to 0600 on POSIX.
|
|
124
|
+
*
|
|
125
|
+
* Throws `diskError` on any filesystem failure so the init command
|
|
126
|
+
* can surface a clean error with exit code 3.
|
|
127
|
+
*
|
|
128
|
+
* @param {Partial<GlobalConfig>} config - Caller-provided fields.
|
|
129
|
+
* Required: apiKey, serverUrl. Optional: everything else.
|
|
130
|
+
* @returns {"created" | "updated"} Whether the file existed before.
|
|
131
|
+
*/
|
|
132
|
+
export function writeConfig(config) {
|
|
133
|
+
if (!config || typeof config !== "object") {
|
|
134
|
+
throw validationError("writeConfig: config object is required");
|
|
135
|
+
}
|
|
136
|
+
if (typeof config.apiKey !== "string" || !config.apiKey) {
|
|
137
|
+
throw validationError("writeConfig: apiKey is required");
|
|
138
|
+
}
|
|
139
|
+
if (typeof config.serverUrl !== "string" || !config.serverUrl) {
|
|
140
|
+
throw validationError("writeConfig: serverUrl is required");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const path = globalConfigPath();
|
|
144
|
+
const existed = existsSync(path);
|
|
145
|
+
const dir = dirname(path);
|
|
146
|
+
|
|
147
|
+
// Ensure the parent dir exists — the dir itself holds other
|
|
148
|
+
// CLI state (.last-sync, maybe more in the future), so creating
|
|
149
|
+
// it is expected.
|
|
150
|
+
try {
|
|
151
|
+
if (!existsSync(dir)) {
|
|
152
|
+
mkdirSync(dir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw diskError(`Cannot create ${dir}: ${err.message}`, { cause: err });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build the persisted shape. Preserve unknown fields from the
|
|
159
|
+
// existing file if any — a future CLI version might add fields
|
|
160
|
+
// that this writer doesn't know about.
|
|
161
|
+
let existing = {};
|
|
162
|
+
if (existed) {
|
|
163
|
+
const current = readConfig();
|
|
164
|
+
if (current) existing = current;
|
|
165
|
+
}
|
|
166
|
+
const merged = {
|
|
167
|
+
...existing,
|
|
168
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
169
|
+
apiKey: config.apiKey,
|
|
170
|
+
serverUrl: config.serverUrl,
|
|
171
|
+
writtenAt: new Date().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
// Copy optional fields if provided
|
|
174
|
+
for (const key of ["accountSlug", "accountId", "userId"]) {
|
|
175
|
+
if (config[key] !== undefined) merged[key] = config[key];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Atomic write via temp-file + rename. Matches the file-write.mjs
|
|
179
|
+
// pattern: the config file is never in a half-written state, so
|
|
180
|
+
// a crash mid-write can't leave it corrupted.
|
|
181
|
+
const tmpPath = `${path}.tmp`;
|
|
182
|
+
try {
|
|
183
|
+
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw diskError(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// chmod the temp file before renaming so the destination never
|
|
189
|
+
// exists with world-readable perms (which would be a brief
|
|
190
|
+
// credential leak window on a shared system).
|
|
191
|
+
if (platform() !== "win32") {
|
|
192
|
+
try {
|
|
193
|
+
chmodSync(tmpPath, 0o600);
|
|
194
|
+
} catch {
|
|
195
|
+
// Non-fatal — chmod failure doesn't corrupt the file, it just
|
|
196
|
+
// means permissions are looser than we'd like. We intentionally
|
|
197
|
+
// DON'T emit a warning here: the round-1 review caught that
|
|
198
|
+
// the warning used the tmp path (which doesn't exist after
|
|
199
|
+
// rename), and mixing stream output into a pure I/O helper
|
|
200
|
+
// leaks concerns across layers. Callers that care about
|
|
201
|
+
// permissions can stat the file themselves after the write.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
// renameSync on POSIX is atomic on the same filesystem
|
|
207
|
+
renameSync(tmpPath, path);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Round-1 review fix: clean up the stale .tmp file on rename
|
|
210
|
+
// failure so a live access key isn't left behind on disk. The
|
|
211
|
+
// unlink is best-effort — if IT also fails, the original
|
|
212
|
+
// rename error is still the one we surface to the caller.
|
|
213
|
+
try {
|
|
214
|
+
unlinkSync(tmpPath);
|
|
215
|
+
} catch {
|
|
216
|
+
/* best-effort */
|
|
217
|
+
}
|
|
218
|
+
throw diskError(`Cannot install ${path}: ${err.message}`, { cause: err });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return existed ? "updated" : "created";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Delete the global config file. Returns true if the file existed
|
|
226
|
+
* and was removed, false if it wasn't there. Used by tests and
|
|
227
|
+
* future `skillrepo logout` (not yet implemented).
|
|
228
|
+
*/
|
|
229
|
+
export function clearConfig() {
|
|
230
|
+
const path = globalConfigPath();
|
|
231
|
+
if (!existsSync(path)) return false;
|
|
232
|
+
try {
|
|
233
|
+
unlinkSync(path);
|
|
234
|
+
return true;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw diskError(`Cannot delete ${path}: ${err.message}`, { cause: err });
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/lib/detect-ides.mjs
CHANGED
|
@@ -42,22 +42,3 @@ export function formatDetectedIdes(detected) {
|
|
|
42
42
|
{ name: "VS Code + Copilot", key: "vscode", detected: detected.vscode },
|
|
43
43
|
];
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Get the list of IDE keys that were detected.
|
|
48
|
-
* If none detected, defaults to Claude Code + Cursor (most common pair).
|
|
49
|
-
* @param {DetectedIdes} detected
|
|
50
|
-
* @returns {string[]}
|
|
51
|
-
*/
|
|
52
|
-
export function getDetectedIdeKeys(detected) {
|
|
53
|
-
const keys = Object.entries(detected)
|
|
54
|
-
.filter(([, v]) => v)
|
|
55
|
-
.map(([k]) => k);
|
|
56
|
-
|
|
57
|
-
// Default to Claude Code + Cursor if nothing detected
|
|
58
|
-
if (keys.length === 0) {
|
|
59
|
-
return ["claudeCode", "cursor"];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return keys;
|
|
63
|
-
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillRepo CLI exit codes, error types, and retry helper.
|
|
3
|
+
*
|
|
4
|
+
* Exit code matrix:
|
|
5
|
+
*
|
|
6
|
+
* 0 — success
|
|
7
|
+
* 1 — network / transient error (cannot reach server, DNS failure, etc.)
|
|
8
|
+
* 2 — auth error (invalid key, revoked, suspended account)
|
|
9
|
+
* 3 — disk error (cannot read or write a file/directory)
|
|
10
|
+
* 4 — scope error (key lacks the required scope for the requested action)
|
|
11
|
+
* 5 — validation error (bad CLI input — invalid flag, malformed identifier, etc.)
|
|
12
|
+
*
|
|
13
|
+
* These mirror the documented behavior in #683 and are the contract
|
|
14
|
+
* shell users and CI scripts can rely on.
|
|
15
|
+
*
|
|
16
|
+
* Retry policy (PR4, #683):
|
|
17
|
+
*
|
|
18
|
+
* `withRetry(fn, options)` wraps a fetch-style operation and retries
|
|
19
|
+
* transient failures — specifically 429, 502, 503, 504 responses
|
|
20
|
+
* and raw networkErrors (DNS, TCP reset, timeout). 4xx auth/validation
|
|
21
|
+
* errors (401, 403, 404, 405, 409, 422, ...) are NOT retried — they
|
|
22
|
+
* will never succeed on a second attempt.
|
|
23
|
+
*
|
|
24
|
+
* Backoff: exponential with full jitter. Attempt N sleeps in
|
|
25
|
+
* [0, baseDelayMs * 2^(N-1)]. Default 3 attempts total with a base
|
|
26
|
+
* delay of 500ms and a cap of 8000ms per sleep. Deterministic in
|
|
27
|
+
* tests via the injected `sleepFn` and `randomFn`.
|
|
28
|
+
*
|
|
29
|
+
* The retry layer is OFF by default on `safeFetch` — it's opt-in
|
|
30
|
+
* per-call via `{ retry: true }` because some endpoints (e.g. the
|
|
31
|
+
* idempotent add post-fetch) require the exact single-shot semantics
|
|
32
|
+
* the tests were written against. Commands that naturally benefit
|
|
33
|
+
* from retries (update, get, list, search) opt in by passing the
|
|
34
|
+
* flag.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const EXIT_OK = 0;
|
|
38
|
+
export const EXIT_NETWORK = 1;
|
|
39
|
+
export const EXIT_AUTH = 2;
|
|
40
|
+
export const EXIT_DISK = 3;
|
|
41
|
+
export const EXIT_SCOPE = 4;
|
|
42
|
+
export const EXIT_VALIDATION = 5;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Base error class for typed CLI errors. Carries an exit code and
|
|
46
|
+
* optional cause/hint fields. Retry behavior is determined by
|
|
47
|
+
* `isRetryable()` inspecting the `exitCode` (EXIT_NETWORK is the
|
|
48
|
+
* only retryable class), not by subclass identity.
|
|
49
|
+
*/
|
|
50
|
+
export class CliError extends Error {
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} message - User-facing error message.
|
|
53
|
+
* @param {number} exitCode - One of the EXIT_* constants.
|
|
54
|
+
* @param {object} [options]
|
|
55
|
+
* @param {Error} [options.cause] - Underlying cause (for --verbose).
|
|
56
|
+
* @param {string} [options.hint] - Optional next-step hint shown after the message.
|
|
57
|
+
*/
|
|
58
|
+
constructor(message, exitCode, { cause, hint } = {}) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "CliError";
|
|
61
|
+
this.exitCode = exitCode;
|
|
62
|
+
if (cause !== undefined) this.cause = cause;
|
|
63
|
+
if (hint !== undefined) this.hint = hint;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Convenience constructors for each exit code class. Commands import
|
|
69
|
+
* these directly so call sites read like `throw networkError("...")`
|
|
70
|
+
* rather than `throw new CliError("...", EXIT_NETWORK)`.
|
|
71
|
+
*/
|
|
72
|
+
export function networkError(message, options) {
|
|
73
|
+
return new CliError(message, EXIT_NETWORK, options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function authError(message, options) {
|
|
77
|
+
return new CliError(message, EXIT_AUTH, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function diskError(message, options) {
|
|
81
|
+
return new CliError(message, EXIT_DISK, options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function scopeError(message, options) {
|
|
85
|
+
return new CliError(message, EXIT_SCOPE, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function validationError(message, options) {
|
|
89
|
+
return new CliError(message, EXIT_VALIDATION, options);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Retry helper ───────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* HTTP status codes that are considered transient. A response with
|
|
96
|
+
* one of these statuses will be retried by `withRetry` when the
|
|
97
|
+
* caller returns a structured `{ transient: true, statusCode }`
|
|
98
|
+
* signal. 502/503/504 are bad gateway / service unavailable /
|
|
99
|
+
* gateway timeout — nearly always transient. 429 is rate-limiting;
|
|
100
|
+
* retrying with backoff respects the documented behavior of the
|
|
101
|
+
* v1 API.
|
|
102
|
+
*
|
|
103
|
+
* 500 is NOT in this set: "internal server error" is a catch-all
|
|
104
|
+
* that typically represents a real bug, not a transient condition.
|
|
105
|
+
* Retrying masks issues that should surface loudly.
|
|
106
|
+
*/
|
|
107
|
+
export const TRANSIENT_STATUS_CODES = new Set([429, 502, 503, 504]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default backoff parameters. Exponential with full jitter.
|
|
111
|
+
* Attempt 1 fires immediately; attempt 2 sleeps in [0, 500ms);
|
|
112
|
+
* attempt 3 sleeps in [0, 1000ms). Capped at 8 seconds regardless
|
|
113
|
+
* of attempt number so pathological configurations don't stall
|
|
114
|
+
* the CLI for a minute.
|
|
115
|
+
*/
|
|
116
|
+
export const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
117
|
+
export const DEFAULT_RETRY_BASE_MS = 500;
|
|
118
|
+
export const DEFAULT_RETRY_CAP_MS = 8000;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decide whether a thrown error or a returned response is worth
|
|
122
|
+
* retrying.
|
|
123
|
+
*
|
|
124
|
+
* Retryable:
|
|
125
|
+
* - A `CliError` with `exitCode === EXIT_NETWORK` (networkError,
|
|
126
|
+
* which wraps fetch failures and timeouts)
|
|
127
|
+
* - A `Response`-shaped object whose `status` is in
|
|
128
|
+
* `TRANSIENT_STATUS_CODES`
|
|
129
|
+
*
|
|
130
|
+
* NOT retryable:
|
|
131
|
+
* - Any CliError with a different exit code — auth, scope,
|
|
132
|
+
* validation, disk errors never succeed on retry
|
|
133
|
+
* - Any non-transient response status (the caller's normal
|
|
134
|
+
* error-mapping path handles these)
|
|
135
|
+
*
|
|
136
|
+
* This is exported so tests can assert the contract directly, and
|
|
137
|
+
* so higher-level callers (e.g. commands that wrap multiple fetches
|
|
138
|
+
* in one retry scope) can share the predicate.
|
|
139
|
+
*/
|
|
140
|
+
export function isRetryable(value) {
|
|
141
|
+
if (value instanceof CliError) {
|
|
142
|
+
return value.exitCode === EXIT_NETWORK;
|
|
143
|
+
}
|
|
144
|
+
if (value && typeof value === "object" && typeof value.status === "number") {
|
|
145
|
+
return TRANSIENT_STATUS_CODES.has(value.status);
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Compute the sleep duration before attempt N (1-indexed). Uses
|
|
152
|
+
* full-jitter exponential backoff:
|
|
153
|
+
*
|
|
154
|
+
* delay = random() * min(cap, base * 2^(attempt - 2))
|
|
155
|
+
*
|
|
156
|
+
* Attempt 1 always returns 0 (fire immediately — no sleep before
|
|
157
|
+
* the first call). Attempt 2 samples in [0, base). Attempt 3
|
|
158
|
+
* samples in [0, base*2). Attempt 4 samples in [0, base*4). Any
|
|
159
|
+
* attempt whose window exceeds `capMs` is clamped to `capMs`.
|
|
160
|
+
*
|
|
161
|
+
* `randomFn` is injectable so tests can make the backoff
|
|
162
|
+
* deterministic (pass `() => 0.999999` for the worst case,
|
|
163
|
+
* `() => 0` for the best case).
|
|
164
|
+
*/
|
|
165
|
+
export function computeBackoffDelay(attempt, options = {}) {
|
|
166
|
+
const {
|
|
167
|
+
baseMs = DEFAULT_RETRY_BASE_MS,
|
|
168
|
+
capMs = DEFAULT_RETRY_CAP_MS,
|
|
169
|
+
randomFn = Math.random,
|
|
170
|
+
} = options;
|
|
171
|
+
if (attempt <= 1) return 0;
|
|
172
|
+
const window = Math.min(capMs, baseMs * Math.pow(2, attempt - 2));
|
|
173
|
+
return Math.floor(randomFn() * window);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Retry a function on transient failures. The function must either
|
|
178
|
+
* return a value (success) or throw / return a retryable signal
|
|
179
|
+
* (see `isRetryable`).
|
|
180
|
+
*
|
|
181
|
+
* Two retry triggers are supported:
|
|
182
|
+
*
|
|
183
|
+
* 1. `fn()` throws a networkError-class CliError → caught and
|
|
184
|
+
* retried. Any other thrown error propagates immediately.
|
|
185
|
+
*
|
|
186
|
+
* 2. `fn()` returns a `Response`-shaped object with a transient
|
|
187
|
+
* status. The caller is expected to return the response from
|
|
188
|
+
* its first `res.ok === false && TRANSIENT_STATUS_CODES.has(res.status)`
|
|
189
|
+
* branch and withRetry will re-invoke `fn` up to the attempt
|
|
190
|
+
* cap. On exhaustion, withRetry returns that last response so
|
|
191
|
+
* the caller can map it to a typed CliError via its normal
|
|
192
|
+
* mapErrorResponse path.
|
|
193
|
+
*
|
|
194
|
+
* This two-mode design lets `safeFetch` leave the status-to-CliError
|
|
195
|
+
* mapping in `mapErrorResponse` (where every endpoint uses it) while
|
|
196
|
+
* still participating in retry. The alternative — throwing a
|
|
197
|
+
* specially-typed error from safeFetch on 429 and catching it — would
|
|
198
|
+
* require every endpoint to special-case retry-exhausted errors
|
|
199
|
+
* instead of letting the existing error mapper do its job.
|
|
200
|
+
*
|
|
201
|
+
* @template T
|
|
202
|
+
* @param {() => Promise<T>} fn - The async operation to retry
|
|
203
|
+
* @param {object} [options]
|
|
204
|
+
* @param {number} [options.attempts=3] - Total attempts (1 + retries)
|
|
205
|
+
* @param {number} [options.baseMs=500]
|
|
206
|
+
* @param {number} [options.capMs=8000]
|
|
207
|
+
* @param {(ms: number) => Promise<void>} [options.sleepFn] - Injectable for tests
|
|
208
|
+
* @param {() => number} [options.randomFn] - Injectable jitter source
|
|
209
|
+
* @param {(info: {attempt: number, delayMs: number, cause: unknown}) => void} [options.onRetry]
|
|
210
|
+
* Optional callback fired BEFORE each retry — used by tests and
|
|
211
|
+
* by --verbose mode to surface retry activity.
|
|
212
|
+
* @returns {Promise<T>}
|
|
213
|
+
*/
|
|
214
|
+
export async function withRetry(fn, options = {}) {
|
|
215
|
+
const {
|
|
216
|
+
attempts = DEFAULT_RETRY_ATTEMPTS,
|
|
217
|
+
baseMs = DEFAULT_RETRY_BASE_MS,
|
|
218
|
+
capMs = DEFAULT_RETRY_CAP_MS,
|
|
219
|
+
sleepFn = defaultSleep,
|
|
220
|
+
randomFn = Math.random,
|
|
221
|
+
onRetry,
|
|
222
|
+
} = options;
|
|
223
|
+
|
|
224
|
+
if (!Number.isInteger(attempts) || attempts < 1) {
|
|
225
|
+
throw validationError(`withRetry: attempts must be a positive integer, got ${attempts}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let lastValue;
|
|
229
|
+
let lastError;
|
|
230
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
231
|
+
try {
|
|
232
|
+
const result = await fn();
|
|
233
|
+
// Response-shaped transient: retry if we have attempts left,
|
|
234
|
+
// otherwise return the last response so the caller maps it.
|
|
235
|
+
if (isRetryable(result)) {
|
|
236
|
+
lastValue = result;
|
|
237
|
+
if (attempt < attempts) {
|
|
238
|
+
const delay = computeBackoffDelay(attempt + 1, { baseMs, capMs, randomFn });
|
|
239
|
+
onRetry?.({ attempt, delayMs: delay, cause: result });
|
|
240
|
+
await sleepFn(delay);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (!isRetryable(err)) throw err;
|
|
248
|
+
lastError = err;
|
|
249
|
+
if (attempt >= attempts) throw err;
|
|
250
|
+
const delay = computeBackoffDelay(attempt + 1, { baseMs, capMs, randomFn });
|
|
251
|
+
onRetry?.({ attempt, delayMs: delay, cause: err });
|
|
252
|
+
await sleepFn(delay);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Unreachable — the loop either returns or throws. Defensive:
|
|
257
|
+
if (lastError) throw lastError;
|
|
258
|
+
return lastValue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function defaultSleep(ms) {
|
|
262
|
+
if (ms <= 0) return Promise.resolve();
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|