skillrepo 4.1.0 → 4.2.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/bin/skillrepo.mjs +8 -0
- package/package.json +10 -4
- package/src/commands/push.mjs +187 -0
- package/src/lib/http.mjs +169 -11
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/mock-server.mjs +92 -10
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit / integration tests for `src/commands/push.mjs` (#1455).
|
|
3
|
+
*
|
|
4
|
+
* Runs against the mock server which exposes `POST /api/v1/library`
|
|
5
|
+
* (multipart upsert). The mock doesn't actually parse multipart — it
|
|
6
|
+
* just checks the Content-Type and serves either a configured response
|
|
7
|
+
* (via `setPushResponse`) or a default 201 LibraryPushResponse.
|
|
8
|
+
*
|
|
9
|
+
* Coverage: happy paths (created / updated / unchanged), JSON output,
|
|
10
|
+
* missing-path / missing-SKILL.md errors, invalid frontmatter,
|
|
11
|
+
* idempotency-key flag.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
import { runPush } from "../../commands/push.mjs";
|
|
21
|
+
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
22
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
23
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
24
|
+
import {
|
|
25
|
+
captureHome,
|
|
26
|
+
setSandboxHome,
|
|
27
|
+
restoreHome,
|
|
28
|
+
} from "../helpers/sandbox-home.mjs";
|
|
29
|
+
|
|
30
|
+
let sandbox;
|
|
31
|
+
let server;
|
|
32
|
+
let serverUrl;
|
|
33
|
+
let originalCwd;
|
|
34
|
+
let originalHomeEnv;
|
|
35
|
+
let stdout;
|
|
36
|
+
const VALID_KEY = "sk_live_test";
|
|
37
|
+
|
|
38
|
+
const VALID_SKILL_MD = `---
|
|
39
|
+
name: my-skill
|
|
40
|
+
description: A skill exercising the push command
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
# my-skill
|
|
44
|
+
|
|
45
|
+
body
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
function file(rel, content = "x") {
|
|
49
|
+
const abs = join(sandbox, "skill", rel);
|
|
50
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
51
|
+
writeFileSync(abs, content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function setup() {
|
|
55
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-push-"));
|
|
56
|
+
mkdirSync(join(sandbox, "skill"), { recursive: true });
|
|
57
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
58
|
+
originalCwd = process.cwd();
|
|
59
|
+
originalHomeEnv = captureHome();
|
|
60
|
+
process.chdir(sandbox);
|
|
61
|
+
setSandboxHome(join(sandbox, "home"));
|
|
62
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
63
|
+
|
|
64
|
+
server = createMockServer({});
|
|
65
|
+
const port = await server.start();
|
|
66
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
67
|
+
|
|
68
|
+
stdout = createCaptureStream();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function teardown() {
|
|
72
|
+
if (server) await server.stop();
|
|
73
|
+
process.chdir(originalCwd);
|
|
74
|
+
restoreHome(originalHomeEnv);
|
|
75
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
76
|
+
server = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("runPush — happy paths", () => {
|
|
80
|
+
beforeEach(setup);
|
|
81
|
+
afterEach(teardown);
|
|
82
|
+
|
|
83
|
+
it("pushes a fresh skill (default response: 201 created)", async () => {
|
|
84
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
85
|
+
file("references/intro.md", "intro");
|
|
86
|
+
|
|
87
|
+
await runPush(
|
|
88
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
89
|
+
{ stdout },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
assert.match(stdout.text(), /Created @mock\/test-skill/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("--json prints action / bump / owner / name / version / filesUploaded", async () => {
|
|
96
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
97
|
+
file("references/intro.md", "intro");
|
|
98
|
+
|
|
99
|
+
await runPush(
|
|
100
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json", "skill"],
|
|
101
|
+
{ stdout },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const json = JSON.parse(stdout.text());
|
|
105
|
+
assert.equal(json.action, "created");
|
|
106
|
+
assert.equal(json.owner, "mock");
|
|
107
|
+
assert.equal(json.name, "test-skill");
|
|
108
|
+
assert.equal(json.version, "1.0");
|
|
109
|
+
// SKILL.md + 1 supporting file = 2 (the walker includes SKILL.md as
|
|
110
|
+
// a regular file per the agentskills.io spec).
|
|
111
|
+
assert.equal(json.filesUploaded, 2);
|
|
112
|
+
assert.equal(json.bump, null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("reports 'updated' with bump when server returns action=updated", async () => {
|
|
116
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
117
|
+
server.setPushResponse({
|
|
118
|
+
status: 200,
|
|
119
|
+
body: {
|
|
120
|
+
action: "updated",
|
|
121
|
+
bump: "minor",
|
|
122
|
+
skill: {
|
|
123
|
+
owner: "alice",
|
|
124
|
+
name: "my-skill",
|
|
125
|
+
version: "1.1",
|
|
126
|
+
description: "A skill exercising the push command",
|
|
127
|
+
keywords: [],
|
|
128
|
+
updatedAt: new Date().toISOString(),
|
|
129
|
+
etag: '"alice/my-skill@0"',
|
|
130
|
+
contextSignals: null,
|
|
131
|
+
files: [],
|
|
132
|
+
filesIncomplete: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await runPush(
|
|
138
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
139
|
+
{ stdout },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
assert.match(stdout.text(), /Released @alice\/my-skill v1\.1.*minor bump/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("reports 'no changes' when server returns action=unchanged", async () => {
|
|
146
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
147
|
+
server.setPushResponse({
|
|
148
|
+
status: 200,
|
|
149
|
+
body: {
|
|
150
|
+
action: "unchanged",
|
|
151
|
+
bump: null,
|
|
152
|
+
skill: {
|
|
153
|
+
owner: "alice",
|
|
154
|
+
name: "my-skill",
|
|
155
|
+
version: "1.0",
|
|
156
|
+
description: "A skill exercising the push command",
|
|
157
|
+
keywords: [],
|
|
158
|
+
updatedAt: new Date().toISOString(),
|
|
159
|
+
etag: '"alice/my-skill@0"',
|
|
160
|
+
contextSignals: null,
|
|
161
|
+
files: [],
|
|
162
|
+
filesIncomplete: false,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await runPush(
|
|
168
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
169
|
+
{ stdout },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
assert.match(stdout.text(), /No changes — @alice\/my-skill is already at v1\.0/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("runPush — input validation", () => {
|
|
177
|
+
beforeEach(setup);
|
|
178
|
+
afterEach(teardown);
|
|
179
|
+
|
|
180
|
+
it("errors when no path is provided", async () => {
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
|
|
183
|
+
(err) =>
|
|
184
|
+
err instanceof CliError &&
|
|
185
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
186
|
+
/Missing skill directory path/.test(err.message),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("errors when the path doesn't exist", async () => {
|
|
191
|
+
await assert.rejects(
|
|
192
|
+
runPush(
|
|
193
|
+
["--key", VALID_KEY, "--url", serverUrl, "./does-not-exist"],
|
|
194
|
+
{ stdout },
|
|
195
|
+
),
|
|
196
|
+
(err) =>
|
|
197
|
+
err instanceof CliError &&
|
|
198
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
199
|
+
/Path not found/.test(err.message),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("errors when the path is a file, not a directory", async () => {
|
|
204
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
205
|
+
await assert.rejects(
|
|
206
|
+
runPush(
|
|
207
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill/SKILL.md"],
|
|
208
|
+
{ stdout },
|
|
209
|
+
),
|
|
210
|
+
(err) =>
|
|
211
|
+
err instanceof CliError &&
|
|
212
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
213
|
+
/Not a directory/.test(err.message),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("errors when SKILL.md is missing", async () => {
|
|
218
|
+
// Empty `skill/` directory — no SKILL.md.
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
runPush(
|
|
221
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
222
|
+
{ stdout },
|
|
223
|
+
),
|
|
224
|
+
(err) =>
|
|
225
|
+
err instanceof CliError &&
|
|
226
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
227
|
+
/No SKILL\.md at skill\/SKILL\.md/.test(err.message),
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("errors when SKILL.md frontmatter is missing the `name` field", async () => {
|
|
232
|
+
file("SKILL.md", "---\ndescription: only description\n---\n\nbody\n");
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
runPush(
|
|
235
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
236
|
+
{ stdout },
|
|
237
|
+
),
|
|
238
|
+
(err) =>
|
|
239
|
+
err instanceof CliError &&
|
|
240
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
241
|
+
/SKILL\.md is missing the required `name` field/.test(err.message),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("errors when SKILL.md frontmatter is malformed YAML", async () => {
|
|
246
|
+
file("SKILL.md", "---\nname: [unclosed\n---\n");
|
|
247
|
+
await assert.rejects(
|
|
248
|
+
runPush(
|
|
249
|
+
["--key", VALID_KEY, "--url", serverUrl, "skill"],
|
|
250
|
+
{ stdout },
|
|
251
|
+
),
|
|
252
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("runPush — flags", () => {
|
|
258
|
+
beforeEach(setup);
|
|
259
|
+
afterEach(teardown);
|
|
260
|
+
|
|
261
|
+
it("--idempotency-key requires a value", async () => {
|
|
262
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
263
|
+
await assert.rejects(
|
|
264
|
+
runPush(
|
|
265
|
+
["--key", VALID_KEY, "--url", serverUrl, "--idempotency-key"],
|
|
266
|
+
{ stdout },
|
|
267
|
+
),
|
|
268
|
+
(err) => /Missing value for --idempotency-key/.test(err.message),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("accepts --idempotency-key + path together", async () => {
|
|
273
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
274
|
+
await runPush(
|
|
275
|
+
[
|
|
276
|
+
"--key",
|
|
277
|
+
VALID_KEY,
|
|
278
|
+
"--url",
|
|
279
|
+
serverUrl,
|
|
280
|
+
"--idempotency-key",
|
|
281
|
+
"test-key-123",
|
|
282
|
+
"skill",
|
|
283
|
+
],
|
|
284
|
+
{ stdout },
|
|
285
|
+
);
|
|
286
|
+
// Default server response is 201 created.
|
|
287
|
+
assert.match(stdout.text(), /Created @mock\/test-skill/);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -71,7 +71,7 @@ describe("dispatcher — top-level help", () => {
|
|
|
71
71
|
assert.equal(r.status, 0);
|
|
72
72
|
assert.match(r.stdout, /SkillRepo CLI/);
|
|
73
73
|
// All 7 commands listed
|
|
74
|
-
for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
|
|
74
|
+
for (const cmd of ["init", "update", "get", "add", "push", "remove", "list", "search"]) {
|
|
75
75
|
assert.match(r.stdout, new RegExp(`\\b${cmd}\\b`), `expected to see "${cmd}" in help`);
|
|
76
76
|
}
|
|
77
77
|
});
|
|
@@ -123,7 +123,7 @@ describe("dispatcher — unknown command", () => {
|
|
|
123
123
|
describe("dispatcher — per-command help", () => {
|
|
124
124
|
// PR1 only ships init for real; the other 6 are stubs but still
|
|
125
125
|
// route --help correctly.
|
|
126
|
-
for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
|
|
126
|
+
for (const cmd of ["init", "update", "get", "add", "push", "remove", "list", "search"]) {
|
|
127
127
|
it(`\`skillrepo ${cmd} --help\` prints command-specific help`, async () => {
|
|
128
128
|
const r = await runCli([cmd, "--help"]);
|
|
129
129
|
assert.equal(r.status, 0);
|
|
@@ -194,6 +194,14 @@ describe("dispatcher — implemented commands route to their modules", () => {
|
|
|
194
194
|
assert.ok([1, 2, 5].includes(r.status));
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it("`skillrepo push` is wired to the real module (not a stub)", async () => {
|
|
198
|
+
// Missing path exits validation (5); missing credentials would
|
|
199
|
+
// exit auth (2). Either proves the stub is gone.
|
|
200
|
+
const r = await runCli(["push"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
201
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
202
|
+
assert.ok([2, 5].includes(r.status));
|
|
203
|
+
});
|
|
204
|
+
|
|
197
205
|
it("`skillrepo remove` is wired to the real module (not a stub)", async () => {
|
|
198
206
|
const r = await runCli(["remove", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
199
207
|
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* GET /api/v1/skill-content — legacy skill content
|
|
7
7
|
* POST /api/v1/auth/validate — credential check (PR2)
|
|
8
8
|
* GET /api/v1/library — library sync with ETag + since (PR2)
|
|
9
|
-
* POST /api/v1/library —
|
|
9
|
+
* POST /api/v1/library — multipart file-push (#1452)
|
|
10
|
+
* POST /api/v1/library/refs — add catalog skill to library (#1451)
|
|
10
11
|
* DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
|
|
11
12
|
* GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
|
|
12
13
|
* GET /api/v1/skills/search — keyword search (PR2)
|
|
@@ -24,8 +25,9 @@
|
|
|
24
25
|
* setEtag(etag) — what library responses include
|
|
25
26
|
*
|
|
26
27
|
* (PR3a additions)
|
|
27
|
-
* setAddResponse(owner, name, resp) — per-skill response for POST /library
|
|
28
|
+
* setAddResponse(owner, name, resp) — per-skill response for POST /library/refs (#1451)
|
|
28
29
|
* setRemoveResponse(owner, name, resp) — per-skill response for DELETE /library/<owner>/<name>
|
|
30
|
+
* setPushResponse(resp) — response for POST /library multipart push (#1452)
|
|
29
31
|
* getLastPostBody() — inspect the most recent POST body (JSON-parsed)
|
|
30
32
|
*
|
|
31
33
|
* The slot APIs let unit/integration tests configure each scenario
|
|
@@ -85,6 +87,14 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
85
87
|
let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
86
88
|
let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
87
89
|
|
|
90
|
+
// #1452 — POST /api/v1/library (multipart file-push) response slot.
|
|
91
|
+
// Tests set this with `setPushResponse({ status, body })`; when null,
|
|
92
|
+
// a default 201 LibraryPushResponse is served. The test server does
|
|
93
|
+
// not parse multipart bodies — verifying the wire format is the
|
|
94
|
+
// client's responsibility.
|
|
95
|
+
/** @type {{ status: number, body: any } | null} */
|
|
96
|
+
let pushResponse = null;
|
|
97
|
+
|
|
88
98
|
// Most recent POST body (JSON-parsed) for test inspection.
|
|
89
99
|
//
|
|
90
100
|
// IMPORTANT:
|
|
@@ -138,19 +148,25 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
138
148
|
// command sends a JSON body to POST /api/v1/library, so we
|
|
139
149
|
// now collect chunks and re-dispatch once the stream ends.
|
|
140
150
|
if (req.method === "POST" || req.method === "DELETE" || req.method === "PUT" || req.method === "PATCH") {
|
|
141
|
-
|
|
151
|
+
// Buffer as Buffer chunks, not string concatenation. JSON routes
|
|
152
|
+
// re-interpret as UTF-8; binary multipart bodies (#1452 push) keep
|
|
153
|
+
// their bytes intact. Concatenating `chunk.toString("utf-8")`
|
|
154
|
+
// would silently corrupt non-UTF-8 file content.
|
|
155
|
+
const chunks = [];
|
|
142
156
|
req.on("data", (chunk) => {
|
|
143
|
-
|
|
157
|
+
chunks.push(chunk);
|
|
144
158
|
});
|
|
145
159
|
req.on("end", () => {
|
|
146
|
-
|
|
160
|
+
const rawBody = Buffer.concat(chunks);
|
|
161
|
+
const rawBodyText = rawBody.toString("utf-8");
|
|
162
|
+
if (req.method === "POST" && rawBodyText.length > 0) {
|
|
147
163
|
try {
|
|
148
|
-
lastPostBody = JSON.parse(
|
|
164
|
+
lastPostBody = JSON.parse(rawBodyText);
|
|
149
165
|
} catch {
|
|
150
|
-
lastPostBody =
|
|
166
|
+
lastPostBody = rawBodyText; // preserve as string for tests
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
|
-
handleRequest(req, res,
|
|
169
|
+
handleRequest(req, res, rawBodyText);
|
|
154
170
|
});
|
|
155
171
|
req.on("error", () => {
|
|
156
172
|
// Connection dropped — nothing to do, client is gone
|
|
@@ -189,8 +205,11 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
189
205
|
return;
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
// ──
|
|
193
|
-
|
|
208
|
+
// ── #1451: POST /api/v1/library/refs (add catalog skill) ───────
|
|
209
|
+
//
|
|
210
|
+
// Was at /api/v1/library until #1452 rebound that URL to multipart
|
|
211
|
+
// file-push. Same handler logic; only the URL moved.
|
|
212
|
+
if (url.pathname === "/api/v1/library/refs" && req.method === "POST") {
|
|
194
213
|
if (!checkAuth(req, res)) return;
|
|
195
214
|
let body;
|
|
196
215
|
try {
|
|
@@ -233,6 +252,61 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
233
252
|
return;
|
|
234
253
|
}
|
|
235
254
|
|
|
255
|
+
// ── #1452: POST /api/v1/library (multipart file-push) ──────────
|
|
256
|
+
//
|
|
257
|
+
// Accepts multipart/form-data and returns a synthesized
|
|
258
|
+
// `LibraryPushResponse` shape. The handler does not actually
|
|
259
|
+
// parse the multipart body — it just inspects the Content-Type
|
|
260
|
+
// header to confirm the client sent multipart, and serves either
|
|
261
|
+
// a `pushResponse` slot value (set via `setPushResponse`) or a
|
|
262
|
+
// default 201 with `action: "created"` shape.
|
|
263
|
+
if (url.pathname === "/api/v1/library" && req.method === "POST") {
|
|
264
|
+
if (!checkAuth(req, res)) return;
|
|
265
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
266
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
267
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
268
|
+
res.end(
|
|
269
|
+
JSON.stringify({
|
|
270
|
+
error:
|
|
271
|
+
"Invalid multipart body. Set Content-Type: multipart/form-data.",
|
|
272
|
+
code: "invalid_multipart",
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (pushResponse) {
|
|
278
|
+
res.writeHead(pushResponse.status, {
|
|
279
|
+
"Content-Type": "application/json",
|
|
280
|
+
});
|
|
281
|
+
res.end(JSON.stringify(pushResponse.body));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Default: 201 with a synthesized SyncSkill response.
|
|
285
|
+
res.writeHead(201, {
|
|
286
|
+
"Content-Type": "application/json",
|
|
287
|
+
Location: "/api/v1/library/mock/test-skill",
|
|
288
|
+
});
|
|
289
|
+
res.end(
|
|
290
|
+
JSON.stringify({
|
|
291
|
+
action: "created",
|
|
292
|
+
bump: null,
|
|
293
|
+
skill: {
|
|
294
|
+
owner: "mock",
|
|
295
|
+
name: "test-skill",
|
|
296
|
+
version: "1.0",
|
|
297
|
+
description: "mock",
|
|
298
|
+
keywords: [],
|
|
299
|
+
updatedAt: new Date().toISOString(),
|
|
300
|
+
etag: '"mock/test-skill@0"',
|
|
301
|
+
contextSignals: null,
|
|
302
|
+
files: [],
|
|
303
|
+
filesIncomplete: false,
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
236
310
|
// ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
|
|
237
311
|
{
|
|
238
312
|
const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
|
|
@@ -489,6 +563,14 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
489
563
|
removeResponses = new Map();
|
|
490
564
|
},
|
|
491
565
|
|
|
566
|
+
/**
|
|
567
|
+
* #1452 — register the next POST /api/v1/library (multipart file-push)
|
|
568
|
+
* response. Pass `null` to restore the default 201 LibraryPushResponse.
|
|
569
|
+
*/
|
|
570
|
+
setPushResponse(response) {
|
|
571
|
+
pushResponse = response;
|
|
572
|
+
},
|
|
573
|
+
|
|
492
574
|
/**
|
|
493
575
|
* Return the most recent POST body the server received, or null
|
|
494
576
|
* if no POST has been made yet. Body is JSON-parsed when
|