@valbuild/cli 0.133.0 → 0.134.1

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.
@@ -0,0 +1,482 @@
1
+ import crypto from "crypto";
2
+ import http from "http";
3
+ import { PublishProblem } from "./contentApi";
4
+
5
+ /**
6
+ * A local stand-in for the publish API on content.val.build.
7
+ *
8
+ * It implements the protocol over real HTTP - the token exchange, the five
9
+ * publish calls, and an object store the presigned PUTs point at - so
10
+ * `val publish` runs against it exactly as it runs against the service: one
11
+ * `VAL_CONTENT_URL`, and nothing stubbed inside the CLI.
12
+ *
13
+ * Three things it does for real, because they are the three the CLI can get
14
+ * wrong on its own:
15
+ *
16
+ * - **It re-hashes what landed.** Bytes that do not match the declared sha256
17
+ * are answered with `409 ARTIFACT_MISMATCH`, which is how a truncated upload
18
+ * is caught here rather than by a reader of the live site.
19
+ * - **It holds artifacts per project by hash.** Declaring the same build twice
20
+ * mints no slots the second time - the dedupe the whole declare step exists
21
+ * for.
22
+ * - **It is idempotent on `buildHash`.** A re-declared build is the same
23
+ * publish, so a retried CI job resumes rather than starting again.
24
+ *
25
+ * Run it by hand to publish against nothing:
26
+ *
27
+ * const fake = await startFakeContentService({});
28
+ * VAL_CONTENT_URL=<fake.url> VAL_PROJECT_TOKEN=val_pt_x npx val publish
29
+ */
30
+ export type FakeContentOptions = {
31
+ /** The project token it accepts. Any token is accepted when absent. */
32
+ token?: string;
33
+ /** sha256s it already holds for this project, so no slot is minted. */
34
+ have?: string[];
35
+ /** Swallow the first N uploads: accept the PUT, store nothing. */
36
+ dropUploads?: number;
37
+ /** Answer the first N uploads with a 503 before accepting any. */
38
+ failUploadsWith5xx?: number;
39
+ /** Answer the first N uploads with a 403, as an expired slot does. */
40
+ expireSlots?: number;
41
+ /** What the canary does. */
42
+ verify?: "ok" | "fail";
43
+ /** Refuse the promote because the branch moved on. */
44
+ promote?: "ok" | "stale";
45
+ /**
46
+ * The head the branch is at now.
47
+ *
48
+ * Only sent when a test asks for it: the API's written contract has it on a
49
+ * stale promote and the handler does not send it, which is a difference
50
+ * worth being able to drive from both sides.
51
+ */
52
+ head?: string;
53
+ };
54
+
55
+ export type FakeContentService = {
56
+ url: string;
57
+ calls: Array<{ method: string; path: string; authorization: string | null }>;
58
+ /** What is in the object store now, by artifact key. */
59
+ stored: Map<string, Buffer>;
60
+ /** What each publish was declared with, by publish id. */
61
+ declarations: Map<string, unknown>;
62
+ close: () => Promise<void>;
63
+ };
64
+
65
+ type Artifact = { key: string; sha256: string; bytes: number };
66
+
67
+ type Publish = {
68
+ id: string;
69
+ buildHash: string;
70
+ commit: string | null;
71
+ branch: string | null;
72
+ artifacts: Artifact[];
73
+ state: string;
74
+ problems: PublishProblem[];
75
+ };
76
+
77
+ export async function startFakeContentService(
78
+ options: FakeContentOptions,
79
+ ): Promise<FakeContentService> {
80
+ const held = new Set(options.have ?? []);
81
+ const calls: FakeContentService["calls"] = [];
82
+ const stored = new Map<string, Buffer>();
83
+ const declarations = new Map<string, unknown>();
84
+ const publishes = new Map<string, Publish>();
85
+ const byBuildHash = new Map<string, string>();
86
+ let dropsLeft = options.dropUploads ?? 0;
87
+ let failuresLeft = options.failUploadsWith5xx ?? 0;
88
+ let expiriesLeft = options.expireSlots ?? 0;
89
+ let nextId = 1;
90
+
91
+ const server = http.createServer((req, res) => {
92
+ void handle(req, res).catch((err) => {
93
+ send(res, 500, { statusCode: 500, message: String(err) });
94
+ });
95
+ });
96
+
97
+ const outstanding = (publish: Publish): Artifact[] =>
98
+ publish.artifacts.filter((artifact) => !held.has(artifact.sha256));
99
+
100
+ const slotsFor = (publish: Publish) =>
101
+ outstanding(publish).map((artifact) => ({
102
+ key: artifact.key,
103
+ url: `${baseUrl()}/store/${encodeURIComponent(publish.id)}/${encodeURIComponent(artifact.key)}`,
104
+ method: "PUT",
105
+ // What `mintUploadSlots` sends. The size is signed into the URL rather
106
+ // than declared in a header.
107
+ headers: { "content-type": "application/octet-stream" },
108
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
109
+ }));
110
+
111
+ const handle = async (
112
+ req: http.IncomingMessage,
113
+ res: http.ServerResponse,
114
+ ): Promise<void> => {
115
+ const url = new URL(req.url ?? "/", baseUrl());
116
+ const parts = url.pathname.split("/").filter((part) => part !== "");
117
+ const authorization = req.headers.authorization ?? null;
118
+ calls.push({ method: req.method ?? "", path: url.pathname, authorization });
119
+
120
+ // The object store. A presigned URL is the one thing here that is not
121
+ // content, and it carries no credential of ours.
122
+ if (parts[0] === "store") {
123
+ const body = await readBody(req);
124
+ if (expiriesLeft > 0) {
125
+ expiriesLeft--;
126
+ send(res, 403, { statusCode: 403, message: "Request has expired" });
127
+ return;
128
+ }
129
+ if (failuresLeft > 0) {
130
+ failuresLeft--;
131
+ send(res, 503, { statusCode: 503, message: "Slow down" });
132
+ return;
133
+ }
134
+ if (dropsLeft > 0) {
135
+ dropsLeft--;
136
+ // Accepted, and thrown away: a connection that died mid-body looks
137
+ // exactly like this from the publisher's side.
138
+ send(res, 200, {});
139
+ return;
140
+ }
141
+ stored.set(decodeURIComponent(parts[2] ?? ""), body);
142
+ send(res, 200, {});
143
+ return;
144
+ }
145
+
146
+ if (parts[0] !== "v1") {
147
+ send(res, 404, { statusCode: 404, message: "No such route" });
148
+ return;
149
+ }
150
+
151
+ // POST /v1/<org>/<project>/publish-token
152
+ if (parts.length === 4 && parts[3] === "publish-token") {
153
+ if (typeof req.headers["x-val-pat"] !== "string") {
154
+ send(res, 401, {
155
+ statusCode: 401,
156
+ message: "No personal access token",
157
+ });
158
+ return;
159
+ }
160
+ send(res, 200, {
161
+ token: options.token ?? "val_pt_fake",
162
+ expiresAt: new Date(Date.now() + 600_000).toISOString(),
163
+ publicProjectId: "fake-project",
164
+ productionUrl: "https://example.test",
165
+ });
166
+ return;
167
+ }
168
+
169
+ if (parts[1] !== "publish") {
170
+ send(res, 404, { statusCode: 404, message: "No such route" });
171
+ return;
172
+ }
173
+ if (!authorization?.startsWith("Bearer val_pt_")) {
174
+ send(res, 401, {
175
+ statusCode: 401,
176
+ message: "This needs a project token as `Authorization: Bearer`.",
177
+ });
178
+ return;
179
+ }
180
+ if (
181
+ options.token !== undefined &&
182
+ authorization !== `Bearer ${options.token}`
183
+ ) {
184
+ send(res, 401, {
185
+ statusCode: 401,
186
+ message: "This project token is not valid. It may have been revoked.",
187
+ });
188
+ return;
189
+ }
190
+
191
+ // POST /v1/publish - declare.
192
+ if (parts.length === 2 && req.method === "POST") {
193
+ const body = parseJson(await readBody(req));
194
+ const problems = declarationProblems(body);
195
+ if (problems.length > 0) {
196
+ send(res, 400, {
197
+ statusCode: 400,
198
+ message: "This publish cannot be declared",
199
+ details: problems,
200
+ });
201
+ return;
202
+ }
203
+ const buildHash = stringOf(body, "buildHash");
204
+ declarations.set(buildHash, body);
205
+ const existingId = byBuildHash.get(buildHash);
206
+ const publish: Publish = publishes.get(existingId ?? "") ?? {
207
+ id: `pub_${nextId++}`,
208
+ buildHash,
209
+ commit: nullableStringOf(body, "commit"),
210
+ branch: nullableStringOf(body, "branch"),
211
+ artifacts: artifactsOf(body),
212
+ state: "awaiting-artifacts",
213
+ problems: [],
214
+ };
215
+ publishes.set(publish.id, publish);
216
+ byBuildHash.set(buildHash, publish.id);
217
+ // A live publish is not re-opened: re-declaring would mint slots to
218
+ // overwrite the bytes of a build that is currently serving.
219
+ const uploads = publish.state === "live" ? [] : slotsFor(publish);
220
+ send(res, 200, {
221
+ publishId: publish.id,
222
+ state: publish.state,
223
+ project: {
224
+ publicProjectId: "fake-project",
225
+ siteUrl: "https://example.test",
226
+ },
227
+ uploads,
228
+ have: publish.artifacts
229
+ .filter((artifact) => held.has(artifact.sha256))
230
+ .map((artifact) => artifact.key),
231
+ });
232
+ return;
233
+ }
234
+
235
+ const publish = publishes.get(decodeURIComponent(parts[2] ?? ""));
236
+ if (!publish) {
237
+ send(res, 404, { statusCode: 404, message: "No such publish" });
238
+ return;
239
+ }
240
+
241
+ // GET /v1/publish/{id} - status.
242
+ if (parts.length === 3 && req.method === "GET") {
243
+ send(res, 200, {
244
+ publishId: publish.id,
245
+ state: publish.state,
246
+ buildHash: publish.buildHash,
247
+ missing: outstanding(publish).map((artifact) => artifact.key),
248
+ problems: publish.problems,
249
+ });
250
+ return;
251
+ }
252
+
253
+ if (req.method !== "POST") {
254
+ send(res, 405, { statusCode: 405, message: "Method not allowed" });
255
+ return;
256
+ }
257
+ await readBody(req);
258
+
259
+ // POST /v1/publish/{id}/artifacts - confirm. What landed is re-hashed.
260
+ if (parts[3] === "artifacts") {
261
+ const problems: Publish["problems"] = [];
262
+ for (const artifact of outstanding(publish)) {
263
+ const bytes = stored.get(artifact.key);
264
+ if (!bytes) {
265
+ problems.push({
266
+ code: "ARTIFACT_NOT_UPLOADED",
267
+ message:
268
+ "These artifacts were declared but nothing was uploaded for them.",
269
+ hint: "Upload slots expire. Declare the publish again for fresh ones.",
270
+ keys: [artifact.key],
271
+ });
272
+ continue;
273
+ }
274
+ if (
275
+ sha256(bytes) !== artifact.sha256 ||
276
+ bytes.length !== artifact.bytes
277
+ ) {
278
+ problems.push({
279
+ code: "ARTIFACT_MISMATCH",
280
+ message: "What was uploaded is not what was declared.",
281
+ hint: "The bytes at that key do not match the sha256 or the size given for it.",
282
+ keys: [artifact.key],
283
+ });
284
+ continue;
285
+ }
286
+ held.add(artifact.sha256);
287
+ }
288
+ if (problems.length > 0) {
289
+ send(res, 409, {
290
+ statusCode: 409,
291
+ message: "Some artifacts did not arrive as declared",
292
+ details: problems,
293
+ });
294
+ return;
295
+ }
296
+ publish.state = "ready";
297
+ send(res, 200, { state: publish.state, problems: [] });
298
+ return;
299
+ }
300
+
301
+ // POST /v1/publish/{id}/verify - a canary build and render.
302
+ if (parts[3] === "verify") {
303
+ const ok = options.verify !== "fail";
304
+ publish.state = ok ? "verified" : "failed";
305
+ publish.problems = ok
306
+ ? []
307
+ : [
308
+ {
309
+ code: "PLATFORM_RENDER_FAILED",
310
+ message: "/ threw on the server",
311
+ },
312
+ ];
313
+ send(res, 200, {
314
+ state: publish.state,
315
+ ok,
316
+ // Null either way today: `CANARY_HAS_NO_PREVIEW`. A test that wants a
317
+ // URL here is testing a service that does not exist yet.
318
+ previewUrl: null,
319
+ problems: publish.problems,
320
+ });
321
+ return;
322
+ }
323
+
324
+ // POST /v1/publish/{id}/promote - the pointer moves, or it does not.
325
+ if (parts[3] === "promote") {
326
+ if (options.promote === "stale") {
327
+ // The shape `postPublishPromote.ts` sends: the ordinary error envelope,
328
+ // with the code in `details`. `head` is in the API's written contract
329
+ // and is not sent today, so it is only here when a test asks for it.
330
+ send(res, 409, {
331
+ statusCode: 409,
332
+ message:
333
+ "This build's commit is no longer the head of its branch, so it " +
334
+ "was not promoted. Rebuild from the current head.",
335
+ details: [
336
+ {
337
+ code: "POINTER_STALE",
338
+ message: "not-fast-forward",
339
+ },
340
+ ],
341
+ ...(options.head ? { head: options.head } : {}),
342
+ });
343
+ return;
344
+ }
345
+ publish.state = "live";
346
+ send(res, 200, {
347
+ state: publish.state,
348
+ url: "https://example.test",
349
+ commit: publish.commit,
350
+ });
351
+ return;
352
+ }
353
+
354
+ send(res, 404, { statusCode: 404, message: "No such route" });
355
+ };
356
+
357
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
358
+ const address = server.address();
359
+ if (address === null || typeof address === "string") {
360
+ throw new Error("Fake content service did not get a port");
361
+ }
362
+ const port = address.port;
363
+ function baseUrl(): string {
364
+ return `http://127.0.0.1:${port}`;
365
+ }
366
+
367
+ return {
368
+ url: baseUrl(),
369
+ calls,
370
+ stored,
371
+ declarations,
372
+ close: () =>
373
+ new Promise<void>((resolve, reject) =>
374
+ server.close((err) => (err ? reject(err) : resolve())),
375
+ ),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * The half of `publishPlan.ts` a publisher can actually trip over.
381
+ *
382
+ * Not all of it - the point here is that a declaration is answered with EVERY
383
+ * problem at once, and that the required keys and the layer rule are enforced
384
+ * somewhere the CLI has to cope with.
385
+ */
386
+ function declarationProblems(
387
+ body: unknown,
388
+ ): Array<{ code: string; message: string; keys?: string[] }> {
389
+ const problems: Array<{ code: string; message: string; keys?: string[] }> =
390
+ [];
391
+ const artifacts = artifactsOf(body);
392
+ const keys = new Set(artifacts.map((artifact) => artifact.key));
393
+ const missing = ["server", "client"].filter((key) => !keys.has(key));
394
+ if (missing.length > 0) {
395
+ problems.push({
396
+ code: "ARTIFACT_MISSING",
397
+ message: "A publish needs at least a server and a client bundle.",
398
+ keys: missing,
399
+ });
400
+ }
401
+ if (keys.has("layer") && !nullableStringOf(body, "layerRev")) {
402
+ problems.push({
403
+ code: "LAYER_REV_MISSING",
404
+ message: "A layer artifact must be declared with the layerRev it is.",
405
+ keys: ["layer"],
406
+ });
407
+ }
408
+ if (nullableStringOf(body, "branch") === "HEAD") {
409
+ problems.push({
410
+ code: "BRANCH_INVALID",
411
+ message: "'HEAD' is not a branch.",
412
+ });
413
+ }
414
+ const bad = artifacts
415
+ .filter((artifact) => !/^[0-9a-f]{64}$/.test(artifact.sha256))
416
+ .map((artifact) => artifact.key);
417
+ if (bad.length > 0) {
418
+ problems.push({
419
+ code: "ARTIFACT_SHA_INVALID",
420
+ message: "sha256 must be 64 lowercase hex characters.",
421
+ keys: bad,
422
+ });
423
+ }
424
+ return problems;
425
+ }
426
+
427
+ function send(res: http.ServerResponse, statusCode: number, body: unknown) {
428
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
429
+ res.end(JSON.stringify(body));
430
+ }
431
+
432
+ async function readBody(req: http.IncomingMessage): Promise<Buffer> {
433
+ const chunks: Buffer[] = [];
434
+ for await (const chunk of req) {
435
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
436
+ }
437
+ return Buffer.concat(chunks);
438
+ }
439
+
440
+ function sha256(bytes: Buffer): string {
441
+ return crypto.createHash("sha256").update(bytes).digest("hex");
442
+ }
443
+
444
+ function parseJson(body: Buffer): unknown {
445
+ return body.length === 0 ? {} : JSON.parse(body.toString("utf-8"));
446
+ }
447
+
448
+ function stringOf(body: unknown, key: string): string {
449
+ const value = nullableStringOf(body, key);
450
+ if (value === null) {
451
+ throw new Error(`Fake content service: no ${key} in the body`);
452
+ }
453
+ return value;
454
+ }
455
+
456
+ function nullableStringOf(body: unknown, key: string): string | null {
457
+ if (typeof body === "object" && body !== null) {
458
+ const value = Reflect.get(body, key);
459
+ if (typeof value === "string" && value !== "") {
460
+ return value;
461
+ }
462
+ }
463
+ return null;
464
+ }
465
+
466
+ function artifactsOf(body: unknown): Artifact[] {
467
+ if (typeof body !== "object" || body === null) {
468
+ throw new Error("Fake content service: no body");
469
+ }
470
+ const raw = Reflect.get(body, "artifacts");
471
+ if (!Array.isArray(raw)) {
472
+ throw new Error("Fake content service: no artifacts");
473
+ }
474
+ return raw.map((entry) => ({
475
+ key: stringOf(entry, "key"),
476
+ sha256: stringOf(entry, "sha256"),
477
+ bytes:
478
+ typeof entry === "object" && entry !== null
479
+ ? Number(Reflect.get(entry, "bytes"))
480
+ : 0,
481
+ }));
482
+ }
@@ -0,0 +1,174 @@
1
+ import { z } from "zod";
2
+ import { fromError } from "zod-validation-error";
3
+ import { ContentPublishApi, PublishProblem, PublishState } from "./contentApi";
4
+
5
+ /**
6
+ * Reading what content answers.
7
+ *
8
+ * Every type here comes from `contentApi.ts`, which is a copy of the
9
+ * service's own `Api.ts` - so the shapes are not re-derived from what this end
10
+ * wishes it received, and a copy brought up to date fails to compile wherever
11
+ * this CLI has not caught up.
12
+ *
13
+ * The schemas are the runtime half, and they are needed as well as the types:
14
+ * what arrives is JSON over HTTP from a service that versions separately, so a
15
+ * type alone asserts a shape rather than checking one. Each schema is
16
+ * annotated with the copied type it must produce (`z.ZodType<DeclareResponse>`
17
+ * and so on), which is what ties the two halves together - a schema that drifts
18
+ * from the copy does not compile, and an answer that drifts from the schema
19
+ * does not parse. `ValOpsHttp` in `@valbuild/server` reads every other content
20
+ * route the same way.
21
+ *
22
+ * Nothing here is laxer than the copy. An optional field is optional because
23
+ * the service says so; making a required one optional here would hide exactly
24
+ * the drift this file exists to catch.
25
+ */
26
+
27
+ export type { PublishProblem, PublishState };
28
+
29
+ export type DeclareBody = ContentPublishApi["/publish"]["POST"]["body"];
30
+ export type DeclareResponse = ContentPublishApi["/publish"]["POST"]["res"];
31
+ export type ArtifactsResponse =
32
+ ContentPublishApi["/publish/:publishId/artifacts"]["POST"]["res"];
33
+ export type VerifyResponse =
34
+ ContentPublishApi["/publish/:publishId/verify"]["POST"]["res"];
35
+ export type PromoteResponse =
36
+ ContentPublishApi["/publish/:publishId/promote"]["POST"]["res"];
37
+ export type StatusResponse =
38
+ ContentPublishApi["/publish/:publishId"]["GET"]["res"];
39
+ export type PublishTokenResponse =
40
+ ContentPublishApi["/publish-token"]["POST"]["res"];
41
+
42
+ /** An artifact, as it is declared: by key, by hash, by size. */
43
+ export type DeclaredArtifact = DeclareBody["artifacts"][number];
44
+ /** Permission to write one artifact's bytes to object storage. */
45
+ export type UploadSlot = DeclareResponse["uploads"][number];
46
+
47
+ const publishState: z.ZodType<PublishState> = z.enum([
48
+ "awaiting-artifacts",
49
+ "ready",
50
+ "verified",
51
+ "live",
52
+ "failed",
53
+ "expired",
54
+ ]);
55
+
56
+ const publishProblem: z.ZodType<PublishProblem> = z.object({
57
+ code: z.string(),
58
+ message: z.string(),
59
+ hint: z.string().optional(),
60
+ keys: z.array(z.string()).optional(),
61
+ });
62
+
63
+ const uploadSlot: z.ZodType<UploadSlot> = z.object({
64
+ key: z.string(),
65
+ url: z.string(),
66
+ method: z.literal("PUT"),
67
+ headers: z.record(z.string(), z.string()),
68
+ expiresAt: z.string(),
69
+ });
70
+
71
+ const declareResponse: z.ZodType<DeclareResponse> = z.object({
72
+ publishId: z.string(),
73
+ state: publishState,
74
+ project: z.object({
75
+ publicProjectId: z.string(),
76
+ siteUrl: z.string().nullable(),
77
+ }),
78
+ uploads: z.array(uploadSlot),
79
+ have: z.array(z.string()),
80
+ });
81
+
82
+ const artifactsResponse: z.ZodType<ArtifactsResponse> = z.object({
83
+ state: publishState,
84
+ problems: z.array(publishProblem),
85
+ });
86
+
87
+ const verifyResponse: z.ZodType<VerifyResponse> = z.object({
88
+ state: publishState,
89
+ ok: z.boolean(),
90
+ previewUrl: z.string().nullable(),
91
+ problems: z.array(publishProblem),
92
+ });
93
+
94
+ const promoteResponse: z.ZodType<PromoteResponse> = z.object({
95
+ state: publishState,
96
+ url: z.string().nullable(),
97
+ commit: z.string().nullable(),
98
+ });
99
+
100
+ const statusResponse: z.ZodType<StatusResponse> = z.object({
101
+ publishId: z.string(),
102
+ state: publishState,
103
+ buildHash: z.string(),
104
+ missing: z.array(z.string()),
105
+ problems: z.array(publishProblem),
106
+ });
107
+
108
+ const publishTokenResponse: z.ZodType<PublishTokenResponse> = z.object({
109
+ token: z.string(),
110
+ expiresAt: z.string().nullable(),
111
+ publicProjectId: z.string(),
112
+ productionUrl: z.string().nullable(),
113
+ });
114
+
115
+ /**
116
+ * Content answered, and the answer was not one this CLI can act on.
117
+ *
118
+ * Separate from a refusal, which is content saying no in a sentence meant for
119
+ * a person. This is two programs disagreeing - the copy in `contentApi.ts` and
120
+ * the service that has moved on from it - and the two want opposite things
121
+ * said about them.
122
+ */
123
+ export class PublishProtocolError extends Error {
124
+ constructor(message: string) {
125
+ super(message);
126
+ this.name = "PublishProtocolError";
127
+ }
128
+ }
129
+
130
+ export const parseDeclare = (body: unknown): DeclareResponse =>
131
+ parse(declareResponse, body, "POST /v1/publish");
132
+
133
+ export const parseArtifacts = (body: unknown): ArtifactsResponse =>
134
+ parse(artifactsResponse, body, "POST /v1/publish/{id}/artifacts");
135
+
136
+ export const parseVerify = (body: unknown): VerifyResponse =>
137
+ parse(verifyResponse, body, "POST /v1/publish/{id}/verify");
138
+
139
+ export const parsePromote = (body: unknown): PromoteResponse =>
140
+ parse(promoteResponse, body, "POST /v1/publish/{id}/promote");
141
+
142
+ export const parseStatus = (body: unknown): StatusResponse =>
143
+ parse(statusResponse, body, "GET /v1/publish/{id}");
144
+
145
+ export const parsePublishToken = (
146
+ body: unknown,
147
+ call: string,
148
+ ): PublishTokenResponse => parse(publishTokenResponse, body, call);
149
+
150
+ /**
151
+ * The `details` of a refusal, when it carries a list of problems.
152
+ *
153
+ * Every non-2xx is `{ statusCode, message, details? }`, and the publish routes
154
+ * put `PublishProblem[]` in `details` - a declaration is answered with EVERY
155
+ * problem rather than the first, because finding them one round trip at a time
156
+ * is how a publish takes six attempts. Anything else in there is somebody
157
+ * else's `details` and is left to the caller's own message.
158
+ */
159
+ export function parseProblems(details: unknown): PublishProblem[] {
160
+ const parsed = z.array(publishProblem).safeParse(details);
161
+ return parsed.success ? parsed.data : [];
162
+ }
163
+
164
+ function parse<T>(schema: z.ZodType<T>, body: unknown, call: string): T {
165
+ const parsed = schema.safeParse(body);
166
+ if (!parsed.success) {
167
+ throw new PublishProtocolError(
168
+ `${call} answered with something this CLI cannot read: ${fromError(
169
+ parsed.error,
170
+ ).toString()}`,
171
+ );
172
+ }
173
+ return parsed.data;
174
+ }