skillrepo 2.0.0 → 3.0.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.
Files changed (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -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
+ }