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