skillrepo 4.1.0 → 4.3.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 +22 -1
- package/package.json +10 -4
- package/src/commands/publish.mjs +125 -0
- package/src/commands/push.mjs +187 -0
- package/src/commands/unpublish.mjs +129 -0
- package/src/lib/http.mjs +358 -11
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/publish.test.mjs +420 -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 +202 -10
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit / integration tests for `runPublish` and `runUnpublish` —
|
|
3
|
+
* Epic #1444 R3 #1456.
|
|
4
|
+
*
|
|
5
|
+
* Tests both verbs in one file because they share the
|
|
6
|
+
* `setSkillVisibility` HTTP helper and have symmetric outcome
|
|
7
|
+
* mappings (only the response shape and 403 code differ). Each
|
|
8
|
+
* test exercises a distinct outcome of the discriminated
|
|
9
|
+
* `VisibilityResult` union returned by the HTTP layer.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
|
|
18
|
+
import { runPublish } from "../../commands/publish.mjs";
|
|
19
|
+
import { runUnpublish } from "../../commands/unpublish.mjs";
|
|
20
|
+
import {
|
|
21
|
+
CliError,
|
|
22
|
+
EXIT_VALIDATION,
|
|
23
|
+
EXIT_AUTH,
|
|
24
|
+
EXIT_SCOPE,
|
|
25
|
+
} from "../../lib/errors.mjs";
|
|
26
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
27
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
28
|
+
import {
|
|
29
|
+
captureHome,
|
|
30
|
+
setSandboxHome,
|
|
31
|
+
restoreHome,
|
|
32
|
+
} from "../helpers/sandbox-home.mjs";
|
|
33
|
+
|
|
34
|
+
let sandbox;
|
|
35
|
+
let server;
|
|
36
|
+
let serverUrl;
|
|
37
|
+
let originalCwd;
|
|
38
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
39
|
+
let originalHomeEnv;
|
|
40
|
+
let stdout;
|
|
41
|
+
const VALID_KEY = "sk_live_test";
|
|
42
|
+
|
|
43
|
+
async function setup() {
|
|
44
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-pub-"));
|
|
45
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
46
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
47
|
+
originalCwd = process.cwd();
|
|
48
|
+
originalHomeEnv = captureHome();
|
|
49
|
+
process.chdir(join(sandbox, "project"));
|
|
50
|
+
setSandboxHome(join(sandbox, "home"));
|
|
51
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
52
|
+
|
|
53
|
+
server = createMockServer({});
|
|
54
|
+
const port = await server.start();
|
|
55
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
56
|
+
|
|
57
|
+
stdout = createCaptureStream();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function teardown() {
|
|
61
|
+
if (server) await server.stop();
|
|
62
|
+
process.chdir(originalCwd);
|
|
63
|
+
restoreHome(originalHomeEnv);
|
|
64
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
65
|
+
server = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// argv used by both verbs — identifier first, then auth + endpoint
|
|
69
|
+
// flags. Caller can append extra flags (e.g. `--json`) at the end.
|
|
70
|
+
const baseArgv = (...extra) => [
|
|
71
|
+
`@alice/my-skill`,
|
|
72
|
+
"--key",
|
|
73
|
+
VALID_KEY,
|
|
74
|
+
"--url",
|
|
75
|
+
serverUrl,
|
|
76
|
+
...extra,
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// ── runPublish ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("runPublish — happy path", () => {
|
|
82
|
+
beforeEach(setup);
|
|
83
|
+
afterEach(teardown);
|
|
84
|
+
|
|
85
|
+
it("200 published → exit 0, prints '✓ Published @owner/name'", async () => {
|
|
86
|
+
// Default mock-server response is 200 published with synthesised
|
|
87
|
+
// SyncSkill — no slot configuration needed.
|
|
88
|
+
await runPublish(baseArgv(), { stdout });
|
|
89
|
+
const out = stdout.text();
|
|
90
|
+
// Matches the established `✓ <verb>` shape used by add/remove/push.
|
|
91
|
+
assert.match(out, /✓ Published @alice\/my-skill/);
|
|
92
|
+
assert.match(out, /now in the public catalog/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("200 unchanged → exit 0, prints '✓ Already published'", async () => {
|
|
96
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
97
|
+
status: 200,
|
|
98
|
+
body: {
|
|
99
|
+
action: "unchanged",
|
|
100
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
await runPublish(baseArgv(), { stdout });
|
|
104
|
+
assert.match(stdout.text(), /✓ Already published/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("--json emits a structured success payload (no `ok` field — matches add/remove/push convention)", async () => {
|
|
108
|
+
await runPublish(baseArgv("--json"), { stdout });
|
|
109
|
+
const parsed = JSON.parse(stdout.text());
|
|
110
|
+
// `action` is the success discriminator. NO `ok` field — the
|
|
111
|
+
// CLI exits non-zero on error, so the presence of stdout JSON
|
|
112
|
+
// already implies success. Matches add.mjs / remove.mjs /
|
|
113
|
+
// push.mjs shapes.
|
|
114
|
+
assert.equal(parsed.ok, undefined);
|
|
115
|
+
assert.equal(parsed.action, "published");
|
|
116
|
+
assert.equal(parsed.owner, "alice");
|
|
117
|
+
assert.equal(parsed.name, "my-skill");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("runPublish — error paths", () => {
|
|
122
|
+
beforeEach(setup);
|
|
123
|
+
afterEach(teardown);
|
|
124
|
+
|
|
125
|
+
it("missing identifier → CliError EXIT_VALIDATION", async () => {
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
runPublish(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
|
|
128
|
+
(err) =>
|
|
129
|
+
err instanceof CliError &&
|
|
130
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
131
|
+
/Missing skill identifier/.test(err.message),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("404 not-found → CliError EXIT_VALIDATION with helpful hint", async () => {
|
|
136
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
137
|
+
status: 404,
|
|
138
|
+
body: { error: "Skill not found", code: "skill_not_found" },
|
|
139
|
+
});
|
|
140
|
+
await assert.rejects(
|
|
141
|
+
runPublish(baseArgv(), { stdout }),
|
|
142
|
+
(err) =>
|
|
143
|
+
err instanceof CliError &&
|
|
144
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
145
|
+
/not found in your account/.test(err.message),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("403 publish_not_permitted → CliError EXIT_SCOPE with canPublish hint", async () => {
|
|
150
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
151
|
+
status: 403,
|
|
152
|
+
body: {
|
|
153
|
+
error: "You do not have permission to publish this skill.",
|
|
154
|
+
code: "publish_not_permitted",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
runPublish(baseArgv(), { stdout }),
|
|
159
|
+
(err) =>
|
|
160
|
+
err instanceof CliError &&
|
|
161
|
+
err.exitCode === EXIT_SCOPE &&
|
|
162
|
+
/canPublish/.test(err.hint ?? ""),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("403 hint does NOT duplicate the server's error reason text", async () => {
|
|
167
|
+
// Regression guard: pre-PR-F-review the CLI hint repeated the
|
|
168
|
+
// entitlement explanation that the server already includes in
|
|
169
|
+
// its `error` field. Result was the same sentence twice in
|
|
170
|
+
// terminal output. The hint must stay short and action-only.
|
|
171
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
172
|
+
status: 403,
|
|
173
|
+
body: {
|
|
174
|
+
error:
|
|
175
|
+
"You do not have permission to publish this skill. Admins, or members with the `canPublish` entitlement, can publish.",
|
|
176
|
+
code: "publish_not_permitted",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
|
|
180
|
+
assert.ok(err instanceof CliError);
|
|
181
|
+
// The server's reason text contains "Admins, or members with
|
|
182
|
+
// the `canPublish` entitlement, can publish." That sentence
|
|
183
|
+
// must NOT appear in the hint too.
|
|
184
|
+
assert.doesNotMatch(
|
|
185
|
+
err.hint ?? "",
|
|
186
|
+
/Admins, or members with the `canPublish` entitlement, can publish\./,
|
|
187
|
+
);
|
|
188
|
+
// But the hint should still be actionable.
|
|
189
|
+
assert.match(err.hint ?? "", /Ask an account admin/);
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), billing hint surfaces", async () => {
|
|
195
|
+
// Real-world regression scenario: an account over its skill
|
|
196
|
+
// quota tries to publish. Server returns 403 with code
|
|
197
|
+
// `plan_limit`. The CLI MUST classify this as exit 5
|
|
198
|
+
// validation with the billing hint, NOT exit 2 authError
|
|
199
|
+
// ("contact support") — a pre-review version of the code
|
|
200
|
+
// consumed the 403 body and let mapErrorResponse re-parse, but
|
|
201
|
+
// the body was empty by then so plan_limit fell through to
|
|
202
|
+
// authError.
|
|
203
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
204
|
+
status: 403,
|
|
205
|
+
body: {
|
|
206
|
+
error: "You are over your library skill quota.",
|
|
207
|
+
code: "plan_limit",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
|
|
211
|
+
assert.ok(err instanceof CliError);
|
|
212
|
+
assert.equal(err.exitCode, EXIT_VALIDATION);
|
|
213
|
+
assert.match(err.hint ?? "", /Upgrade your plan|billing/);
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("422 namespace_unset → CliError EXIT_VALIDATION, reason text passes through", async () => {
|
|
219
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
220
|
+
status: 422,
|
|
221
|
+
body: {
|
|
222
|
+
error: "Set up your account namespace before publishing.",
|
|
223
|
+
code: "namespace_unset",
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
await assert.rejects(
|
|
227
|
+
runPublish(baseArgv(), { stdout }),
|
|
228
|
+
(err) =>
|
|
229
|
+
err instanceof CliError &&
|
|
230
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
231
|
+
/account namespace/.test(err.message),
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("422 analysis_pending → CliError EXIT_VALIDATION, server reason wins", async () => {
|
|
236
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
237
|
+
status: 422,
|
|
238
|
+
body: {
|
|
239
|
+
error: "This skill must be analyzed before publishing.",
|
|
240
|
+
code: "analysis_pending",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
await assert.rejects(
|
|
244
|
+
runPublish(baseArgv(), { stdout }),
|
|
245
|
+
(err) =>
|
|
246
|
+
err instanceof CliError &&
|
|
247
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
248
|
+
/must be analyzed/.test(err.message),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("422 safety_grade_too_low → CliError EXIT_VALIDATION, server reason wins", async () => {
|
|
253
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
254
|
+
status: 422,
|
|
255
|
+
body: {
|
|
256
|
+
error: "Skills with a safety grade of F cannot be published.",
|
|
257
|
+
code: "safety_grade_too_low",
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
await assert.rejects(
|
|
261
|
+
runPublish(baseArgv(), { stdout }),
|
|
262
|
+
(err) =>
|
|
263
|
+
err instanceof CliError &&
|
|
264
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
265
|
+
/safety grade of F cannot be published/.test(err.message),
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("401 unauthorized → CliError EXIT_AUTH", async () => {
|
|
270
|
+
server.setPublishResponse("alice", "my-skill", {
|
|
271
|
+
status: 401,
|
|
272
|
+
body: { error: "Invalid access key" },
|
|
273
|
+
});
|
|
274
|
+
await assert.rejects(
|
|
275
|
+
runPublish(baseArgv(), { stdout }),
|
|
276
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── runUnpublish ────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("runUnpublish — happy path", () => {
|
|
284
|
+
beforeEach(setup);
|
|
285
|
+
afterEach(teardown);
|
|
286
|
+
|
|
287
|
+
it("200 unpublished with N=0 subscribers → exit 0, no-notifications message", async () => {
|
|
288
|
+
// Default mock-server response is 200 unpublished, count=0.
|
|
289
|
+
await runUnpublish(baseArgv(), { stdout });
|
|
290
|
+
assert.match(
|
|
291
|
+
stdout.text(),
|
|
292
|
+
/✓ Unpublished @alice\/my-skill — no other accounts had it in their library/,
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("200 unpublished with N=1 → singular 'notified 1 subscriber'", async () => {
|
|
297
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
298
|
+
status: 200,
|
|
299
|
+
body: {
|
|
300
|
+
action: "unpublished",
|
|
301
|
+
notifiedSubscriberCount: 1,
|
|
302
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
await runUnpublish(baseArgv(), { stdout });
|
|
306
|
+
const out = stdout.text();
|
|
307
|
+
assert.match(out, /notified 1 subscriber\b/);
|
|
308
|
+
assert.doesNotMatch(out, /1 subscribers/);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("200 unpublished with N=5 → plural 'notified 5 subscribers'", async () => {
|
|
312
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
313
|
+
status: 200,
|
|
314
|
+
body: {
|
|
315
|
+
action: "unpublished",
|
|
316
|
+
notifiedSubscriberCount: 5,
|
|
317
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
await runUnpublish(baseArgv(), { stdout });
|
|
321
|
+
const out = stdout.text();
|
|
322
|
+
assert.match(out, /notified 5 subscribers/);
|
|
323
|
+
assert.match(out, /they keep their current copy/);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("200 unchanged → exit 0, prints '✓ Already private'", async () => {
|
|
327
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
328
|
+
status: 200,
|
|
329
|
+
body: {
|
|
330
|
+
action: "unchanged",
|
|
331
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
await runUnpublish(baseArgv(), { stdout });
|
|
335
|
+
assert.match(stdout.text(), /✓ Already private/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("--json emits a structured payload with notifiedSubscriberCount (no `ok` field)", async () => {
|
|
339
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
340
|
+
status: 200,
|
|
341
|
+
body: {
|
|
342
|
+
action: "unpublished",
|
|
343
|
+
notifiedSubscriberCount: 3,
|
|
344
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
await runUnpublish(baseArgv("--json"), { stdout });
|
|
348
|
+
const parsed = JSON.parse(stdout.text());
|
|
349
|
+
assert.equal(parsed.ok, undefined);
|
|
350
|
+
assert.equal(parsed.action, "unpublished");
|
|
351
|
+
assert.equal(parsed.notifiedSubscriberCount, 3);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("--json on unchanged emits notifiedSubscriberCount=0 (no `ok` field)", async () => {
|
|
355
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
356
|
+
status: 200,
|
|
357
|
+
body: {
|
|
358
|
+
action: "unchanged",
|
|
359
|
+
skill: { owner: "alice", name: "my-skill" },
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
await runUnpublish(baseArgv("--json"), { stdout });
|
|
363
|
+
const parsed = JSON.parse(stdout.text());
|
|
364
|
+
assert.equal(parsed.ok, undefined);
|
|
365
|
+
assert.equal(parsed.action, "unchanged");
|
|
366
|
+
assert.equal(parsed.notifiedSubscriberCount, 0);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("runUnpublish — error paths", () => {
|
|
371
|
+
beforeEach(setup);
|
|
372
|
+
afterEach(teardown);
|
|
373
|
+
|
|
374
|
+
it("404 not-found → CliError EXIT_VALIDATION", async () => {
|
|
375
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
376
|
+
status: 404,
|
|
377
|
+
body: { error: "Skill not found", code: "skill_not_found" },
|
|
378
|
+
});
|
|
379
|
+
await assert.rejects(
|
|
380
|
+
runUnpublish(baseArgv(), { stdout }),
|
|
381
|
+
(err) =>
|
|
382
|
+
err instanceof CliError &&
|
|
383
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
384
|
+
/not found in your account/.test(err.message),
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("403 unpublish_not_permitted → CliError EXIT_SCOPE with action-only hint", async () => {
|
|
389
|
+
server.setUnpublishResponse("alice", "my-skill", {
|
|
390
|
+
status: 403,
|
|
391
|
+
body: {
|
|
392
|
+
error:
|
|
393
|
+
"You do not have permission to unpublish this skill. Admins, or members with the `canPublish` entitlement, can unpublish.",
|
|
394
|
+
code: "unpublish_not_permitted",
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
await assert.rejects(runUnpublish(baseArgv(), { stdout }), (err) => {
|
|
398
|
+
assert.ok(err instanceof CliError);
|
|
399
|
+
assert.equal(err.exitCode, EXIT_SCOPE);
|
|
400
|
+
// Same regression guard as the publish path — hint must NOT
|
|
401
|
+
// duplicate the server's entitlement explanation.
|
|
402
|
+
assert.doesNotMatch(
|
|
403
|
+
err.hint ?? "",
|
|
404
|
+
/Admins, or members with the `canPublish` entitlement, can unpublish\./,
|
|
405
|
+
);
|
|
406
|
+
assert.match(err.hint ?? "", /Ask an account admin/);
|
|
407
|
+
return true;
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("missing identifier → CliError EXIT_VALIDATION", async () => {
|
|
412
|
+
await assert.rejects(
|
|
413
|
+
runUnpublish(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
|
|
414
|
+
(err) =>
|
|
415
|
+
err instanceof CliError &&
|
|
416
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
417
|
+
/Missing skill identifier/.test(err.message),
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -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
|
+
});
|