skillrepo 4.8.0 → 4.8.2
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/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- package/src/test/resolve-key.test.mjs +33 -0
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
EXIT_VALIDATION,
|
|
23
23
|
EXIT_AUTH,
|
|
24
24
|
EXIT_SCOPE,
|
|
25
|
+
EXIT_NETWORK,
|
|
25
26
|
} from "../../lib/errors.mjs";
|
|
26
27
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
27
28
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
@@ -191,11 +192,11 @@ describe("runPublish — error paths", () => {
|
|
|
191
192
|
});
|
|
192
193
|
});
|
|
193
194
|
|
|
194
|
-
it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError),
|
|
195
|
+
it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), no CLI-side hint", async () => {
|
|
195
196
|
// Real-world regression scenario: an account over its skill
|
|
196
197
|
// quota tries to publish. Server returns 403 with code
|
|
197
198
|
// `plan_limit`. The CLI MUST classify this as exit 5
|
|
198
|
-
// validation with the
|
|
199
|
+
// validation with the self-contained server message, NOT exit 2 authError
|
|
199
200
|
// ("contact support") — a pre-review version of the code
|
|
200
201
|
// consumed the 403 body and let mapErrorResponse re-parse, but
|
|
201
202
|
// the body was empty by then so plan_limit fell through to
|
|
@@ -210,7 +211,7 @@ describe("runPublish — error paths", () => {
|
|
|
210
211
|
await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
|
|
211
212
|
assert.ok(err instanceof CliError);
|
|
212
213
|
assert.equal(err.exitCode, EXIT_VALIDATION);
|
|
213
|
-
assert.
|
|
214
|
+
assert.equal(err.hint, undefined); // server plan_limit message is self-contained (#2239)
|
|
214
215
|
return true;
|
|
215
216
|
});
|
|
216
217
|
});
|
|
@@ -276,6 +277,66 @@ describe("runPublish — error paths", () => {
|
|
|
276
277
|
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
277
278
|
);
|
|
278
279
|
});
|
|
280
|
+
|
|
281
|
+
it("extra positional argument → CliError EXIT_VALIDATION 'Unexpected extra argument'", async () => {
|
|
282
|
+
// The arg parser accepts exactly ONE positional (the identifier).
|
|
283
|
+
// A second positional must be rejected before any HTTP call —
|
|
284
|
+
// `publish` takes a single @owner/name. The identifier comes
|
|
285
|
+
// first so the parser sees the SECOND token as the surplus arg.
|
|
286
|
+
await assert.rejects(
|
|
287
|
+
runPublish(
|
|
288
|
+
[`@alice/my-skill`, `@alice/other`, "--key", VALID_KEY, "--url", serverUrl],
|
|
289
|
+
{ stdout },
|
|
290
|
+
),
|
|
291
|
+
(err) =>
|
|
292
|
+
err instanceof CliError &&
|
|
293
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
294
|
+
/Unexpected extra argument/.test(err.message),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("generic 403 with a `scope` code (not publish_not_permitted/plan_limit) → CliError EXIT_SCOPE", async () => {
|
|
299
|
+
// setSkillVisibility's inline 403 dispatch (http.mjs ~1254) mirrors
|
|
300
|
+
// mapErrorResponse: any code containing "scope" maps to scopeError
|
|
301
|
+
// (exit 4), NOT authError (exit 2). This is the documented contract
|
|
302
|
+
// — assert it directly so a regression to authError is caught. The
|
|
303
|
+
// code here ("insufficient_scope") is neither publish_not_permitted
|
|
304
|
+
// nor plan_limit, so it takes the generic-scope branch.
|
|
305
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
306
|
+
status: 403,
|
|
307
|
+
body: {
|
|
308
|
+
error: "Your access key lacks the registry:write scope.",
|
|
309
|
+
code: "insufficient_scope",
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
|
|
313
|
+
assert.ok(err instanceof CliError);
|
|
314
|
+
assert.equal(err.exitCode, EXIT_SCOPE);
|
|
315
|
+
assert.match(err.hint ?? "", /write-scoped key/);
|
|
316
|
+
return true;
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("200 with an unexpected `action` value → networkError EXIT_NETWORK", async () => {
|
|
321
|
+
// A 200 whose `action` is none of published/unpublished/unchanged
|
|
322
|
+
// is a protocol violation — the server promised a known
|
|
323
|
+
// discriminant. setSkillVisibility (http.mjs ~1212) throws a
|
|
324
|
+
// networkError naming the bogus action. Assert exit 1 and that the
|
|
325
|
+
// message surfaces the unexpected value, not a silent success.
|
|
326
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
327
|
+
status: 200,
|
|
328
|
+
body: {
|
|
329
|
+
action: "teleported",
|
|
330
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
|
|
334
|
+
assert.ok(err instanceof CliError);
|
|
335
|
+
assert.equal(err.exitCode, EXIT_NETWORK);
|
|
336
|
+
assert.match(err.message, /unexpected action "teleported"/);
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
});
|
|
279
340
|
});
|
|
280
341
|
|
|
281
342
|
// ── runUnpublish ────────────────────────────────────────────────────────
|
|
@@ -417,4 +478,76 @@ describe("runUnpublish — error paths", () => {
|
|
|
417
478
|
/Missing skill identifier/.test(err.message),
|
|
418
479
|
);
|
|
419
480
|
});
|
|
481
|
+
|
|
482
|
+
it("401 unauthorized → CliError EXIT_AUTH", async () => {
|
|
483
|
+
// Mirror the publish-side 401 vector. A 401 on unpublish flows
|
|
484
|
+
// through mapErrorResponse (http.mjs ~305) → authError → exit 2.
|
|
485
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
486
|
+
status: 401,
|
|
487
|
+
body: { error: "Invalid access key" },
|
|
488
|
+
});
|
|
489
|
+
await assert.rejects(
|
|
490
|
+
runUnpublish(baseArgv(), { stdout }),
|
|
491
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("403 unpublish_not_permitted → CliError EXIT_SCOPE (code confirmed)", async () => {
|
|
496
|
+
// The unpublish 403 endpoint-permission code is
|
|
497
|
+
// `unpublish_not_permitted` (http.mjs ~1241, VisibilityResultForbidden
|
|
498
|
+
// typedef ~1145). It maps to the `forbidden` outcome → scopeError
|
|
499
|
+
// → exit 4. Asserts the exit-code mapping for the bare code without
|
|
500
|
+
// the hint-dedup detail covered by the action-only-hint test above.
|
|
501
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
502
|
+
status: 403,
|
|
503
|
+
body: {
|
|
504
|
+
error: "You do not have permission to unpublish this skill.",
|
|
505
|
+
code: "unpublish_not_permitted",
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
await assert.rejects(
|
|
509
|
+
runUnpublish(baseArgv(), { stdout }),
|
|
510
|
+
(err) =>
|
|
511
|
+
err instanceof CliError &&
|
|
512
|
+
err.exitCode === EXIT_SCOPE &&
|
|
513
|
+
/canPublish/.test(err.hint ?? ""),
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), no CLI-side hint", async () => {
|
|
518
|
+
// Same shared `setSkillVisibility` 403 dispatch as publish: a
|
|
519
|
+
// plan_limit 403 must classify as exit 5 validation with the
|
|
520
|
+
// self-contained server message, never exit 2 authError. Mirrors the publish-side
|
|
521
|
+
// regression guard for the unpublish verb.
|
|
522
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
523
|
+
status: 403,
|
|
524
|
+
body: {
|
|
525
|
+
error: "You are over your library skill quota.",
|
|
526
|
+
code: "plan_limit",
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
await assert.rejects(runUnpublish(baseArgv(), { stdout }), (err) => {
|
|
530
|
+
assert.ok(err instanceof CliError);
|
|
531
|
+
assert.equal(err.exitCode, EXIT_VALIDATION);
|
|
532
|
+
assert.equal(err.hint, undefined); // server plan_limit message is self-contained (#2239)
|
|
533
|
+
return true;
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("extra positional argument → CliError EXIT_VALIDATION 'Unexpected extra argument'", async () => {
|
|
538
|
+
// `unpublish` takes a single @owner/name. A second positional is
|
|
539
|
+
// rejected by the arg parser before any HTTP call (unpublish.mjs
|
|
540
|
+
// acceptPositional, ~49). The identifier comes first; the SECOND
|
|
541
|
+
// token is the surplus arg.
|
|
542
|
+
await assert.rejects(
|
|
543
|
+
runUnpublish(
|
|
544
|
+
[`@alice/my-skill`, `@alice/other`, "--key", VALID_KEY, "--url", serverUrl],
|
|
545
|
+
{ stdout },
|
|
546
|
+
),
|
|
547
|
+
(err) =>
|
|
548
|
+
err instanceof CliError &&
|
|
549
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
550
|
+
/Unexpected extra argument/.test(err.message),
|
|
551
|
+
);
|
|
552
|
+
});
|
|
420
553
|
});
|
|
@@ -18,7 +18,12 @@ import { join } from "node:path";
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
|
|
20
20
|
import { runPush } from "../../commands/push.mjs";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
CliError,
|
|
23
|
+
EXIT_AUTH,
|
|
24
|
+
EXIT_SCOPE,
|
|
25
|
+
EXIT_VALIDATION,
|
|
26
|
+
} from "../../lib/errors.mjs";
|
|
22
27
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
23
28
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
24
29
|
import {
|
|
@@ -142,6 +147,52 @@ describe("runPush — happy paths", () => {
|
|
|
142
147
|
assert.match(stdout.text(), /Released @alice\/my-skill v1\.1.*minor bump/);
|
|
143
148
|
});
|
|
144
149
|
|
|
150
|
+
it("--json falls back to frontmatter name + null owner/version when server omits `skill`", async () => {
|
|
151
|
+
// The server can return a documented action with a null/absent
|
|
152
|
+
// `skill` (e.g. a minimal 201). The JSON reporter must degrade
|
|
153
|
+
// gracefully: owner/version → null, name → the SKILL.md frontmatter
|
|
154
|
+
// name. This covers the `result.skill?.owner ?? null`,
|
|
155
|
+
// `?? frontmatter.name`, and `?? null` branches in --json mode.
|
|
156
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
157
|
+
server.setPushResponse({
|
|
158
|
+
status: 201,
|
|
159
|
+
body: { action: "created", bump: null, skill: null },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await runPush(
|
|
163
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json", "skill"],
|
|
164
|
+
{ stdout },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const json = JSON.parse(stdout.text());
|
|
168
|
+
assert.equal(json.action, "created");
|
|
169
|
+
assert.equal(json.owner, null, "owner falls back to null when skill is absent");
|
|
170
|
+
assert.equal(json.name, "my-skill", "name falls back to the frontmatter name");
|
|
171
|
+
assert.equal(json.version, null, "version falls back to null");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("human output falls back to @?/<frontmatter-name> and v1.0 when server omits `skill` (created)", async () => {
|
|
175
|
+
// Mirror of the --json fallback test for the human report path:
|
|
176
|
+
// `@${owner ?? "?"}/${name ?? frontmatter.name}` and the created
|
|
177
|
+
// branch's `version ?? "1.0"`.
|
|
178
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
179
|
+
server.setPushResponse({
|
|
180
|
+
status: 201,
|
|
181
|
+
body: { action: "created", bump: null, skill: null },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await runPush(
|
|
185
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
186
|
+
{ stdout },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
assert.match(
|
|
190
|
+
stdout.text(),
|
|
191
|
+
/Created @\?\/my-skill v1\.0 \(1 file\)/,
|
|
192
|
+
"human report uses @?/<frontmatter-name> and the v1.0 version fallback",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
145
196
|
it("reports 'no changes' when server returns action=unchanged", async () => {
|
|
146
197
|
file("SKILL.md", VALID_SKILL_MD);
|
|
147
198
|
server.setPushResponse({
|
|
@@ -286,4 +337,232 @@ describe("runPush — flags", () => {
|
|
|
286
337
|
// Default server response is 201 created.
|
|
287
338
|
assert.match(stdout.text(), /Created @mock\/test-skill/);
|
|
288
339
|
});
|
|
340
|
+
|
|
341
|
+
it("rejects a second positional argument with EXIT_VALIDATION", async () => {
|
|
342
|
+
// `resolveFlags`' acceptPositional callback claims the first bare
|
|
343
|
+
// arg as the skill path; a second bare arg has nowhere to go and
|
|
344
|
+
// must fail loudly rather than be silently dropped. The dispatch
|
|
345
|
+
// happens during argv parsing — before any directory stat or HTTP
|
|
346
|
+
// — so no SKILL.md fixture is required.
|
|
347
|
+
await assert.rejects(
|
|
348
|
+
runPush(
|
|
349
|
+
["--key", VALID_KEY, "--url", serverUrl, "./dir", "extra"],
|
|
350
|
+
{ stdout },
|
|
351
|
+
),
|
|
352
|
+
(err) =>
|
|
353
|
+
err instanceof CliError &&
|
|
354
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
355
|
+
/Unexpected extra argument/.test(err.message),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── runPush — server-side error mapping (through the command) ──────────
|
|
361
|
+
//
|
|
362
|
+
// Every case below configures the mock's POST /api/v1/library response
|
|
363
|
+
// via `setPushResponse` and drives the FULL command path
|
|
364
|
+
// (`runPush` → `pushSkill` → `mapErrorResponse`). The assertions pin the
|
|
365
|
+
// exit code each documented server outcome MUST surface, per the
|
|
366
|
+
// `mapErrorResponse` 401/403/404/429/5xx taxonomy in `src/lib/http.mjs`
|
|
367
|
+
// and the exit-code matrix in `src/lib/errors.mjs`. These mirror the
|
|
368
|
+
// `pushSkill` unit tests in `src/test/lib/http.test.mjs` but exercise the
|
|
369
|
+
// command wrapper rather than the raw lib function.
|
|
370
|
+
|
|
371
|
+
describe("runPush — server error mapping", () => {
|
|
372
|
+
beforeEach(setup);
|
|
373
|
+
afterEach(teardown);
|
|
374
|
+
|
|
375
|
+
it("maps server 401 to EXIT_AUTH", async () => {
|
|
376
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
377
|
+
server.setPushResponse({
|
|
378
|
+
status: 401,
|
|
379
|
+
body: { error: "Invalid access key", code: "unauthorized" },
|
|
380
|
+
});
|
|
381
|
+
await assert.rejects(
|
|
382
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
383
|
+
(err) =>
|
|
384
|
+
err instanceof CliError &&
|
|
385
|
+
err.exitCode === EXIT_AUTH &&
|
|
386
|
+
/Invalid access key/.test(err.message),
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("maps server 403 scope to EXIT_SCOPE", async () => {
|
|
391
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
392
|
+
server.setPushResponse({
|
|
393
|
+
status: 403,
|
|
394
|
+
body: { error: "Write scope required", code: "scope_required" },
|
|
395
|
+
});
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
398
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("maps server 403 plan_limit to EXIT_VALIDATION (NOT auth)", async () => {
|
|
403
|
+
// mapErrorResponse explicitly disambiguates plan_limit from the
|
|
404
|
+
// generic-403 authError path so the user sees the plan-limit message, not
|
|
405
|
+
// a "contact support" hint. Mirrors the http.test.mjs assertion.
|
|
406
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
407
|
+
server.setPushResponse({
|
|
408
|
+
status: 403,
|
|
409
|
+
body: {
|
|
410
|
+
error: "Your free plan allows up to 5 library skills.",
|
|
411
|
+
code: "plan_limit",
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
await assert.rejects(
|
|
415
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
416
|
+
(err) =>
|
|
417
|
+
err instanceof CliError &&
|
|
418
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
419
|
+
/plan/i.test(err.message),
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("maps server 409 source_conflict to EXIT_VALIDATION (generic 4xx)", async () => {
|
|
424
|
+
// 409 has no dedicated branch in mapErrorResponse — it falls
|
|
425
|
+
// through to the generic-4xx `validationError(message)` return.
|
|
426
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
427
|
+
server.setPushResponse({
|
|
428
|
+
status: 409,
|
|
429
|
+
body: {
|
|
430
|
+
error: "A skill with this name is owned by another source.",
|
|
431
|
+
code: "source_conflict",
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
await assert.rejects(
|
|
435
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
436
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("maps server 422 idempotency_key_reused to EXIT_VALIDATION", async () => {
|
|
441
|
+
// 422 has no dedicated branch either — generic-4xx → validationError.
|
|
442
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
443
|
+
server.setPushResponse({
|
|
444
|
+
status: 422,
|
|
445
|
+
body: {
|
|
446
|
+
error: "Idempotency key was reused with a different request body.",
|
|
447
|
+
code: "idempotency_key_reused",
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
await assert.rejects(
|
|
451
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
452
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("maps server 413 payload_too_large to EXIT_VALIDATION (generic 4xx)", async () => {
|
|
457
|
+
// 413 has NO explicit branch in mapErrorResponse (only 401/403/404/
|
|
458
|
+
// 429/5xx are special-cased). It therefore falls through to the
|
|
459
|
+
// generic-4xx `return validationError(message)` → exit 5. This pins
|
|
460
|
+
// the ACTUAL produced code; if the source ever grows a dedicated
|
|
461
|
+
// 413 branch with a different exit code, this fails and forces a
|
|
462
|
+
// conscious update.
|
|
463
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
464
|
+
server.setPushResponse({
|
|
465
|
+
status: 413,
|
|
466
|
+
body: { error: "Payload exceeds the 4.5MB limit.", code: "payload_too_large" },
|
|
467
|
+
});
|
|
468
|
+
await assert.rejects(
|
|
469
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
470
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ── runPush — multipart wire format ────────────────────────────────────
|
|
476
|
+
//
|
|
477
|
+
// The mock server doesn't parse multipart; `getLastPostBody()` returns
|
|
478
|
+
// the RAW request body. For a multipart push the body is the
|
|
479
|
+
// `multipart/form-data` byte stream (undici serializes the FormData),
|
|
480
|
+
// which JSON.parse can't handle, so the mock stores it verbatim as a
|
|
481
|
+
// string. We assert the wire format directly against that raw string:
|
|
482
|
+
// a part for SKILL.md, a part whose Content-Disposition filename carries
|
|
483
|
+
// the nested POSIX relative path (RFC 7578), and that the client sent a
|
|
484
|
+
// multipart/form-data Content-Type.
|
|
485
|
+
|
|
486
|
+
describe("runPush — multipart wire format", () => {
|
|
487
|
+
beforeEach(setup);
|
|
488
|
+
afterEach(teardown);
|
|
489
|
+
|
|
490
|
+
it("encodes SKILL.md + nested file with RFC 7578 filename paths", async () => {
|
|
491
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
492
|
+
file("references/intro.md", "intro body");
|
|
493
|
+
|
|
494
|
+
await runPush(
|
|
495
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
496
|
+
{ stdout },
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// Sanity: the push succeeded (default 201 created response).
|
|
500
|
+
assert.match(stdout.text(), /Created @mock\/test-skill/);
|
|
501
|
+
|
|
502
|
+
const rawBody = server.getLastPostBody();
|
|
503
|
+
assert.equal(
|
|
504
|
+
typeof rawBody,
|
|
505
|
+
"string",
|
|
506
|
+
"multipart body should be captured as a raw string (JSON.parse fails on multipart)",
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Every part is appended under the `files` field name.
|
|
510
|
+
assert.match(
|
|
511
|
+
rawBody,
|
|
512
|
+
/Content-Disposition: form-data; name="files"/,
|
|
513
|
+
"expected a multipart part for the `files` field",
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
// The root SKILL.md must carry filename="SKILL.md" (no path prefix);
|
|
517
|
+
// the server identifies SKILL.md by exact-match filename.
|
|
518
|
+
assert.match(
|
|
519
|
+
rawBody,
|
|
520
|
+
/filename="SKILL\.md"/,
|
|
521
|
+
"expected a part whose Content-Disposition filename is exactly SKILL.md",
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// The nested file must preserve its POSIX skill-relative path in the
|
|
525
|
+
// filename parameter (RFC 7578) so the server reconstructs the tree.
|
|
526
|
+
assert.match(
|
|
527
|
+
rawBody,
|
|
528
|
+
/filename="references\/intro\.md"/,
|
|
529
|
+
"expected the nested file's filename to carry the relative path references/intro.md",
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
// The raw bytes of the nested file must be in the body.
|
|
533
|
+
assert.ok(
|
|
534
|
+
rawBody.includes("intro body"),
|
|
535
|
+
"expected the nested file content to be present in the multipart body",
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("sends a multipart/form-data Content-Type (server accepts; boundary present)", async () => {
|
|
540
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
541
|
+
file("references/intro.md", "intro body");
|
|
542
|
+
|
|
543
|
+
// The mock's POST /api/v1/library handler returns 400
|
|
544
|
+
// invalid_multipart UNLESS the inbound Content-Type includes
|
|
545
|
+
// `multipart/form-data` (see mock-server.mjs). A successful 201 is
|
|
546
|
+
// therefore positive proof the client set that Content-Type — if
|
|
547
|
+
// pushSkill had set Content-Type to JSON (or omitted the boundary so
|
|
548
|
+
// undici couldn't infer multipart), this would surface as a
|
|
549
|
+
// validationError, not a "Created" success.
|
|
550
|
+
await runPush(
|
|
551
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
552
|
+
{ stdout },
|
|
553
|
+
);
|
|
554
|
+
assert.match(stdout.text(), /Created @mock\/test-skill/);
|
|
555
|
+
|
|
556
|
+
// Belt-and-suspenders at the wire level: undici emits a boundary
|
|
557
|
+
// delimiter (`--<boundary>`) before each part, which proves the body
|
|
558
|
+
// is genuinely multipart-encoded and not, say, a raw JSON blob the
|
|
559
|
+
// server happened to accept.
|
|
560
|
+
const rawBody = server.getLastPostBody();
|
|
561
|
+
assert.equal(typeof rawBody, "string");
|
|
562
|
+
assert.match(
|
|
563
|
+
rawBody,
|
|
564
|
+
/^--[-A-Za-z0-9]+\r?\n/m,
|
|
565
|
+
"expected a multipart boundary delimiter at the start of a part",
|
|
566
|
+
);
|
|
567
|
+
});
|
|
289
568
|
});
|