@valbuild/cli 0.137.0 → 0.138.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.
@@ -6,13 +6,14 @@ import { glob } from 'fast-glob';
6
6
  import { Internal, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST } from '@valbuild/core';
7
7
  import { createPrettierFormatter, createService, createValModuleFileInspector, fixHandlers, createFixPatch, getModulePathRange, findJsonEntryFilePath, createJsonEntryPathMap, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, createDefaultValFSHost, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, formatPatchSourceError } from '@valbuild/server';
8
8
  import fs from 'fs/promises';
9
- import { resolveSchemaSourceFixes, parseDeclare, parseArtifacts, parseVerify, parsePromote, parseStatus, parsePublishToken, PublishProtocolError, parseProblems } from '@valbuild/shared/internal';
9
+ import { resolveSchemaSourceFixes, parseBuildTarget, parseDeclare, parseArtifacts, parseVerify, parsePromote, parseStatus, parsePublishToken, PublishProtocolError, parseProblems } from '@valbuild/shared/internal';
10
10
  import fs$1 from 'fs';
11
11
  import ts from 'typescript';
12
12
  import JSZip from 'jszip';
13
13
  import readline from 'readline';
14
14
  import crypto from 'crypto';
15
15
  import zlib from 'zlib';
16
+ import os from 'os';
16
17
 
17
18
  function error(message) {
18
19
  console.error(chalk.red("❌Error: ") + message);
@@ -2095,10 +2096,10 @@ function confirm(question) {
2095
2096
  * imports it at, so a normalised name produces a bundle whose imports resolve
2096
2097
  * to nothing, at runtime, in the isolate.
2097
2098
  *
2098
- * **This is the seam with the build.** `val publish` uploads artifacts; it does
2099
- * not produce them. Whatever builds the project - today the platform's own
2100
- * builder, which is where the wire, rolldown and dependency-layer steps live -
2101
- * writes this directory, and this reads it.
2099
+ * **This is the seam with the build.** Whatever built the project writes this
2100
+ * directory, and this reads it: `buildArtifacts` when `val publish` builds the
2101
+ * checkout itself, or anything else that lays out the same keys, handed over
2102
+ * with `--artifacts`. One path for bytes to leave by, whoever built them.
2102
2103
  */
2103
2104
  async function collectArtifacts(dir) {
2104
2105
  const artifacts = [];
@@ -2250,6 +2251,308 @@ function resolveArtifactsDir(options) {
2250
2251
  };
2251
2252
  }
2252
2253
 
2254
+ /**
2255
+ * `val publish`'s build: the site, built from the checkout, as the artifacts
2256
+ * the publish API takes (valbuild/home, docs/app-mode.md, "Briefing:
2257
+ * val-publish" and "Flow B").
2258
+ *
2259
+ * The same builder the Studio runs in a tab -- `@valbuild/tanstack-build` --
2260
+ * plus the half a tab cannot run: the project's dependency layer, built from
2261
+ * its own `node_modules` (`@valbuild/tanstack-build/node`). So a CI checkout
2262
+ * and a Studio build of the same content produce the same site, and the layer
2263
+ * is the one thing only this side can make.
2264
+ *
2265
+ * Against content's build target, never the loader's: content is the only
2266
+ * address a publisher has, and it answers what the platform expects.
2267
+ *
2268
+ * Written to a directory, and then published by the upload path `val publish
2269
+ * --artifacts` has always had: one path for bytes to leave by, whoever built
2270
+ * them.
2271
+ */
2272
+
2273
+ /** What `val publish` hands on to the upload. */
2274
+
2275
+ /**
2276
+ * The builder, loaded only when a build is asked for: it brings rolldown's
2277
+ * native binary with it, and `val validate` has no use for one.
2278
+ */
2279
+ async function loadBuilder() {
2280
+ try {
2281
+ const [build, node] = await Promise.all([import('@valbuild/tanstack-build'), import('@valbuild/tanstack-build/node')]);
2282
+ return {
2283
+ status: "ok",
2284
+ build,
2285
+ node
2286
+ };
2287
+ } catch (e) {
2288
+ return {
2289
+ status: "error",
2290
+ message: "`val publish` builds the site with @valbuild/tanstack-build and rolldown, and " + `they could not be loaded: ${e instanceof Error ? e.message : String(e)}\n\n` + "Install them in the project:\n\n" + " npm install --save-dev @valbuild/tanstack-build rolldown @tanstack/router-generator @tanstack/router-plugin\n\n" + "or publish a build made elsewhere with --artifacts <dir>."
2291
+ };
2292
+ }
2293
+ }
2294
+
2295
+ /**
2296
+ * Never the project's own files, at any depth: installed packages. Dot
2297
+ * directories (`.git`, `.tanstack`, `.val`, `.output`) are tooling too.
2298
+ */
2299
+ const IGNORED_DIRS = new Set(["node_modules"]);
2300
+
2301
+ /**
2302
+ * Build output, at the project's root only: `src/routes/build/` is a route
2303
+ * called "build", not a previous build.
2304
+ */
2305
+ const ROOT_OUTPUT_DIRS = new Set(["dist", "build"]);
2306
+
2307
+ /** Lockfiles are JSON, and nothing imports them. */
2308
+ const ROOT_LOCKFILES = new Set(["package-lock.json"]);
2309
+ const CODE_FILE = /\.(tsx?|jsx?|mjs|cjs|css)$/;
2310
+
2311
+ /**
2312
+ * JSON is the project's too: `tsconfig.json` and `tsr.config.json` are read
2313
+ * by the build, a `.val.json` holds a `.jsonValues()` entry no import points
2314
+ * at, and a module may import one -- the starter's `val.server.ts` imports
2315
+ * `.prettierrc.json`, which is also why a dot FILE is read.
2316
+ */
2317
+ const JSON_FILE = /\.json$/;
2318
+
2319
+ /**
2320
+ * The project's source as it is STORED, which is what the Studio builds from:
2321
+ * every file this build read, less the stylesheets.
2322
+ *
2323
+ * This build compiles them, because it can run what a stylesheet asks for -- a
2324
+ * Tailwind `@plugin` is a module resolved and executed from `node_modules`. A
2325
+ * build in the browser cannot, and refuses the stylesheet rather than ship a
2326
+ * page that is quietly unstyled; the starter's `src/styles.css` has
2327
+ * `@plugin "@tailwindcss/typography"`. So a stored stylesheet made every
2328
+ * Studio publish after the first one fail. Without it, the Studio's build has
2329
+ * no CSS of its own and ships the live site's (`liveStylesheet` in the
2330
+ * Studio), which is right: a Studio save changes content and uploaded files,
2331
+ * never a stylesheet. It is also what the platform's own publish has always
2332
+ * stored.
2333
+ */
2334
+ function withoutStylesheets(sources) {
2335
+ return Object.fromEntries(Object.entries(sources).filter(([key]) => !key.endsWith(".css")));
2336
+ }
2337
+
2338
+ /**
2339
+ * The project's own files: its source as text, and the binaries it imports
2340
+ * (an image, a font) base64, as `BuildInput.assets` takes them. `public/` is
2341
+ * neither: it is served as is, and read by `readPublicFiles`.
2342
+ */
2343
+ function readProjectFiles(root, isAsset) {
2344
+ const sources = {};
2345
+ const assets = {};
2346
+ const walk = dir => {
2347
+ let entries;
2348
+ try {
2349
+ entries = fs$1.readdirSync(dir, {
2350
+ withFileTypes: true
2351
+ });
2352
+ } catch {
2353
+ return;
2354
+ }
2355
+ for (const entry of entries) {
2356
+ const full = path.join(dir, entry.name);
2357
+ if (entry.isDirectory()) {
2358
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
2359
+ if (dir === root && ROOT_OUTPUT_DIRS.has(entry.name)) continue;
2360
+ if (dir === root && entry.name === "public") continue;
2361
+ walk(full);
2362
+ continue;
2363
+ }
2364
+ const key = path.relative(root, full).split(path.sep).join("/");
2365
+ if (CODE_FILE.test(entry.name)) {
2366
+ sources[key] = fs$1.readFileSync(full, "utf8");
2367
+ } else if (JSON_FILE.test(entry.name)) {
2368
+ if (!ROOT_LOCKFILES.has(key)) {
2369
+ sources[key] = fs$1.readFileSync(full, "utf8");
2370
+ }
2371
+ } else if (isAsset(key)) {
2372
+ assets[key] = fs$1.readFileSync(full).toString("base64");
2373
+ }
2374
+ }
2375
+ };
2376
+ walk(root);
2377
+ return {
2378
+ sources,
2379
+ assets
2380
+ };
2381
+ }
2382
+
2383
+ /** Files under `public/`, base64, keyed `public/<path>` as the build takes them. */
2384
+ function readPublicFiles(root) {
2385
+ const out = {};
2386
+ const base = path.join(root, "public");
2387
+ const walk = dir => {
2388
+ let entries;
2389
+ try {
2390
+ entries = fs$1.readdirSync(dir, {
2391
+ withFileTypes: true
2392
+ });
2393
+ } catch {
2394
+ return;
2395
+ }
2396
+ for (const entry of entries) {
2397
+ const full = path.join(dir, entry.name);
2398
+ if (entry.isDirectory()) walk(full);else {
2399
+ const key = path.relative(root, full).split(path.sep).join("/");
2400
+ out[key] = fs$1.readFileSync(full).toString("base64");
2401
+ }
2402
+ }
2403
+ };
2404
+ walk(base);
2405
+ return out;
2406
+ }
2407
+
2408
+ /** The project's build-time variables: `PUBLIC_*` reach the browser, by the builder's rule. */
2409
+ function publicEnv(env) {
2410
+ const out = {};
2411
+ for (const [key, value] of Object.entries(env)) {
2412
+ if (key.startsWith("PUBLIC_") && value !== undefined) out[key] = value;
2413
+ }
2414
+ return out;
2415
+ }
2416
+
2417
+ /**
2418
+ * The target, with this project's layer as built here rather than as content
2419
+ * last stored it: a build has to link against the layer it ships with.
2420
+ */
2421
+ function targetWithLayer(target, layer) {
2422
+ return {
2423
+ ...target,
2424
+ project: {
2425
+ ...target.project,
2426
+ rev: (layer === null || layer === void 0 ? void 0 : layer.rev) ?? null,
2427
+ modules: (layer === null || layer === void 0 ? void 0 : layer.modules) ?? {},
2428
+ css: (layer === null || layer === void 0 ? void 0 : layer.css) ?? {},
2429
+ workerOnly: (layer === null || layer === void 0 ? void 0 : layer.workerOnly) ?? []
2430
+ }
2431
+ };
2432
+ }
2433
+ function writeArtifact(dir, key, body) {
2434
+ const file = path.join(dir, ...key.split("/"));
2435
+ fs$1.mkdirSync(path.dirname(file), {
2436
+ recursive: true
2437
+ });
2438
+ fs$1.writeFileSync(file, body);
2439
+ }
2440
+ async function buildArtifacts(options) {
2441
+ const log = options.log ?? (() => {});
2442
+ const loaded = await loadBuilder();
2443
+ if (loaded.status === "error") return loaded;
2444
+ const {
2445
+ build: builder,
2446
+ node
2447
+ } = loaded;
2448
+ const {
2449
+ root,
2450
+ target
2451
+ } = options;
2452
+ const warnings = [];
2453
+ try {
2454
+ /*
2455
+ * Wired for the platform at publish time, as the Studio's builds are: the
2456
+ * wired `val.server.ts` reads its files from a module the BUILD emits, so
2457
+ * no checkout can carry it. `rebakeGit` would be the tab's spelling of the
2458
+ * same thing for a record that is already wired.
2459
+ */
2460
+ const {
2461
+ sources: onDisk,
2462
+ assets
2463
+ } = readProjectFiles(root, builder.isAsset);
2464
+ const wiring = builder.isValProject(onDisk) && !builder.isWired(onDisk) ? builder.wireUp(onDisk, {
2465
+ project: options.project,
2466
+ loader: options.contentHost,
2467
+ ...(options.git ? {
2468
+ git: options.git
2469
+ } : {})
2470
+ }) : null;
2471
+ // Less the one about the layer, which is the thing this goes on to build.
2472
+ warnings.push(...((wiring === null || wiring === void 0 ? void 0 : wiring.warnings) ?? []).filter(warning => !warning.includes("dependency layer built for it")));
2473
+ const sources = (wiring === null || wiring === void 0 ? void 0 : wiring.files) ?? onDisk;
2474
+ const projectSource = withoutStylesheets(sources);
2475
+
2476
+ // The dependency layer: from what the project imports, of what it declares.
2477
+ log("Building the dependency layer");
2478
+ const manifest = (() => {
2479
+ try {
2480
+ return JSON.parse(sources["package.json"] ?? "{}");
2481
+ } catch {
2482
+ return {};
2483
+ }
2484
+ })();
2485
+ const deps = node.layerDepsOf(manifest, sources);
2486
+ const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources));
2487
+ const layer = await node.buildVendorLayer(root, deps, css, {
2488
+ base: target.base,
2489
+ // A project on the platform layers its own copies; only the platform's
2490
+ // own checkout carries pinned ones.
2491
+ resolvePlatformModule: () => null,
2492
+ // No built base layer here: the export audit is skipped, not failed.
2493
+ baseLayerDir: null
2494
+ });
2495
+ log("Building the site");
2496
+ const fileBased = await node.isFileBased(root);
2497
+ const files = fileBased ? await node.generateRouteTree(root, sources) : sources;
2498
+ /*
2499
+ * The project's copy of the splitter first, then the one beside this CLI
2500
+ * -- where a package manager puts an optional peer the project installed
2501
+ * but did not hoist. Neither is a build that does not split, which costs
2502
+ * size and not behaviour.
2503
+ */
2504
+ const splitter = (await node.routeSplitter(root)) ?? (typeof __dirname === "string" ? await node.routeSplitter(__dirname) : null);
2505
+ if (splitter === null) {
2506
+ warnings.push("Routes are not code-split: @tanstack/router-plugin is not installed.");
2507
+ }
2508
+ const built = await builder.buildUserApp({
2509
+ files,
2510
+ target: targetWithLayer(target, layer),
2511
+ env: publicEnv(options.env ?? process.env),
2512
+ publicFiles: readPublicFiles(root),
2513
+ assets,
2514
+ // The project's own files, without the generated tree or the
2515
+ // stylesheets: what the next publish -- the Studio's included -- starts
2516
+ // from. See `withoutStylesheets`.
2517
+ projectSource,
2518
+ loadCssModule: node.cssModuleLoader(root),
2519
+ ...(splitter ? {
2520
+ routeSplitter: splitter
2521
+ } : {}),
2522
+ rsc: target.project.rsc
2523
+ });
2524
+ const artifacts = await builder.publishArtifacts(built, {
2525
+ projectSource
2526
+ });
2527
+ const dir = options.outDir ?? fs$1.mkdtempSync(path.join(os.tmpdir(), "val-publish-"));
2528
+ for (const artifact of artifacts) {
2529
+ writeArtifact(dir, artifact.key, artifact.body);
2530
+ }
2531
+ /*
2532
+ * The layer: sent when content does not hold this one, named when it
2533
+ * does. The rev is a digest of the built chunks, so "content has this" is
2534
+ * a fact rather than a hope.
2535
+ */
2536
+ let layerRev;
2537
+ if (layer === null) layerRev = null;else if (layer.rev === target.project.rev) layerRev = layer.rev;else writeArtifact(dir, "layer", zlib.gzipSync(JSON.stringify(layer)));
2538
+ return {
2539
+ status: "built",
2540
+ dir,
2541
+ buildHash: built.hash,
2542
+ linksOwnCss: built.linksOwnCss,
2543
+ ...(layerRev !== undefined ? {
2544
+ layerRev
2545
+ } : {}),
2546
+ warnings
2547
+ };
2548
+ } catch (e) {
2549
+ return {
2550
+ status: "error",
2551
+ message: `The site could not be built: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`
2552
+ };
2553
+ }
2554
+ }
2555
+
2253
2556
  /**
2254
2557
  * The one host `val publish` talks to.
2255
2558
  *
@@ -2396,6 +2699,11 @@ function createPublishClient(options) {
2396
2699
  };
2397
2700
  const publishUrl = (publishId, step) => `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
2398
2701
  return {
2702
+ buildTarget: async () => parseBuildTarget(await getJson({
2703
+ url: `${host}/v1/build-target`,
2704
+ headers,
2705
+ fetchImpl
2706
+ })),
2399
2707
  declare: async body => parseDeclare(await postJson({
2400
2708
  url: `${host}/v1/publish`,
2401
2709
  headers,
@@ -2735,40 +3043,33 @@ const DECLARE_ROUNDS = 3;
2735
3043
  * that could reach either.
2736
3044
  */
2737
3045
  async function runPublish(options) {
3046
+ var _built;
2738
3047
  const env = options.env ?? process.env;
2739
3048
  const log = options.log ?? (line => console.log(line));
2740
3049
  const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
2741
3050
  const root = options.root ? path.resolve(options.root) : process.cwd();
2742
- const dir = resolveArtifactsDir({
2743
- root,
2744
- ...(options.artifacts ? {
2745
- dir: options.artifacts
2746
- } : {})
2747
- });
2748
- if (dir.status === "error") {
3051
+
3052
+ /*
3053
+ * Upload-only when there is a build to upload: `--artifacts`, or the
3054
+ * directory it defaults to. Otherwise this builds the checkout -- which is
3055
+ * what a CI runner has, and `.val/` is never committed, so a fresh checkout
3056
+ * always builds.
3057
+ */
3058
+ const prebuilt = options.artifacts !== undefined || fs$1.existsSync(path.join(root, DEFAULT_ARTIFACTS_DIR));
3059
+ if (!prebuilt && !fs$1.existsSync(path.join(root, "package.json"))) {
2749
3060
  return {
2750
3061
  status: "error",
2751
- message: dir.message
3062
+ message: `Nothing to publish: ${root} has no package.json to build, and no --artifacts were given.\n\n` + "Run this in the project, or point at a build made elsewhere:\n\n" + " npx val publish --artifacts <directory>"
2752
3063
  };
2753
3064
  }
2754
- const collected = await collectArtifacts(dir.dir);
2755
- for (const skipped of collected.skipped) {
2756
- log(`Skipped ${skipped}`);
2757
- }
2758
- if (collected.artifacts.length === 0) {
2759
- return {
2760
- status: "error",
2761
- message: `There is nothing in ${dir.dir}. Build the project before publishing it.`
2762
- };
3065
+ let collected = null;
3066
+ let layerRev = null;
3067
+ if (prebuilt) {
3068
+ const fromDir = await collectPrebuilt(root, options.artifacts, log);
3069
+ if (fromDir.status === "error") return fromDir;
3070
+ collected = fromDir.collected;
3071
+ layerRev = fromDir.layerRev;
2763
3072
  }
2764
- const layer = await layerRevOf(collected.artifacts);
2765
- if (layer.status === "error") {
2766
- return {
2767
- status: "error",
2768
- message: layer.message
2769
- };
2770
- }
2771
- const layerRev = options.layerRev ?? layer.layerRev;
2772
3073
  const git = await resolveGit({
2773
3074
  root,
2774
3075
  options,
@@ -2778,9 +3079,10 @@ async function runPublish(options) {
2778
3079
  return git;
2779
3080
  }
2780
3081
  const config = await findAndEvalValConfigFile(root).catch(() => null);
3082
+ const project = (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null;
2781
3083
  const credential = await resolvePublishCredential({
2782
3084
  root,
2783
- project: (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null,
3085
+ project,
2784
3086
  env,
2785
3087
  ...(options.fetchImpl ? {
2786
3088
  fetchImpl: options.fetchImpl
@@ -2792,16 +3094,52 @@ async function runPublish(options) {
2792
3094
  message: credential.message
2793
3095
  };
2794
3096
  }
3097
+ const contentHost = getContentHost(env);
2795
3098
  const client = createPublishClient({
2796
- host: getContentHost(env),
3099
+ host: contentHost,
2797
3100
  token: credential.credential.token,
2798
3101
  ...(options.fetchImpl ? {
2799
3102
  fetchImpl: options.fetchImpl
2800
3103
  } : {})
2801
3104
  });
2802
- const buildHash = options.buildHash ?? buildHashOf(collected.artifacts);
3105
+ let built = null;
3106
+ try {
3107
+ if (collected === null) {
3108
+ log("Asking content what to build against");
3109
+ const target = await client.buildTarget();
3110
+ const build = await buildArtifacts({
3111
+ root,
3112
+ target,
3113
+ project: project ?? "",
3114
+ contentHost,
3115
+ git: {
3116
+ commit: git.commit,
3117
+ branch: git.branch
3118
+ },
3119
+ env,
3120
+ log
3121
+ });
3122
+ if (build.status === "error") {
3123
+ return {
3124
+ status: "error",
3125
+ message: build.message
3126
+ };
3127
+ }
3128
+ built = build;
3129
+ for (const warning of build.warnings) log(`warn: ${warning}`);
3130
+ const fromBuild = await collectPrebuilt(root, build.dir, log);
3131
+ if (fromBuild.status === "error") return fromBuild;
3132
+ collected = fromBuild.collected;
3133
+ // Named when content already holds it; otherwise it is in the directory.
3134
+ layerRev = build.layerRev !== undefined ? build.layerRev : fromBuild.layerRev;
3135
+ }
3136
+ } catch (err) {
3137
+ return errorFrom(err);
3138
+ }
3139
+ const buildHash = options.buildHash ?? ((_built = built) === null || _built === void 0 ? void 0 : _built.buildHash) ?? buildHashOf(collected.artifacts);
2803
3140
  log(`${count(collected.artifacts.length, "artifact")}, ${formatBytes(collected.totalBytes)}` + `, at ${git.commit.slice(0, 7)} on ${git.branch}`);
2804
3141
  try {
3142
+ var _built2;
2805
3143
  return await publishDeclaredBuild({
2806
3144
  client,
2807
3145
  log,
@@ -2812,8 +3150,8 @@ async function runPublish(options) {
2812
3150
  buildHash,
2813
3151
  commit: git.commit,
2814
3152
  branch: git.branch,
2815
- layerRev,
2816
- linksOwnCss: options.linksOwnCss ?? null,
3153
+ layerRev: options.layerRev ?? layerRev,
3154
+ linksOwnCss: options.linksOwnCss ?? ((_built2 = built) === null || _built2 === void 0 ? void 0 : _built2.linksOwnCss) ?? null,
2817
3155
  artifacts: collected.artifacts.map(({
2818
3156
  key,
2819
3157
  sha256,
@@ -2828,7 +3166,53 @@ async function runPublish(options) {
2828
3166
  });
2829
3167
  } catch (err) {
2830
3168
  return errorFrom(err);
3169
+ } finally {
3170
+ // Ours to remove: a build is written to a fresh temporary directory.
3171
+ if (built !== null) {
3172
+ fs$1.rmSync(built.dir, {
3173
+ recursive: true,
3174
+ force: true
3175
+ });
3176
+ }
3177
+ }
3178
+ }
3179
+
3180
+ /** A build on disk: its artifacts, and the layer it sends or names. */
3181
+ async function collectPrebuilt(root, artifacts, log) {
3182
+ const dir = resolveArtifactsDir({
3183
+ root,
3184
+ ...(artifacts ? {
3185
+ dir: artifacts
3186
+ } : {})
3187
+ });
3188
+ if (dir.status === "error") {
3189
+ return {
3190
+ status: "error",
3191
+ message: dir.message
3192
+ };
3193
+ }
3194
+ const collected = await collectArtifacts(dir.dir);
3195
+ for (const skipped of collected.skipped) {
3196
+ log(`Skipped ${skipped}`);
3197
+ }
3198
+ if (collected.artifacts.length === 0) {
3199
+ return {
3200
+ status: "error",
3201
+ message: `There is nothing in ${dir.dir}. Build the project before publishing it.`
3202
+ };
2831
3203
  }
3204
+ const layer = await layerRevOf(collected.artifacts);
3205
+ if (layer.status === "error") {
3206
+ return {
3207
+ status: "error",
3208
+ message: layer.message
3209
+ };
3210
+ }
3211
+ return {
3212
+ status: "ok",
3213
+ collected,
3214
+ layerRev: layer.layerRev
3215
+ };
2832
3216
  }
2833
3217
  async function publishDeclaredBuild(args) {
2834
3218
  const {
@@ -3322,6 +3706,125 @@ function printProblem(problem) {
3322
3706
  }
3323
3707
  }
3324
3708
 
3709
+ /**
3710
+ * `val ci-report` -- CI saying how its build of a commit went.
3711
+ *
3712
+ * The last step of a connected project's workflow, run whether the build
3713
+ * passed or not. Content learns a build failed from this and nothing else,
3714
+ * so it needs no GitHub permission to tell an editor "Published, not on the
3715
+ * site yet: the build failed" (valbuild/home, docs/app-mode.md, "Failures":
3716
+ * after the seal). The run's address goes with it, for "View run".
3717
+ *
3718
+ * Everything has a default from GitHub Actions' own environment -- the
3719
+ * commit it checked out, the branch it ran for, the run's URL -- so the step
3720
+ * is one line. Never fails the job over a report it could not send: the
3721
+ * build's own result is what the job's status is, and a report is advice.
3722
+ */
3723
+
3724
+ function ciReportFromEnv(options, env = process.env) {
3725
+ const status = options.status;
3726
+ if (status !== "failed" && status !== "succeeded") {
3727
+ return {
3728
+ status: "error",
3729
+ message: `--status is "failed" or "succeeded"${status === undefined ? "" : `, not "${status}"`}.`
3730
+ };
3731
+ }
3732
+ const commit = options.commit ?? env.GITHUB_SHA ?? env.VAL_GIT_COMMIT;
3733
+ if (!commit) {
3734
+ return {
3735
+ status: "error",
3736
+ message: "No commit to report on: pass --commit, or run this in GitHub Actions (GITHUB_SHA)."
3737
+ };
3738
+ }
3739
+ const branch = options.branch ?? env.GITHUB_REF_NAME ?? env.VAL_GIT_BRANCH;
3740
+ const url = options.url ?? (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined);
3741
+ return {
3742
+ status: "ok",
3743
+ report: {
3744
+ status,
3745
+ commit,
3746
+ ...(branch ? {
3747
+ branch
3748
+ } : {}),
3749
+ ...(url ? {
3750
+ url
3751
+ } : {})
3752
+ }
3753
+ };
3754
+ }
3755
+ async function reportCiRun(options) {
3756
+ const env = options.env ?? process.env;
3757
+ const config = await findAndEvalValConfigFile(options.root).catch(() => null);
3758
+ const credential = await resolvePublishCredential({
3759
+ root: options.root,
3760
+ project: (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null,
3761
+ env,
3762
+ ...(options.fetchImpl ? {
3763
+ fetchImpl: options.fetchImpl
3764
+ } : {})
3765
+ });
3766
+ if (credential.status === "error") {
3767
+ return {
3768
+ status: "error",
3769
+ message: credential.message
3770
+ };
3771
+ }
3772
+ const body = options.report;
3773
+ try {
3774
+ await postJson({
3775
+ url: `${getContentHost(env)}/v1/ci-runs`,
3776
+ headers: {
3777
+ Authorization: `Bearer ${credential.credential.token}`
3778
+ },
3779
+ body,
3780
+ ...(options.fetchImpl ? {
3781
+ fetchImpl: options.fetchImpl
3782
+ } : {})
3783
+ });
3784
+ return {
3785
+ status: "reported"
3786
+ };
3787
+ } catch (e) {
3788
+ return {
3789
+ status: "error",
3790
+ message: e instanceof ContentHostError ? `Content did not take the report (${e.statusCode}): ${e.message}` : e instanceof Error ? e.message : String(e)
3791
+ };
3792
+ }
3793
+ }
3794
+
3795
+ /**
3796
+ * `val ci-report` -- a thin shell around `reportCiRun`. It never fails the
3797
+ * job: the build's own result is what the job's status is, and a report that
3798
+ * could not be sent is said and then left.
3799
+ */
3800
+ async function ciReport(options) {
3801
+ const read = ciReportFromEnv({
3802
+ status: options.status,
3803
+ ...(options.commit ? {
3804
+ commit: options.commit
3805
+ } : {}),
3806
+ ...(options.branch ? {
3807
+ branch: options.branch
3808
+ } : {}),
3809
+ ...(options.url ? {
3810
+ url: options.url
3811
+ } : {})
3812
+ });
3813
+ if (read.status === "error") {
3814
+ console.error(pc.yellow(`val ci-report: ${read.message}`));
3815
+ return;
3816
+ }
3817
+ const sent = await reportCiRun({
3818
+ root: options.root ?? process.cwd(),
3819
+ report: read.report
3820
+ });
3821
+ if (sent.status === "error") {
3822
+ console.error(pc.yellow(`val ci-report: not reported. ${sent.message}`));
3823
+ return;
3824
+ }
3825
+ console.log(pc.dim(`Reported the build of ${read.report.commit.slice(0, 7)} as ${read.report.status}.`));
3826
+ }
3827
+
3325
3828
  async function main() {
3326
3829
  const {
3327
3830
  input,
@@ -3364,19 +3867,35 @@ async function main() {
3364
3867
  Options:
3365
3868
  --root [root], -r [root] Set project root directory (default process.cwd())
3366
3869
 
3870
+ Command: ci-report
3871
+ Description: tell content how CI's build of a commit went, for a connected
3872
+ project's workflow. Run as its last step, whatever the build did:
3873
+ a failed build then reads "Published, not on the site yet" in the
3874
+ Studio, with a link to the run. Never fails the job.
3875
+ Options:
3876
+ --status [failed|succeeded] How the build went
3877
+ --commit [sha] The commit built (default GITHUB_SHA)
3878
+ --branch [name] Its branch (default GITHUB_REF_NAME, else the project's)
3879
+ --url [url] The run (default this GitHub Actions run)
3880
+
3367
3881
  Command: publish
3368
- Description: publish this project's build through content.val.build.
3369
- Declares the build's artifacts, uploads the ones content does not already hold,
3370
- and has content verify it by building and rendering a canary before the site
3371
- changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
3372
- .val/pat.json (which needs the project, as "<org>/<project>", in val.config or
3373
- VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
3374
- processes, and it is kept in shell history and in every CI log.
3882
+ Description: build this project and publish it through content.val.build.
3883
+ Asks content what to build against, builds the site and its dependency layer from
3884
+ the checkout (needs @valbuild/tanstack-build, rolldown, @tanstack/router-generator
3885
+ and @tanstack/router-plugin installed), declares the artifacts, uploads the ones
3886
+ content does not already hold, and has content verify it by building and rendering
3887
+ a canary before the site changes. With --artifacts, or a .val/publish directory,
3888
+ it publishes that build instead of making one. Authenticates with VAL_PROJECT_TOKEN,
3889
+ or with the "val login" token in .val/pat.json (which needs the project, as
3890
+ "<org>/<project>", in val.config or VAL_PROJECT). Never as a flag: an argument is
3891
+ visible to anyone who can list processes, and it is kept in shell history and in
3892
+ every CI log.
3375
3893
  Options:
3376
3894
  --root [root], -r [root] Set project root directory (default process.cwd())
3377
- --artifacts [dir] The built artifacts (default <root>/.val/publish). The path
3378
- of each file under it is its artifact key: server, client,
3379
- css, rsc, layer, or a path under chunk/server, chunk/client,
3895
+ --artifacts [dir] Publish a build made elsewhere (default: build the checkout,
3896
+ unless <root>/.val/publish exists). The path of each file
3897
+ under it is its artifact key: server, client, css, rsc,
3898
+ layer, source, or a path under chunk/server, chunk/client,
3380
3899
  chunk/rsc, asset, public
3381
3900
  --commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
3382
3901
  else GITHUB_SHA, else git HEAD)
@@ -3485,6 +4004,12 @@ async function main() {
3485
4004
  },
3486
4005
  verbose: {
3487
4006
  type: "boolean"
4007
+ },
4008
+ status: {
4009
+ type: "string"
4010
+ },
4011
+ url: {
4012
+ type: "string"
3488
4013
  }
3489
4014
  },
3490
4015
  hardRejection: false
@@ -3528,6 +4053,14 @@ async function main() {
3528
4053
  yes: flags.yes,
3529
4054
  verbose: flags.verbose
3530
4055
  });
4056
+ case "ci-report":
4057
+ return ciReport({
4058
+ root: flags.root,
4059
+ status: flags.status,
4060
+ commit: flags.commit,
4061
+ branch: flags.branch,
4062
+ url: flags.url
4063
+ });
3531
4064
  case "publish":
3532
4065
  return publish({
3533
4066
  root: flags.root,