@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.
- package/CHANGELOG.md +58 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +1424 -0
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1424 -0
- package/cli/dist/valbuild-cli-cli.esm.js +1422 -0
- package/package.json +7 -5
- package/src/cli.ts +51 -0
- package/src/publish/artifacts.ts +194 -0
- package/src/publish/client.ts +177 -0
- package/src/publish/contentApi.ts +210 -0
- package/src/publish/contentHost.ts +160 -0
- package/src/publish/credentials.test.ts +348 -0
- package/src/publish/credentials.ts +258 -0
- package/src/publish/fakeContent.ts +482 -0
- package/src/publish/protocol.ts +174 -0
- package/src/publish/publishWireContract.test.ts +292 -0
- package/src/publish/runPublish.test.ts +536 -0
- package/src/publish/runPublish.ts +633 -0
- package/src/publish.ts +112 -0
|
@@ -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
|
+
}
|