@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.
@@ -13,6 +13,10 @@ var fs = require('fs');
13
13
  var ts = require('typescript');
14
14
  var JSZip = require('jszip');
15
15
  var readline = require('readline');
16
+ var crypto = require('crypto');
17
+ var zlib = require('zlib');
18
+ var zod = require('zod');
19
+ var zodValidationError = require('zod-validation-error');
16
20
 
17
21
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
18
22
 
@@ -43,6 +47,8 @@ var fs__default = /*#__PURE__*/_interopDefault(fs);
43
47
  var ts__default = /*#__PURE__*/_interopDefault(ts);
44
48
  var JSZip__default = /*#__PURE__*/_interopDefault(JSZip);
45
49
  var readline__default = /*#__PURE__*/_interopDefault(readline);
50
+ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
51
+ var zlib__default = /*#__PURE__*/_interopDefault(zlib);
46
52
 
47
53
  function error(message) {
48
54
  console.error(chalk__default["default"].red("❌Error: ") + message);
@@ -2045,6 +2051,1374 @@ function confirm(question) {
2045
2051
  });
2046
2052
  }
2047
2053
 
2054
+ /**
2055
+ * Where an artifact's bytes are, and what it is called on the wire.
2056
+ *
2057
+ * The key namespace is flat and is content's: `server`, `client`, `css`,
2058
+ * `rsc`, `layer`, and paths under `chunk/server/`, `chunk/client/`,
2059
+ * `chunk/rsc/`, `asset/` and `public/`. Content validates it and answers with
2060
+ * every problem at once, so this does not re-implement the rules - a second,
2061
+ * slightly different copy of them here is how a CLI comes to refuse a publish
2062
+ * the service would have taken.
2063
+ */
2064
+
2065
+ /**
2066
+ * The artifacts to publish, read from a directory laid out by key.
2067
+ *
2068
+ * The path under the directory IS the artifact key: a file at `public/app.css`
2069
+ * is the artifact `public/app.css`, and `server` is the server bundle. Nothing
2070
+ * is renamed on the way - an asset is addressed by the path the built code
2071
+ * imports it at, so a normalised name produces a bundle whose imports resolve
2072
+ * to nothing, at runtime, in the isolate.
2073
+ *
2074
+ * **This is the seam with the build.** `val publish` uploads artifacts; it does
2075
+ * not produce them. Whatever builds the project - today the platform's own
2076
+ * builder, which is where the wire, rolldown and dependency-layer steps live -
2077
+ * writes this directory, and this reads it.
2078
+ */
2079
+ async function collectArtifacts(dir) {
2080
+ const artifacts = [];
2081
+ const skipped = [];
2082
+ let totalBytes = 0;
2083
+ const walk = async current => {
2084
+ const entries = await fs__default["default"].promises.readdir(current, {
2085
+ withFileTypes: true
2086
+ });
2087
+ for (const entry of entries) {
2088
+ const absolute = path__default["default"].join(current, entry.name);
2089
+ const key = path__default["default"].relative(dir, absolute).split(path__default["default"].sep).join("/");
2090
+ if (entry.isDirectory()) {
2091
+ await walk(absolute);
2092
+ continue;
2093
+ }
2094
+ if (entry.isSymbolicLink()) {
2095
+ // A symlinked file is published as its bytes; a symlinked directory is
2096
+ // not walked, because one usually comes with a cycle, and a publisher
2097
+ // that hangs is worse than one that says what it left out.
2098
+ const stat = await fs__default["default"].promises.stat(absolute).catch(() => null);
2099
+ if (stat === null) {
2100
+ skipped.push(`${key} (broken symlink)`);
2101
+ continue;
2102
+ }
2103
+ if (!stat.isFile()) {
2104
+ skipped.push(`${key} (symlink to a ${stat.isDirectory() ? "directory" : "special file"})`);
2105
+ continue;
2106
+ }
2107
+ } else if (!entry.isFile()) {
2108
+ skipped.push(`${key} (not a regular file)`);
2109
+ continue;
2110
+ }
2111
+ const {
2112
+ sha256,
2113
+ bytes
2114
+ } = await hashFile(absolute);
2115
+ artifacts.push({
2116
+ key,
2117
+ sha256,
2118
+ bytes,
2119
+ file: absolute
2120
+ });
2121
+ totalBytes += bytes;
2122
+ }
2123
+ };
2124
+ await walk(dir);
2125
+ // Sorted so two publishes of one build declare the same list in the same
2126
+ // order, which is what makes the build hash below reproducible.
2127
+ artifacts.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
2128
+ return {
2129
+ artifacts,
2130
+ totalBytes,
2131
+ skipped
2132
+ };
2133
+ }
2134
+ async function hashFile(absolute) {
2135
+ const hash = crypto__default["default"].createHash("sha256");
2136
+ let bytes = 0;
2137
+ // Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
2138
+ // it is a needless way to run a CI runner out of memory.
2139
+ const stream = fs__default["default"].createReadStream(absolute);
2140
+ await new Promise((resolve, reject) => {
2141
+ stream.on("data", chunk => {
2142
+ hash.update(chunk);
2143
+ bytes += chunk.length;
2144
+ });
2145
+ stream.on("end", resolve);
2146
+ stream.on("error", reject);
2147
+ });
2148
+ return {
2149
+ sha256: hash.digest("hex"),
2150
+ bytes
2151
+ };
2152
+ }
2153
+
2154
+ /**
2155
+ * The build's own hash, when the build did not say.
2156
+ *
2157
+ * Content uses it for idempotency - declaring the same `buildHash` twice
2158
+ * returns the same publish, which is what makes a retried CI job resume rather
2159
+ * than start again - so it has to be a function of the build and nothing else.
2160
+ * Every artifact's key and hash, in order: two runs of one build agree, and
2161
+ * any change to any artifact is a different publish.
2162
+ */
2163
+ function buildHashOf(artifacts) {
2164
+ const hash = crypto__default["default"].createHash("sha256");
2165
+ for (const artifact of artifacts) {
2166
+ hash.update(`${artifact.key} ${artifact.sha256}\n`);
2167
+ }
2168
+ return hash.digest("hex");
2169
+ }
2170
+
2171
+ /**
2172
+ * Which dependency layer this build was built against.
2173
+ *
2174
+ * A layer is sent or named, never neither: the loader reads the head's layer
2175
+ * by rev, and a publish that declares no layer at all loses every dependency
2176
+ * on the next render. When the layer is being uploaded, its rev is inside it -
2177
+ * it is gzipped JSON with a `rev` - so there is nothing to ask the caller for.
2178
+ * When it is not, the build knows which stored layer it used, and says so with
2179
+ * `--layer-rev`.
2180
+ */
2181
+ async function layerRevOf(artifacts) {
2182
+ const layer = artifacts.find(artifact => artifact.key === "layer");
2183
+ if (!layer) {
2184
+ return {
2185
+ status: "ok",
2186
+ layerRev: null
2187
+ };
2188
+ }
2189
+ const bytes = await fs__default["default"].promises.readFile(layer.file);
2190
+ let parsed;
2191
+ try {
2192
+ parsed = JSON.parse(zlib__default["default"].gunzipSync(bytes).toString("utf-8"));
2193
+ } catch (err) {
2194
+ return {
2195
+ status: "error",
2196
+ message: `The layer artifact is not gzipped JSON: ${err instanceof Error ? err.message : String(err)}\n\n` + "A layer is `{ rev, worker, browser, ... }`, gzipped. Pass --layer-rev to\n" + "name a layer content already holds instead of sending one."
2197
+ };
2198
+ }
2199
+ const rev = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "rev") : undefined;
2200
+ if (typeof rev !== "string" || rev === "") {
2201
+ return {
2202
+ status: "error",
2203
+ message: "The layer artifact has no rev, and a layer that is sent has to say which\n" + "it is: that is the name the loader stores and later reuses it by."
2204
+ };
2205
+ }
2206
+ return {
2207
+ status: "ok",
2208
+ layerRev: rev
2209
+ };
2210
+ }
2211
+
2212
+ /** Where the artifacts are, if the caller did not say. */
2213
+ const DEFAULT_ARTIFACTS_DIR = ".val/publish";
2214
+ function resolveArtifactsDir(options) {
2215
+ const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
2216
+ const dir = path__default["default"].resolve(options.root, named);
2217
+ if (!fs__default["default"].existsSync(dir) || !fs__default["default"].statSync(dir).isDirectory()) {
2218
+ return {
2219
+ status: "error",
2220
+ message: `No artifacts to publish: ${dir} is not a directory.\n\n` + "Build the project first, then point at what it produced:\n\n" + " npx val publish --artifacts <directory>\n\n" + "The path of each file under it is its artifact key - `server`, `client`,\n" + "`public/...`, `chunk/client/...`, `layer`."
2221
+ };
2222
+ }
2223
+ return {
2224
+ status: "ok",
2225
+ dir
2226
+ };
2227
+ }
2228
+
2229
+ /**
2230
+ * The one host `val publish` talks to.
2231
+ *
2232
+ * Not a flag, and not several: publishing is a conversation with
2233
+ * content.val.build, which holds the operator relationship with whatever
2234
+ * actually serves the site and calls it on our behalf. A second host here
2235
+ * would be a second thing to configure in every repository that publishes,
2236
+ * and a second place for a credential to go.
2237
+ *
2238
+ * `VAL_CONTENT_URL` overrides it, as it does everywhere else in Val, so a
2239
+ * test can point the CLI at a fake and a developer at a local content service.
2240
+ */
2241
+ function getContentHost(env = process.env) {
2242
+ const configured = env.VAL_CONTENT_URL;
2243
+ if (!configured) {
2244
+ return core.DEFAULT_CONTENT_HOST;
2245
+ }
2246
+ // A trailing slash turns every path below into a double slash, which some
2247
+ // routers answer with a redirect and others with a 404.
2248
+ return configured.replace(/\/+$/, "");
2249
+ }
2250
+
2251
+ /**
2252
+ * A refusal from content, carrying the status so a caller can tell "your
2253
+ * credential is no good" (401/403) from "content is down" (5xx) and say
2254
+ * something different about each.
2255
+ */
2256
+ class ContentHostError extends Error {
2257
+ statusCode;
2258
+ /**
2259
+ * Whatever was in `details`, unread.
2260
+ *
2261
+ * The publish routes put a `PublishProblem[]` there - every problem with a
2262
+ * declaration rather than the first - and the older routes put a sentence.
2263
+ * Keeping it unparsed here lets each caller read the one it expects without
2264
+ * this file having to know about either.
2265
+ */
2266
+ details;
2267
+ /**
2268
+ * The whole answer.
2269
+ *
2270
+ * Some refusals carry a field of their own beside the message - a stale
2271
+ * pointer answers with the `head` the branch is at now, which is the one
2272
+ * thing that tells a publisher what happened - so the body is kept rather
2273
+ * than reduced to two strings on the way past.
2274
+ */
2275
+ body;
2276
+ constructor(statusCode, message, details, body) {
2277
+ super(message);
2278
+ this.name = "ContentHostError";
2279
+ this.statusCode = statusCode;
2280
+ this.details = details;
2281
+ this.body = body;
2282
+ }
2283
+ }
2284
+
2285
+ /** The `details` of a refusal, when it is a sentence rather than a list. */
2286
+ function detailText(details) {
2287
+ return typeof details === "string" && details !== "" ? details : null;
2288
+ }
2289
+
2290
+ /** GET JSON from content. Same envelope, same failures, one less body. */
2291
+ async function getJson(options) {
2292
+ return requestJson({
2293
+ method: "GET",
2294
+ ...options
2295
+ });
2296
+ }
2297
+
2298
+ /**
2299
+ * POST JSON to content and read JSON back.
2300
+ *
2301
+ * Content answers an error as `{ statusCode, message, details? }` (its
2302
+ * `sendResult`), so the message a user sees is the one the service wrote
2303
+ * rather than a status code they then have to look up. A body that is not
2304
+ * that shape - a proxy's HTML error page, say - still has to produce a
2305
+ * sentence, hence the fallback.
2306
+ */
2307
+ async function postJson(options) {
2308
+ return requestJson({
2309
+ method: "POST",
2310
+ ...options
2311
+ });
2312
+ }
2313
+ async function requestJson(options) {
2314
+ const fetchImpl = options.fetchImpl ?? fetch;
2315
+ let res;
2316
+ try {
2317
+ res = await fetchImpl(options.url, {
2318
+ method: options.method,
2319
+ headers: options.method === "GET" ? options.headers : {
2320
+ "Content-Type": "application/json",
2321
+ ...options.headers
2322
+ },
2323
+ ...(options.method === "GET" ? {} : {
2324
+ body: JSON.stringify(options.body ?? {})
2325
+ })
2326
+ });
2327
+ } catch (err) {
2328
+ // No status at all: DNS, TLS, a dropped connection. 0 is the shape the
2329
+ // rest of this file expects, and it is never a status a server sends.
2330
+ throw new ContentHostError(0, `Could not reach ${options.url}`, err instanceof Error ? err.message : String(err));
2331
+ }
2332
+ const text = await res.text();
2333
+ let parsed = undefined;
2334
+ if (text !== "") {
2335
+ try {
2336
+ parsed = JSON.parse(text);
2337
+ } catch {
2338
+ parsed = undefined;
2339
+ }
2340
+ }
2341
+ if (!res.ok) {
2342
+ throw new ContentHostError(res.status, errorMessageOf(parsed) ?? `${res.status} ${res.statusText}`, errorDetailsOf(parsed), parsed);
2343
+ }
2344
+ return parsed;
2345
+ }
2346
+ function errorMessageOf(body) {
2347
+ if (typeof body === "object" && body !== null && "message" in body) {
2348
+ const message = body.message;
2349
+ if (typeof message === "string" && message !== "") {
2350
+ return message;
2351
+ }
2352
+ }
2353
+ return undefined;
2354
+ }
2355
+ function errorDetailsOf(body) {
2356
+ if (typeof body === "object" && body !== null && "details" in body) {
2357
+ return body.details;
2358
+ }
2359
+ return undefined;
2360
+ }
2361
+
2362
+ /**
2363
+ * Reading what content answers.
2364
+ *
2365
+ * Every type here comes from `contentApi.ts`, which is a copy of the
2366
+ * service's own `Api.ts` - so the shapes are not re-derived from what this end
2367
+ * wishes it received, and a copy brought up to date fails to compile wherever
2368
+ * this CLI has not caught up.
2369
+ *
2370
+ * The schemas are the runtime half, and they are needed as well as the types:
2371
+ * what arrives is JSON over HTTP from a service that versions separately, so a
2372
+ * type alone asserts a shape rather than checking one. Each schema is
2373
+ * annotated with the copied type it must produce (`z.ZodType<DeclareResponse>`
2374
+ * and so on), which is what ties the two halves together - a schema that drifts
2375
+ * from the copy does not compile, and an answer that drifts from the schema
2376
+ * does not parse. `ValOpsHttp` in `@valbuild/server` reads every other content
2377
+ * route the same way.
2378
+ *
2379
+ * Nothing here is laxer than the copy. An optional field is optional because
2380
+ * the service says so; making a required one optional here would hide exactly
2381
+ * the drift this file exists to catch.
2382
+ */
2383
+
2384
+ /** An artifact, as it is declared: by key, by hash, by size. */
2385
+
2386
+ /** Permission to write one artifact's bytes to object storage. */
2387
+
2388
+ const publishState = zod.z.enum(["awaiting-artifacts", "ready", "verified", "live", "failed", "expired"]);
2389
+ const publishProblem = zod.z.object({
2390
+ code: zod.z.string(),
2391
+ message: zod.z.string(),
2392
+ hint: zod.z.string().optional(),
2393
+ keys: zod.z.array(zod.z.string()).optional()
2394
+ });
2395
+ const uploadSlot = zod.z.object({
2396
+ key: zod.z.string(),
2397
+ url: zod.z.string(),
2398
+ method: zod.z.literal("PUT"),
2399
+ headers: zod.z.record(zod.z.string(), zod.z.string()),
2400
+ expiresAt: zod.z.string()
2401
+ });
2402
+ const declareResponse = zod.z.object({
2403
+ publishId: zod.z.string(),
2404
+ state: publishState,
2405
+ project: zod.z.object({
2406
+ publicProjectId: zod.z.string(),
2407
+ siteUrl: zod.z.string().nullable()
2408
+ }),
2409
+ uploads: zod.z.array(uploadSlot),
2410
+ have: zod.z.array(zod.z.string())
2411
+ });
2412
+ const artifactsResponse = zod.z.object({
2413
+ state: publishState,
2414
+ problems: zod.z.array(publishProblem)
2415
+ });
2416
+ const verifyResponse = zod.z.object({
2417
+ state: publishState,
2418
+ ok: zod.z.boolean(),
2419
+ previewUrl: zod.z.string().nullable(),
2420
+ problems: zod.z.array(publishProblem)
2421
+ });
2422
+ const promoteResponse = zod.z.object({
2423
+ state: publishState,
2424
+ url: zod.z.string().nullable(),
2425
+ commit: zod.z.string().nullable()
2426
+ });
2427
+ const statusResponse = zod.z.object({
2428
+ publishId: zod.z.string(),
2429
+ state: publishState,
2430
+ buildHash: zod.z.string(),
2431
+ missing: zod.z.array(zod.z.string()),
2432
+ problems: zod.z.array(publishProblem)
2433
+ });
2434
+ const publishTokenResponse = zod.z.object({
2435
+ token: zod.z.string(),
2436
+ expiresAt: zod.z.string().nullable(),
2437
+ publicProjectId: zod.z.string(),
2438
+ productionUrl: zod.z.string().nullable()
2439
+ });
2440
+
2441
+ /**
2442
+ * Content answered, and the answer was not one this CLI can act on.
2443
+ *
2444
+ * Separate from a refusal, which is content saying no in a sentence meant for
2445
+ * a person. This is two programs disagreeing - the copy in `contentApi.ts` and
2446
+ * the service that has moved on from it - and the two want opposite things
2447
+ * said about them.
2448
+ */
2449
+ class PublishProtocolError extends Error {
2450
+ constructor(message) {
2451
+ super(message);
2452
+ this.name = "PublishProtocolError";
2453
+ }
2454
+ }
2455
+ const parseDeclare = body => parse(declareResponse, body, "POST /v1/publish");
2456
+ const parseArtifacts = body => parse(artifactsResponse, body, "POST /v1/publish/{id}/artifacts");
2457
+ const parseVerify = body => parse(verifyResponse, body, "POST /v1/publish/{id}/verify");
2458
+ const parsePromote = body => parse(promoteResponse, body, "POST /v1/publish/{id}/promote");
2459
+ const parseStatus = body => parse(statusResponse, body, "GET /v1/publish/{id}");
2460
+ const parsePublishToken = (body, call) => parse(publishTokenResponse, body, call);
2461
+
2462
+ /**
2463
+ * The `details` of a refusal, when it carries a list of problems.
2464
+ *
2465
+ * Every non-2xx is `{ statusCode, message, details? }`, and the publish routes
2466
+ * put `PublishProblem[]` in `details` - a declaration is answered with EVERY
2467
+ * problem rather than the first, because finding them one round trip at a time
2468
+ * is how a publish takes six attempts. Anything else in there is somebody
2469
+ * else's `details` and is left to the caller's own message.
2470
+ */
2471
+ function parseProblems(details) {
2472
+ const parsed = zod.z.array(publishProblem).safeParse(details);
2473
+ return parsed.success ? parsed.data : [];
2474
+ }
2475
+ function parse(schema, body, call) {
2476
+ const parsed = schema.safeParse(body);
2477
+ if (!parsed.success) {
2478
+ throw new PublishProtocolError(`${call} answered with something this CLI cannot read: ${zodValidationError.fromError(parsed.error).toString()}`);
2479
+ }
2480
+ return parsed.data;
2481
+ }
2482
+
2483
+ function createPublishClient(options) {
2484
+ const {
2485
+ host,
2486
+ token
2487
+ } = options;
2488
+ const fetchImpl = options.fetchImpl ?? fetch;
2489
+ // Every call, and nothing else: not a cookie, not the project's api key, not
2490
+ // a personal access token.
2491
+ const headers = {
2492
+ Authorization: `Bearer ${token}`
2493
+ };
2494
+ const publishUrl = (publishId, step) => `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
2495
+ return {
2496
+ declare: async body => parseDeclare(await postJson({
2497
+ url: `${host}/v1/publish`,
2498
+ headers,
2499
+ body,
2500
+ fetchImpl
2501
+ })),
2502
+ confirmArtifacts: async publishId => parseArtifacts(await postJson({
2503
+ url: publishUrl(publishId, "artifacts"),
2504
+ headers,
2505
+ fetchImpl
2506
+ })),
2507
+ verify: async publishId => parseVerify(await postJson({
2508
+ url: publishUrl(publishId, "verify"),
2509
+ headers,
2510
+ fetchImpl
2511
+ })),
2512
+ promote: async publishId => parsePromote(await postJson({
2513
+ url: publishUrl(publishId, "promote"),
2514
+ headers,
2515
+ fetchImpl
2516
+ })),
2517
+ status: async publishId => parseStatus(await getJson({
2518
+ url: publishUrl(publishId),
2519
+ headers,
2520
+ fetchImpl
2521
+ })),
2522
+ upload: (slot, file) => putArtifact(slot, file, fetchImpl)
2523
+ };
2524
+ }
2525
+
2526
+ /** Object storage refused the bytes, or could not be reached. */
2527
+ class UploadError extends Error {
2528
+ statusCode;
2529
+ key;
2530
+ constructor(statusCode, key, message) {
2531
+ super(message);
2532
+ this.name = "UploadError";
2533
+ this.statusCode = statusCode;
2534
+ this.key = key;
2535
+ }
2536
+ }
2537
+
2538
+ /**
2539
+ * A slot's permission to write has run out.
2540
+ *
2541
+ * Not a failed publish: declaring again mints fresh slots and the publish
2542
+ * carries on from where it was, which is why this is its own kind of failure
2543
+ * rather than an error message.
2544
+ */
2545
+ function isExpiredSlot(err) {
2546
+ return err.statusCode === 403 || err.statusCode === 401;
2547
+ }
2548
+ async function putArtifact(slot, file, fetchImpl) {
2549
+ /*
2550
+ * Read, rather than streamed.
2551
+ *
2552
+ * `ContentLength` is signed into the slot's URL, so the store rejects a body
2553
+ * of a different size - and a streamed body goes out chunked, which is a
2554
+ * different size as far as the signature is concerned. An artifact is capped
2555
+ * at 128 MB by the API, which is the bound this trades against.
2556
+ */
2557
+ const body = await fs__default["default"].promises.readFile(file);
2558
+ let res;
2559
+ try {
2560
+ res = await fetchImpl(slot.url, {
2561
+ method: slot.method,
2562
+ headers: slot.headers,
2563
+ body
2564
+ });
2565
+ } catch (err) {
2566
+ throw new UploadError(0, slot.key, `${slot.key}: ${err instanceof Error ? err.message : String(err)}`);
2567
+ }
2568
+ if (!res.ok) {
2569
+ // The store answers XML, and a whole error document in a CI log buries the
2570
+ // line that matters. The status is the actionable half.
2571
+ throw new UploadError(res.status, slot.key, `${slot.key}: object storage answered ${res.status} ${res.statusText}`);
2572
+ }
2573
+ }
2574
+
2575
+ /**
2576
+ * Run `worker` over `items`, `limit` at a time.
2577
+ *
2578
+ * A project can have hundreds of small artifacts, so one at a time is minutes
2579
+ * of latency and all at once is hundreds of open sockets on a CI runner. The
2580
+ * first failure stops the pool: there is no point uploading the rest of a
2581
+ * publish that is not going to be promoted.
2582
+ */
2583
+ async function pool(items, limit, worker) {
2584
+ let next = 0;
2585
+ let failure = undefined;
2586
+ const run = async () => {
2587
+ for (;;) {
2588
+ if (failure !== undefined) {
2589
+ return;
2590
+ }
2591
+ const index = next++;
2592
+ if (index >= items.length) {
2593
+ return;
2594
+ }
2595
+ try {
2596
+ await worker(items[index]);
2597
+ } catch (err) {
2598
+ failure = err;
2599
+ return;
2600
+ }
2601
+ }
2602
+ };
2603
+ await Promise.all(Array.from({
2604
+ length: Math.min(limit, items.length)
2605
+ }, () => run()));
2606
+ if (failure !== undefined) {
2607
+ throw failure;
2608
+ }
2609
+ }
2610
+
2611
+ /**
2612
+ * The credential `val publish` presents to content, and where it came from.
2613
+ *
2614
+ * Whatever the publisher started with, this is a *project* token: it can do one
2615
+ * thing to one project. A personal access token is a person's credential -
2616
+ * org-wide, read and write on every project they can reach - so it never
2617
+ * travels further than val.build, which exchanges it for one of these.
2618
+ */
2619
+
2620
+ const NO_CREDENTIAL = "Publishing needs a credential.\n\n" + " In CI: set VAL_PROJECT_TOKEN as a repository secret.\n" + " On your machine: run\n\n" + " npx val login\n\n" + "Neither is a command line flag, deliberately: an argument is visible to\n" + "anyone who can list processes, and it is kept in shell history and in the\n" + "log of every CI job that echoes its command line.";
2621
+
2622
+ /**
2623
+ * The credential, in the order the publisher should prefer them.
2624
+ *
2625
+ * 1. `VAL_PROJECT_TOKEN` from the environment - used as it is, no exchange.
2626
+ * This is what a repository created by `/new` holds, and the only secret it
2627
+ * needs: the token names its project, so content can answer *where* to
2628
+ * publish as well as *whether*.
2629
+ * 2. The `val login` token in `<root>/.val/pat.json` - exchanged first, because
2630
+ * what publishes must be a project token. The exchange is addressed by org
2631
+ * and project, since a personal access token does not name one; that is why
2632
+ * this way needs `project` and the first does not.
2633
+ * 3. Neither, which is not an error we can guess our way out of.
2634
+ *
2635
+ * Returns a result rather than throwing: "no credential" is one of the things a
2636
+ * publisher has to say well, and the sentence differs by which half was missing.
2637
+ */
2638
+ async function resolvePublishCredential(options) {
2639
+ var _env$VAL_PROJECT_TOKE;
2640
+ const env = options.env ?? process.env;
2641
+ // An unset secret reaches a CI job as the empty string rather than as absent,
2642
+ // and an empty credential presented to content is a 401 that reads as "your
2643
+ // token is bad" instead of "you never set one".
2644
+ const projectToken = (_env$VAL_PROJECT_TOKE = env.VAL_PROJECT_TOKEN) === null || _env$VAL_PROJECT_TOKE === void 0 ? void 0 : _env$VAL_PROJECT_TOKE.trim();
2645
+ if (projectToken) {
2646
+ if (looksLikePersonalAccessToken(projectToken)) {
2647
+ return {
2648
+ status: "error",
2649
+ message: "VAL_PROJECT_TOKEN looks like a personal access token, not a project\n" + "token. A project token begins with `val_pt_` and is made for one\n" + "project; a personal access token belongs to a person and cannot\n" + "publish. Mint a project token on the project's settings page."
2650
+ };
2651
+ }
2652
+ return {
2653
+ status: "ok",
2654
+ credential: {
2655
+ token: projectToken,
2656
+ origin: "VAL_PROJECT_TOKEN",
2657
+ // A standing token need not expire, and nothing here has been told
2658
+ // otherwise. Content is where an expiry would come from.
2659
+ expiresAt: null
2660
+ }
2661
+ };
2662
+ }
2663
+ const pat = readPersonalAccessToken(options.root);
2664
+ if (pat.status === "error") {
2665
+ return pat;
2666
+ }
2667
+ if (pat.status === "none") {
2668
+ var _ref;
2669
+ /*
2670
+ * The old names, still read.
2671
+ *
2672
+ * This is where the api key used to be passed, and this platform's own CI
2673
+ * still passes it: a rename that breaks the publisher is a rename that gets
2674
+ * reverted. It will not be accepted for long - publishing is being taken
2675
+ * out of what an api key may do - and content says so in its own words
2676
+ * when it refuses one, which is a better sentence than a guess here.
2677
+ */
2678
+ const legacy = (_ref = env.VAL_APP_TOKEN ?? env.PLATFORM_TOKEN) === null || _ref === void 0 ? void 0 : _ref.trim();
2679
+ if (legacy) {
2680
+ return {
2681
+ status: "ok",
2682
+ credential: {
2683
+ token: legacy,
2684
+ origin: "VAL_APP_TOKEN",
2685
+ expiresAt: null
2686
+ }
2687
+ };
2688
+ }
2689
+ return {
2690
+ status: "error",
2691
+ message: NO_CREDENTIAL
2692
+ };
2693
+ }
2694
+ if (!options.project) {
2695
+ return {
2696
+ status: "error",
2697
+ message: "You are logged in, but nothing says which project to publish.\n\n" + 'Set `project` ("<org>/<project>") in val.config, or the VAL_PROJECT\n' + "environment variable.\n\n" + "A `val login` token belongs to you rather than to a project, so it\n" + "cannot say which one you are standing in."
2698
+ };
2699
+ }
2700
+ const parts = options.project.split("/");
2701
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
2702
+ return {
2703
+ status: "error",
2704
+ message: `Invalid project: "${options.project}". Expected "<org>/<project>".`
2705
+ };
2706
+ }
2707
+ const [orgName, projectName] = parts;
2708
+ return exchangePersonalAccessToken({
2709
+ pat: pat.pat,
2710
+ orgName,
2711
+ projectName,
2712
+ env,
2713
+ ...(options.fetchImpl ? {
2714
+ fetchImpl: options.fetchImpl
2715
+ } : {})
2716
+ });
2717
+ }
2718
+
2719
+ /**
2720
+ * Trade the token you have for the token that may publish.
2721
+ *
2722
+ * Ten minutes, one project, one scope. The person's identity survives the
2723
+ * exchange even though the credential will not outlive the command, which is
2724
+ * what gives "who published this" an answer afterwards.
2725
+ */
2726
+ async function exchangePersonalAccessToken(options) {
2727
+ const host = getContentHost(options.env);
2728
+ const url = `${host}/v1/${encodeURIComponent(options.orgName)}/${encodeURIComponent(options.projectName)}/publish-token`;
2729
+ let body;
2730
+ try {
2731
+ body = await postJson({
2732
+ url,
2733
+ headers: {
2734
+ "x-val-pat": options.pat
2735
+ },
2736
+ ...(options.fetchImpl ? {
2737
+ fetchImpl: options.fetchImpl
2738
+ } : {})
2739
+ });
2740
+ } catch (err) {
2741
+ if (err instanceof ContentHostError) {
2742
+ return {
2743
+ status: "error",
2744
+ message: exchangeFailureMessage(err, options)
2745
+ };
2746
+ }
2747
+ throw err;
2748
+ }
2749
+ let exchanged;
2750
+ try {
2751
+ exchanged = parsePublishToken(body, `POST ${url}`);
2752
+ } catch (err) {
2753
+ if (err instanceof PublishProtocolError) {
2754
+ return {
2755
+ status: "error",
2756
+ message: err.message
2757
+ };
2758
+ }
2759
+ throw err;
2760
+ }
2761
+ return {
2762
+ status: "ok",
2763
+ credential: {
2764
+ token: exchanged.token,
2765
+ origin: "val login",
2766
+ expiresAt: exchanged.expiresAt
2767
+ }
2768
+ };
2769
+ }
2770
+ function exchangeFailureMessage(err, options) {
2771
+ const project = `${options.orgName}/${options.projectName}`;
2772
+ if (err.statusCode === 401) {
2773
+ return "Your Val login is no longer valid - it may have expired. Log in again:\n\n" + " npx val login";
2774
+ }
2775
+ if (err.statusCode === 403) {
2776
+ return `Your Val account may not publish ${project}: ${err.message}`;
2777
+ }
2778
+ if (err.statusCode === 404) {
2779
+ return `No project ${project}. Check \`project\` in val.config (or VAL_PROJECT):\n` + "it is the org and project as val.build names them, not the repository.";
2780
+ }
2781
+ return `Could not get a publish token for ${project}: ${err.message}` + (err.details ? `\n${err.details}` : "");
2782
+ }
2783
+
2784
+ /** A personal access token is 32 random bytes as hex, and carries no prefix. */
2785
+ function looksLikePersonalAccessToken(token) {
2786
+ return /^[0-9a-f]{64}$/i.test(token);
2787
+ }
2788
+ function readPersonalAccessToken(root) {
2789
+ const patFile = server.getPersonalAccessTokenPath(root);
2790
+ if (!fs__default["default"].existsSync(patFile)) {
2791
+ return {
2792
+ status: "none"
2793
+ };
2794
+ }
2795
+ const parsed = server.parsePersonalAccessTokenFile(fs__default["default"].readFileSync(patFile, "utf-8"));
2796
+ if (!parsed.success) {
2797
+ return {
2798
+ status: "error",
2799
+ message: `Could not read the Val login at ${patFile}: ${parsed.error}.\n` + "Log in again:\n\n npx val login"
2800
+ };
2801
+ }
2802
+ return {
2803
+ status: "ok",
2804
+ pat: parsed.data.pat
2805
+ };
2806
+ }
2807
+
2808
+ /** How many artifacts go up at once, and how often a lost one is retried. */
2809
+ const UPLOAD_CONCURRENCY = 6;
2810
+ const UPLOAD_ATTEMPTS = 3;
2811
+ /**
2812
+ * How many times the publish is declared before giving up.
2813
+ *
2814
+ * A round ends by declaring again, and there are two reasons to: a slot's hour
2815
+ * ran out mid-upload, or content re-hashed what landed and something did not
2816
+ * arrive as declared. Both are fixed by fresh slots and sending again. A third
2817
+ * round that still does not confirm is not a slow network.
2818
+ */
2819
+ const DECLARE_ROUNDS = 3;
2820
+ /**
2821
+ * Publish a build through content.val.build.
2822
+ *
2823
+ * Declare what the build is made of, upload the artifacts content does not
2824
+ * already hold, confirm, let content build and render a canary, and only then
2825
+ * ask it to promote. Every decision in that sequence is content's; this
2826
+ * carries bytes and reports back.
2827
+ *
2828
+ * Content is also the only host it talks to. Content holds the operator
2829
+ * relationship with the build platform and calls it during verify and promote,
2830
+ * which is what lets a repository publish with a token that can do nothing
2831
+ * else - there is no loader address here, no project id, and no credential
2832
+ * that could reach either.
2833
+ */
2834
+ async function runPublish(options) {
2835
+ const env = options.env ?? process.env;
2836
+ const log = options.log ?? (line => console.log(line));
2837
+ const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
2838
+ const root = options.root ? path__default["default"].resolve(options.root) : process.cwd();
2839
+ const dir = resolveArtifactsDir({
2840
+ root,
2841
+ ...(options.artifacts ? {
2842
+ dir: options.artifacts
2843
+ } : {})
2844
+ });
2845
+ if (dir.status === "error") {
2846
+ return {
2847
+ status: "error",
2848
+ message: dir.message
2849
+ };
2850
+ }
2851
+ const collected = await collectArtifacts(dir.dir);
2852
+ for (const skipped of collected.skipped) {
2853
+ log(`Skipped ${skipped}`);
2854
+ }
2855
+ if (collected.artifacts.length === 0) {
2856
+ return {
2857
+ status: "error",
2858
+ message: `There is nothing in ${dir.dir}. Build the project before publishing it.`
2859
+ };
2860
+ }
2861
+ const layer = await layerRevOf(collected.artifacts);
2862
+ if (layer.status === "error") {
2863
+ return {
2864
+ status: "error",
2865
+ message: layer.message
2866
+ };
2867
+ }
2868
+ const layerRev = options.layerRev ?? layer.layerRev;
2869
+ const git = await resolveGit({
2870
+ root,
2871
+ options,
2872
+ env
2873
+ });
2874
+ if (git.status === "error") {
2875
+ return git;
2876
+ }
2877
+ const config = await server.findAndEvalValConfigFile(root).catch(() => null);
2878
+ const credential = await resolvePublishCredential({
2879
+ root,
2880
+ project: (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null,
2881
+ env,
2882
+ ...(options.fetchImpl ? {
2883
+ fetchImpl: options.fetchImpl
2884
+ } : {})
2885
+ });
2886
+ if (credential.status === "error") {
2887
+ return {
2888
+ status: "error",
2889
+ message: credential.message
2890
+ };
2891
+ }
2892
+ const client = createPublishClient({
2893
+ host: getContentHost(env),
2894
+ token: credential.credential.token,
2895
+ ...(options.fetchImpl ? {
2896
+ fetchImpl: options.fetchImpl
2897
+ } : {})
2898
+ });
2899
+ const buildHash = options.buildHash ?? buildHashOf(collected.artifacts);
2900
+ log(`${count(collected.artifacts.length, "artifact")}, ${formatBytes(collected.totalBytes)}` + `, at ${git.commit.slice(0, 7)} on ${git.branch}`);
2901
+ try {
2902
+ return await publishDeclaredBuild({
2903
+ client,
2904
+ log,
2905
+ sleep,
2906
+ artifacts: collected.artifacts,
2907
+ totalBytes: collected.totalBytes,
2908
+ body: {
2909
+ buildHash,
2910
+ commit: git.commit,
2911
+ branch: git.branch,
2912
+ layerRev,
2913
+ linksOwnCss: options.linksOwnCss ?? null,
2914
+ artifacts: collected.artifacts.map(({
2915
+ key,
2916
+ sha256,
2917
+ bytes
2918
+ }) => ({
2919
+ key,
2920
+ sha256,
2921
+ bytes
2922
+ }))
2923
+ },
2924
+ dryRun: options.dryRun === true
2925
+ });
2926
+ } catch (err) {
2927
+ return errorFrom(err);
2928
+ }
2929
+ }
2930
+ async function publishDeclaredBuild(args) {
2931
+ const {
2932
+ client,
2933
+ log,
2934
+ sleep,
2935
+ artifacts,
2936
+ body,
2937
+ dryRun
2938
+ } = args;
2939
+ const fileOf = new Map(artifacts.map(artifact => [artifact.key, artifact.file]));
2940
+ const bytesOf = new Map(artifacts.map(artifact => [artifact.key, artifact.bytes]));
2941
+ const uploadedKeys = new Set();
2942
+ let uploadedBytes = 0;
2943
+ let publishId = null;
2944
+ let confirmed = false;
2945
+ for (let round = 1; round <= DECLARE_ROUNDS && !confirmed; round++) {
2946
+ const declared = await declare(client, body);
2947
+ if (declared.status === "refused") {
2948
+ return {
2949
+ status: "failed",
2950
+ publishId,
2951
+ state: null,
2952
+ message: declared.message,
2953
+ problems: declared.problems,
2954
+ previewUrl: null
2955
+ };
2956
+ }
2957
+ publishId = declared.response.publishId;
2958
+ if (declared.response.state === "live") {
2959
+ /*
2960
+ * Already published, and not re-opened - re-declaring would mint slots
2961
+ * to overwrite the bytes of a build that is currently serving. A CI job
2962
+ * re-run after a successful publish lands here, and it is a success.
2963
+ */
2964
+ log("This build is already live.");
2965
+ return {
2966
+ status: "live",
2967
+ publishId: declared.response.publishId,
2968
+ url: declared.response.project.siteUrl,
2969
+ commit: body.commit,
2970
+ artifacts: artifacts.length,
2971
+ uploaded: uploadedKeys.size,
2972
+ uploadedBytes,
2973
+ previewUrl: null
2974
+ };
2975
+ }
2976
+ const slots = declared.response.uploads;
2977
+ if (slots.length > 0) {
2978
+ const bytes = slots.reduce((sum, slot) => sum + (bytesOf.get(slot.key) ?? 0), 0);
2979
+ log(round === 1 ? `Uploading ${count(slots.length, "artifact")}, ${formatBytes(bytes)}` + ` (${declared.response.have.length} already held)` : `Uploading ${count(slots.length, "artifact")} again`);
2980
+ const outcome = await uploadAll({
2981
+ client,
2982
+ slots,
2983
+ fileOf,
2984
+ sleep
2985
+ });
2986
+ for (const key of outcome.uploaded) {
2987
+ if (!uploadedKeys.has(key)) {
2988
+ uploadedKeys.add(key);
2989
+ uploadedBytes += bytesOf.get(key) ?? 0;
2990
+ }
2991
+ }
2992
+ if (outcome.status === "expired") {
2993
+ // The hour on the slots ran out while we were using them. Declaring
2994
+ // again mints fresh ones; nothing about the publish has failed.
2995
+ log("Upload slots expired. Declaring again for fresh ones.");
2996
+ continue;
2997
+ }
2998
+ }
2999
+ const confirmation = await confirmArtifacts(client, declared.response.publishId);
3000
+ if (confirmation.status === "mismatch") {
3001
+ log(`Content did not receive ${count(confirmation.problems.length, "artifact")} as declared. Declaring again.`);
3002
+ if (round === DECLARE_ROUNDS) {
3003
+ return {
3004
+ status: "failed",
3005
+ publishId,
3006
+ state: null,
3007
+ message: "Some artifacts did not arrive as declared.",
3008
+ problems: confirmation.problems,
3009
+ previewUrl: null
3010
+ };
3011
+ }
3012
+ continue;
3013
+ }
3014
+ if (confirmation.state !== "ready") {
3015
+ return {
3016
+ status: "failed",
3017
+ publishId,
3018
+ state: confirmation.state,
3019
+ message: `Content left this publish in "${confirmation.state}" after confirming the artifacts.`,
3020
+ problems: confirmation.problems,
3021
+ previewUrl: null
3022
+ };
3023
+ }
3024
+ confirmed = true;
3025
+ }
3026
+ if (!confirmed || publishId === null) {
3027
+ return {
3028
+ status: "failed",
3029
+ publishId,
3030
+ state: null,
3031
+ message: `The artifacts were not confirmed after ${DECLARE_ROUNDS} attempts.`,
3032
+ problems: [],
3033
+ previewUrl: null
3034
+ };
3035
+ }
3036
+ log("Verifying: content is building and rendering this as a canary");
3037
+ let verified;
3038
+ try {
3039
+ verified = await client.verify(publishId);
3040
+ } catch (err) {
3041
+ verified = await recoverWithStatus(err, client, publishId);
3042
+ }
3043
+ const summary = {
3044
+ publishId,
3045
+ artifacts: artifacts.length,
3046
+ uploaded: uploadedKeys.size,
3047
+ uploadedBytes,
3048
+ previewUrl: "previewUrl" in verified ? verified.previewUrl : null
3049
+ };
3050
+ const verifiedOk = "ok" in verified ? verified.ok : verified.state === "verified";
3051
+ if (!verifiedOk) {
3052
+ return {
3053
+ status: "failed",
3054
+ publishId,
3055
+ state: verified.state,
3056
+ message: "The canary did not build and render, so nothing was promoted.",
3057
+ problems: verified.problems,
3058
+ previewUrl: summary.previewUrl
3059
+ };
3060
+ }
3061
+ if (dryRun) {
3062
+ log("Verified. Stopping here: --dry-run does not move the pointer.");
3063
+ return {
3064
+ status: "verified",
3065
+ ...summary
3066
+ };
3067
+ }
3068
+ const promoted = await promote(client, publishId);
3069
+ if (promoted.status === "stale") {
3070
+ return {
3071
+ status: "failed",
3072
+ publishId,
3073
+ state: null,
3074
+ message: promoted.message,
3075
+ problems: promoted.problems,
3076
+ previewUrl: summary.previewUrl
3077
+ };
3078
+ }
3079
+ if (promoted.response.state !== "live") {
3080
+ return {
3081
+ status: "failed",
3082
+ publishId,
3083
+ state: promoted.response.state,
3084
+ message: `The pointer was not moved: this publish is "${promoted.response.state}".`,
3085
+ problems: [],
3086
+ previewUrl: summary.previewUrl
3087
+ };
3088
+ }
3089
+ return {
3090
+ status: "live",
3091
+ url: promoted.response.url,
3092
+ commit: promoted.response.commit,
3093
+ ...summary
3094
+ };
3095
+ }
3096
+ async function declare(client, body) {
3097
+ try {
3098
+ return {
3099
+ status: "ok",
3100
+ response: await client.declare(body)
3101
+ };
3102
+ } catch (err) {
3103
+ // A malformed declaration is answered with EVERY problem rather than the
3104
+ // first, so all of them are carried through to the report.
3105
+ if (err instanceof ContentHostError && err.statusCode === 400) {
3106
+ return {
3107
+ status: "refused",
3108
+ message: err.message,
3109
+ problems: parseProblems(err.details)
3110
+ };
3111
+ }
3112
+ throw err;
3113
+ }
3114
+ }
3115
+ async function confirmArtifacts(client, publishId) {
3116
+ try {
3117
+ const response = await client.confirmArtifacts(publishId);
3118
+ return {
3119
+ status: "ok",
3120
+ state: response.state,
3121
+ problems: response.problems
3122
+ };
3123
+ } catch (err) {
3124
+ if (err instanceof ContentHostError && err.statusCode === 409) {
3125
+ // Either nothing is at that key or the bytes are not what was declared.
3126
+ // Both are fixed by fresh slots and sending again.
3127
+ return {
3128
+ status: "mismatch",
3129
+ problems: parseProblems(err.details)
3130
+ };
3131
+ }
3132
+ throw err;
3133
+ }
3134
+ }
3135
+ async function promote(client, publishId) {
3136
+ try {
3137
+ return {
3138
+ status: "ok",
3139
+ response: await client.promote(publishId)
3140
+ };
3141
+ } catch (err) {
3142
+ if (err instanceof ContentHostError && err.statusCode === 0) {
3143
+ // The pointer may well have moved; the answer saying so was lost. Ask.
3144
+ const status = await client.status(publishId);
3145
+ return {
3146
+ status: "ok",
3147
+ response: {
3148
+ state: status.state,
3149
+ url: null,
3150
+ commit: null
3151
+ }
3152
+ };
3153
+ }
3154
+ if (err instanceof ContentHostError && err.statusCode === 409) {
3155
+ /*
3156
+ * Somebody pushed while this was building.
3157
+ *
3158
+ * Content's own sentence already says what to do ("Rebuild from the
3159
+ * current head"), so it is passed through rather than replaced, and the
3160
+ * `POINTER_STALE` code and the loader's words come out of `details` the
3161
+ * way every other refusal's problems do.
3162
+ *
3163
+ * `head` is read if it is there and not required: the API's written
3164
+ * contract says a stale promote carries the sha the branch is at now,
3165
+ * and `postPublishPromote.ts` does not send it today. Naming the sha is
3166
+ * worth having when it arrives; failing without it would be this CLI
3167
+ * refusing an answer that is otherwise complete.
3168
+ */
3169
+ const head = stringField(err.body, "head");
3170
+ return {
3171
+ status: "stale",
3172
+ message: err.message + (head ? ` The branch is at ${head.slice(0, 7)} now.` : ""),
3173
+ problems: parseProblems(err.details)
3174
+ };
3175
+ }
3176
+ throw err;
3177
+ }
3178
+ }
3179
+
3180
+ /**
3181
+ * A step that did not answer is not a step that did not happen.
3182
+ *
3183
+ * Verify and promote can take minutes on content's side, and a connection that
3184
+ * dies in the middle says nothing about what content did. Problems are kept on
3185
+ * the row rather than only returned by the call that found them, so asking is
3186
+ * not retrying: one `GET` gets the same answer the lost response carried.
3187
+ */
3188
+ async function recoverWithStatus(err, client, publishId) {
3189
+ if (err instanceof ContentHostError && err.statusCode === 0) {
3190
+ return client.status(publishId);
3191
+ }
3192
+ throw err;
3193
+ }
3194
+ async function uploadAll(args) {
3195
+ const {
3196
+ client,
3197
+ slots,
3198
+ fileOf,
3199
+ sleep
3200
+ } = args;
3201
+ const uploaded = [];
3202
+ try {
3203
+ await pool(slots, UPLOAD_CONCURRENCY, async slot => {
3204
+ const file = fileOf.get(slot.key);
3205
+ if (file === undefined) {
3206
+ // Content asked for a key this build does not have. Declaring again
3207
+ // cannot fix that, so it is not a round; it is a bug on one side.
3208
+ throw new PublishProtocolError(`Content asked for an artifact this build does not have: ${slot.key}`);
3209
+ }
3210
+ for (let attempt = 1;; attempt++) {
3211
+ try {
3212
+ await client.upload(slot, file);
3213
+ uploaded.push(slot.key);
3214
+ return;
3215
+ } catch (err) {
3216
+ if (attempt >= UPLOAD_ATTEMPTS || !(err instanceof UploadError) || !isWorthRetrying(err)) {
3217
+ throw err;
3218
+ }
3219
+ await sleep(250 * 2 ** attempt);
3220
+ }
3221
+ }
3222
+ });
3223
+ } catch (err) {
3224
+ if (err instanceof UploadError && isExpiredSlot(err)) {
3225
+ return {
3226
+ status: "expired",
3227
+ uploaded
3228
+ };
3229
+ }
3230
+ throw err;
3231
+ }
3232
+ return {
3233
+ status: "ok",
3234
+ uploaded
3235
+ };
3236
+ }
3237
+
3238
+ /**
3239
+ * Whether to try the same slot again.
3240
+ *
3241
+ * A dropped connection or a busy store, yes. A 403 is an expired slot and will
3242
+ * be a 403 every time - that one is answered by declaring again, which is a
3243
+ * round rather than a retry.
3244
+ */
3245
+ function isWorthRetrying(err) {
3246
+ return err.statusCode === 0 || err.statusCode === 429 || err.statusCode >= 500;
3247
+ }
3248
+ function errorFrom(err) {
3249
+ if (err instanceof ContentHostError) {
3250
+ const detail = detailText(err.details);
3251
+ return {
3252
+ status: "error",
3253
+ message: err.statusCode === 401 || err.statusCode === 403 ? `${err.message}\n\nThe credential was refused. A project token can be revoked, and only a token carrying val:publish may publish.` : `${err.message}${detail ? `\n${detail}` : ""}`
3254
+ };
3255
+ }
3256
+ if (err instanceof UploadError || err instanceof PublishProtocolError) {
3257
+ return {
3258
+ status: "error",
3259
+ message: err.message
3260
+ };
3261
+ }
3262
+ throw err;
3263
+ }
3264
+
3265
+ /**
3266
+ * Which commit this build is of, and which branch content commits saves to.
3267
+ *
3268
+ * The commit is baked into the published server and decides which version of
3269
+ * its own content the site reads, so a wrong answer is worse than none: what
3270
+ * the caller said, then Val's own variables, then the CI runner, then git.
3271
+ *
3272
+ * `GITHUB_SHA` before `git rev-parse` because on a `pull_request` event the
3273
+ * checkout is a detached merge commit that exists only on the runner, and
3274
+ * content committed against it would be committed against nothing.
3275
+ */
3276
+ async function resolveGit(args) {
3277
+ const {
3278
+ root,
3279
+ options,
3280
+ env
3281
+ } = args;
3282
+ const fromGit = await server.safeReadGit(root);
3283
+ const commit = options.commit ?? env.VAL_GIT_COMMIT ?? env.GITHUB_SHA ?? fromGit.commit;
3284
+ const branchName = options.branch ?? env.VAL_GIT_BRANCH ?? env.GITHUB_REF_NAME ?? fromGit.branch;
3285
+ // A detached HEAD has no branch, and "HEAD" is not one: content commits
3286
+ // saves to a branch, and the failure would arrive hours later as a save that
3287
+ // cannot be published.
3288
+ const branch = branchName === "HEAD" ? undefined : branchName;
3289
+ if (!commit || !branch) {
3290
+ return {
3291
+ status: "error",
3292
+ message: `Could not tell which ${!commit ? "commit" : "branch"} this build is of.\n\n` + "Pass --commit and --branch, or set VAL_GIT_COMMIT and VAL_GIT_BRANCH.\n" + "The commit is baked into the published site and decides which version of\n" + "its own content it reads, so there is nothing safe to guess."
3293
+ };
3294
+ }
3295
+ return {
3296
+ status: "ok",
3297
+ commit,
3298
+ branch
3299
+ };
3300
+ }
3301
+ function stringField(body, key) {
3302
+ if (typeof body === "object" && body !== null) {
3303
+ const value = Reflect.get(body, key);
3304
+ if (typeof value === "string" && value !== "") {
3305
+ return value;
3306
+ }
3307
+ }
3308
+ return null;
3309
+ }
3310
+
3311
+ /** "1 artifact", "3 artifacts". Every one of these lines ends up in a CI log. */
3312
+ function count(n, noun) {
3313
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
3314
+ }
3315
+ function formatBytes(bytes) {
3316
+ if (bytes < 1024) {
3317
+ return `${bytes} B`;
3318
+ }
3319
+ const units = ["kB", "MB", "GB"];
3320
+ let value = bytes / 1024;
3321
+ let unit = 0;
3322
+ while (value >= 1024 && unit < units.length - 1) {
3323
+ value /= 1024;
3324
+ unit++;
3325
+ }
3326
+ return `${value.toFixed(1)} ${units[unit]}`;
3327
+ }
3328
+
3329
+ /**
3330
+ * `val publish` - a built project in, a live site out.
3331
+ *
3332
+ * A thin shell around `runPublish`: everything it decides is decided there,
3333
+ * and everything colourful happens here. A CI job reads the exit code, a
3334
+ * person reads the lines.
3335
+ */
3336
+ async function publish(options) {
3337
+ const result = await runPublish({
3338
+ ...(options.root ? {
3339
+ root: options.root
3340
+ } : {}),
3341
+ ...(options.artifacts ? {
3342
+ artifacts: options.artifacts
3343
+ } : {}),
3344
+ ...(options.commit ? {
3345
+ commit: options.commit
3346
+ } : {}),
3347
+ ...(options.branch ? {
3348
+ branch: options.branch
3349
+ } : {}),
3350
+ ...(options.layerRev ? {
3351
+ layerRev: options.layerRev
3352
+ } : {}),
3353
+ ...(options.buildHash ? {
3354
+ buildHash: options.buildHash
3355
+ } : {}),
3356
+ ...(options.linksOwnCss === undefined ? {} : {
3357
+ linksOwnCss: options.linksOwnCss
3358
+ }),
3359
+ ...(options.dryRun ? {
3360
+ dryRun: options.dryRun
3361
+ } : {}),
3362
+ log: line => console.log(pc__default["default"].dim(line))
3363
+ });
3364
+ switch (result.status) {
3365
+ case "live":
3366
+ case "verified":
3367
+ {
3368
+ const uploaded = result.uploaded === 0 ? "nothing new to upload" : `${result.uploaded} uploaded, ${formatBytes(result.uploadedBytes)}`;
3369
+ console.log(pc__default["default"].green(result.status === "live" ? "✅ Published" : "✅ Verified (not published: --dry-run)") + pc__default["default"].dim(` — ${result.artifacts} artifact${result.artifacts === 1 ? "" : "s"}, ${uploaded}`));
3370
+ if (result.status === "live" && result.url) {
3371
+ console.log(pc__default["default"].cyan(result.url));
3372
+ }
3373
+ if (result.previewUrl) {
3374
+ console.log(pc__default["default"].dim("Canary: ") + pc__default["default"].cyan(result.previewUrl));
3375
+ }
3376
+ return;
3377
+ }
3378
+ case "failed":
3379
+ {
3380
+ error(result.message);
3381
+ for (const problem of result.problems) {
3382
+ printProblem(problem);
3383
+ }
3384
+ if (result.previewUrl) {
3385
+ console.error(pc__default["default"].dim("Canary: ") + pc__default["default"].cyan(result.previewUrl));
3386
+ }
3387
+ if (result.publishId) {
3388
+ console.error(pc__default["default"].dim(`Publish ${result.publishId}${result.state ? ` is "${result.state}"` : ""}.`));
3389
+ }
3390
+ process.exitCode = 1;
3391
+ return;
3392
+ }
3393
+ case "error":
3394
+ {
3395
+ error(result.message);
3396
+ process.exitCode = 1;
3397
+ return;
3398
+ }
3399
+ }
3400
+ }
3401
+
3402
+ /**
3403
+ * A problem, as content wrote it.
3404
+ *
3405
+ * Verbatim, including the codes the build platform passed through: content
3406
+ * knows what went wrong with the canary and this does not, so rewording can
3407
+ * only lose the sentence that says what to change. The hint is printed for the
3408
+ * same reason - a gate that merely fails is useless.
3409
+ */
3410
+ function printProblem(problem) {
3411
+ console.error(pc__default["default"].red(problem.code ? ` ${problem.code}: ` : " ") + problem.message);
3412
+ const keys = problem.keys ?? [];
3413
+ if (keys.length > 0) {
3414
+ const shown = keys.slice(0, 10).join(", ");
3415
+ console.error(pc__default["default"].dim(` ${shown}${keys.length > 10 ? `, and ${keys.length - 10} more` : ""}`));
3416
+ }
3417
+ if (problem.hint) {
3418
+ console.error(pc__default["default"].dim(` ${problem.hint}`));
3419
+ }
3420
+ }
3421
+
2048
3422
  async function main() {
2049
3423
  const {
2050
3424
  input,
@@ -2062,6 +3436,7 @@ async function main() {
2062
3436
  login
2063
3437
  files
2064
3438
  connect
3439
+ publish
2065
3440
  versions
2066
3441
  lsp
2067
3442
  debug
@@ -2086,6 +3461,32 @@ async function main() {
2086
3461
  Options:
2087
3462
  --root [root], -r [root] Set project root directory (default process.cwd())
2088
3463
 
3464
+ Command: publish
3465
+ Description: publish this project's build through content.val.build.
3466
+ Declares the build's artifacts, uploads the ones content does not already hold,
3467
+ and has content verify it by building and rendering a canary before the site
3468
+ changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
3469
+ .val/pat.json (which needs the project, as "<org>/<project>", in val.config or
3470
+ VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
3471
+ processes, and it is kept in shell history and in every CI log.
3472
+ Options:
3473
+ --root [root], -r [root] Set project root directory (default process.cwd())
3474
+ --artifacts [dir] The built artifacts (default <root>/.val/publish). The path
3475
+ of each file under it is its artifact key: server, client,
3476
+ css, rsc, layer, or a path under chunk/server, chunk/client,
3477
+ chunk/rsc, asset, public
3478
+ --commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
3479
+ else GITHUB_SHA, else git HEAD)
3480
+ --branch [name] The branch content commits saves to (default VAL_GIT_BRANCH,
3481
+ else GITHUB_REF_NAME, else the current git branch)
3482
+ --layer-rev [rev] Name the dependency layer content already holds, when this
3483
+ build does not send one
3484
+ --build-hash [hash] Identify the build (default: a hash of its artifacts).
3485
+ Declaring the same one twice resumes that publish
3486
+ --links-own-css The app links its own CSS (--no-links-own-css for the
3487
+ opposite; omit it when the build did not say)
3488
+ --dry-run Verify, and stop before the site changes
3489
+
2089
3490
  Command: list-unused-files
2090
3491
  Description: EXPERIMENTAL.
2091
3492
  List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
@@ -2149,6 +3550,18 @@ async function main() {
2149
3550
  out: {
2150
3551
  type: "string"
2151
3552
  },
3553
+ artifacts: {
3554
+ type: "string"
3555
+ },
3556
+ layerRev: {
3557
+ type: "string"
3558
+ },
3559
+ buildHash: {
3560
+ type: "string"
3561
+ },
3562
+ linksOwnCss: {
3563
+ type: "boolean"
3564
+ },
2152
3565
  commit: {
2153
3566
  type: "string"
2154
3567
  },
@@ -2212,6 +3625,17 @@ async function main() {
2212
3625
  yes: flags.yes,
2213
3626
  verbose: flags.verbose
2214
3627
  });
3628
+ case "publish":
3629
+ return publish({
3630
+ root: flags.root,
3631
+ artifacts: flags.artifacts,
3632
+ commit: flags.commit,
3633
+ branch: flags.branch,
3634
+ layerRev: flags.layerRev,
3635
+ buildHash: flags.buildHash,
3636
+ linksOwnCss: flags.linksOwnCss,
3637
+ dryRun: flags.dryRun
3638
+ });
2215
3639
  case "login":
2216
3640
  return login({
2217
3641
  root: flags.root