skillrepo 4.2.0 → 4.4.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 +103 -0
- package/bin/skillrepo.mjs +14 -1
- package/package.json +1 -1
- package/src/commands/init.mjs +60 -2
- package/src/commands/publish.mjs +125 -0
- package/src/commands/unpublish.mjs +129 -0
- package/src/lib/config.mjs +6 -0
- package/src/lib/http.mjs +189 -0
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/publish.test.mjs +420 -0
- package/src/test/e2e/mock-server.mjs +110 -0
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/telemetry.test.mjs +289 -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
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* POST /api/v1/library — multipart file-push (#1452)
|
|
10
10
|
* POST /api/v1/library/refs — add catalog skill to library (#1451)
|
|
11
11
|
* DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
|
|
12
|
+
* POST /api/v1/library/[owner]/[name]/publish — make global (#1449)
|
|
13
|
+
* POST /api/v1/library/[owner]/[name]/unpublish — make private (#1449)
|
|
12
14
|
* GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
|
|
13
15
|
* GET /api/v1/skills/search — keyword search (PR2)
|
|
14
16
|
*
|
|
@@ -30,6 +32,10 @@
|
|
|
30
32
|
* setPushResponse(resp) — response for POST /library multipart push (#1452)
|
|
31
33
|
* getLastPostBody() — inspect the most recent POST body (JSON-parsed)
|
|
32
34
|
*
|
|
35
|
+
* (Epic #1444 R3 #1449 additions)
|
|
36
|
+
* setPublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/publish
|
|
37
|
+
* setUnpublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/unpublish
|
|
38
|
+
*
|
|
33
39
|
* The slot APIs let unit/integration tests configure each scenario
|
|
34
40
|
* without spinning up multiple servers per test. Default behavior
|
|
35
41
|
* for an unconfigured slot is a sensible empty payload.
|
|
@@ -86,6 +92,10 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
86
92
|
// matches any owner/name when no exact match is registered.
|
|
87
93
|
let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
88
94
|
let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
95
|
+
// Epic #1444 R3 #1456 — POST /api/v1/library/[owner]/[name]/publish
|
|
96
|
+
// and POST /api/v1/library/[owner]/[name]/unpublish (#1449).
|
|
97
|
+
let publishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
98
|
+
let unpublishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
89
99
|
|
|
90
100
|
// #1452 — POST /api/v1/library (multipart file-push) response slot.
|
|
91
101
|
// Tests set this with `setPushResponse({ status, body })`; when null,
|
|
@@ -307,6 +317,86 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
307
317
|
return;
|
|
308
318
|
}
|
|
309
319
|
|
|
320
|
+
// ── #1449: POST /api/v1/library/[owner]/[name]/publish ─────────
|
|
321
|
+
{
|
|
322
|
+
const m = url.pathname.match(
|
|
323
|
+
/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/publish$/,
|
|
324
|
+
);
|
|
325
|
+
if (m && req.method === "POST") {
|
|
326
|
+
if (!checkAuth(req, res)) return;
|
|
327
|
+
const owner = decodeURIComponent(m[1]);
|
|
328
|
+
const name = decodeURIComponent(m[2]);
|
|
329
|
+
const configured =
|
|
330
|
+
publishResponses.get(`${owner}/${name}`) ?? publishResponses.get("*");
|
|
331
|
+
if (configured) {
|
|
332
|
+
res.writeHead(configured.status, { "Content-Type": "application/json" });
|
|
333
|
+
res.end(JSON.stringify(configured.body));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Default: 200 published with synthesized SyncSkill.
|
|
337
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
338
|
+
res.end(
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
action: "published",
|
|
341
|
+
skill: {
|
|
342
|
+
owner,
|
|
343
|
+
name,
|
|
344
|
+
version: "1.0.0",
|
|
345
|
+
description: "mock",
|
|
346
|
+
keywords: [],
|
|
347
|
+
updatedAt: new Date().toISOString(),
|
|
348
|
+
etag: `"${owner}/${name}@0"`,
|
|
349
|
+
contextSignals: null,
|
|
350
|
+
files: [],
|
|
351
|
+
filesIncomplete: false,
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── #1449: POST /api/v1/library/[owner]/[name]/unpublish ───────
|
|
360
|
+
{
|
|
361
|
+
const m = url.pathname.match(
|
|
362
|
+
/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/unpublish$/,
|
|
363
|
+
);
|
|
364
|
+
if (m && req.method === "POST") {
|
|
365
|
+
if (!checkAuth(req, res)) return;
|
|
366
|
+
const owner = decodeURIComponent(m[1]);
|
|
367
|
+
const name = decodeURIComponent(m[2]);
|
|
368
|
+
const configured =
|
|
369
|
+
unpublishResponses.get(`${owner}/${name}`) ??
|
|
370
|
+
unpublishResponses.get("*");
|
|
371
|
+
if (configured) {
|
|
372
|
+
res.writeHead(configured.status, { "Content-Type": "application/json" });
|
|
373
|
+
res.end(JSON.stringify(configured.body));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Default: 200 unpublished, zero subscribers notified.
|
|
377
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
378
|
+
res.end(
|
|
379
|
+
JSON.stringify({
|
|
380
|
+
action: "unpublished",
|
|
381
|
+
notifiedSubscriberCount: 0,
|
|
382
|
+
skill: {
|
|
383
|
+
owner,
|
|
384
|
+
name,
|
|
385
|
+
version: "1.0.0",
|
|
386
|
+
description: "mock",
|
|
387
|
+
keywords: [],
|
|
388
|
+
updatedAt: new Date().toISOString(),
|
|
389
|
+
etag: `"${owner}/${name}@0"`,
|
|
390
|
+
contextSignals: null,
|
|
391
|
+
files: [],
|
|
392
|
+
filesIncomplete: false,
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
310
400
|
// ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
|
|
311
401
|
{
|
|
312
402
|
const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
|
|
@@ -563,6 +653,26 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
563
653
|
removeResponses = new Map();
|
|
564
654
|
},
|
|
565
655
|
|
|
656
|
+
// Epic #1444 R3 #1456 — publish / unpublish slot setters.
|
|
657
|
+
setPublishResponse(owner, name, response) {
|
|
658
|
+
publishResponses.set(`${owner}/${name}`, response);
|
|
659
|
+
},
|
|
660
|
+
setPublishResponseForAny(response) {
|
|
661
|
+
publishResponses.set("*", response);
|
|
662
|
+
},
|
|
663
|
+
clearPublishResponses() {
|
|
664
|
+
publishResponses = new Map();
|
|
665
|
+
},
|
|
666
|
+
setUnpublishResponse(owner, name, response) {
|
|
667
|
+
unpublishResponses.set(`${owner}/${name}`, response);
|
|
668
|
+
},
|
|
669
|
+
setUnpublishResponseForAny(response) {
|
|
670
|
+
unpublishResponses.set("*", response);
|
|
671
|
+
},
|
|
672
|
+
clearUnpublishResponses() {
|
|
673
|
+
unpublishResponses = new Map();
|
|
674
|
+
},
|
|
675
|
+
|
|
566
676
|
/**
|
|
567
677
|
* #1452 — register the next POST /api/v1/library (multipart file-push)
|
|
568
678
|
* response. Pass `null` to restore the default 201 LibraryPushResponse.
|
|
@@ -206,6 +206,39 @@ describe("writeConfig", () => {
|
|
|
206
206
|
assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
// Telemetry opt-in/out flag persistence (#1539). The field is only
|
|
210
|
+
// written when the caller explicitly sets it (boolean) — omitting
|
|
211
|
+
// means "no preference recorded" and the telemetry module treats
|
|
212
|
+
// that as the opt-out-friendly default of enabled.
|
|
213
|
+
it("persists telemetry=true when explicitly set", () => {
|
|
214
|
+
writeConfig({
|
|
215
|
+
apiKey: "sk_live_abc",
|
|
216
|
+
serverUrl: "https://example.com",
|
|
217
|
+
telemetry: true,
|
|
218
|
+
});
|
|
219
|
+
const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
|
|
220
|
+
assert.equal(raw.telemetry, true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("persists telemetry=false when explicitly set", () => {
|
|
224
|
+
writeConfig({
|
|
225
|
+
apiKey: "sk_live_abc",
|
|
226
|
+
serverUrl: "https://example.com",
|
|
227
|
+
telemetry: false,
|
|
228
|
+
});
|
|
229
|
+
const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
|
|
230
|
+
assert.equal(raw.telemetry, false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does NOT write a telemetry field when omitted (preserves no-preference state)", () => {
|
|
234
|
+
writeConfig({
|
|
235
|
+
apiKey: "sk_live_abc",
|
|
236
|
+
serverUrl: "https://example.com",
|
|
237
|
+
});
|
|
238
|
+
const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
|
|
239
|
+
assert.equal("telemetry" in raw, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
209
242
|
it("throws validationError on missing apiKey", () => {
|
|
210
243
|
assert.throws(
|
|
211
244
|
() => writeConfig({ serverUrl: "https://example.com" }),
|