@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.
@@ -15,6 +15,7 @@ var JSZip = require('jszip');
15
15
  var readline = require('readline');
16
16
  var crypto = require('crypto');
17
17
  var zlib = require('zlib');
18
+ var os = require('os');
18
19
 
19
20
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
20
21
 
@@ -47,6 +48,7 @@ var JSZip__default = /*#__PURE__*/_interopDefault(JSZip);
47
48
  var readline__default = /*#__PURE__*/_interopDefault(readline);
48
49
  var crypto__default = /*#__PURE__*/_interopDefault(crypto);
49
50
  var zlib__default = /*#__PURE__*/_interopDefault(zlib);
51
+ var os__default = /*#__PURE__*/_interopDefault(os);
50
52
 
51
53
  function error(message) {
52
54
  console.error(chalk__default["default"].red("❌Error: ") + message);
@@ -2129,10 +2131,10 @@ function confirm(question) {
2129
2131
  * imports it at, so a normalised name produces a bundle whose imports resolve
2130
2132
  * to nothing, at runtime, in the isolate.
2131
2133
  *
2132
- * **This is the seam with the build.** `val publish` uploads artifacts; it does
2133
- * not produce them. Whatever builds the project - today the platform's own
2134
- * builder, which is where the wire, rolldown and dependency-layer steps live -
2135
- * writes this directory, and this reads it.
2134
+ * **This is the seam with the build.** Whatever built the project writes this
2135
+ * directory, and this reads it: `buildArtifacts` when `val publish` builds the
2136
+ * checkout itself, or anything else that lays out the same keys, handed over
2137
+ * with `--artifacts`. One path for bytes to leave by, whoever built them.
2136
2138
  */
2137
2139
  async function collectArtifacts(dir) {
2138
2140
  const artifacts = [];
@@ -2284,6 +2286,308 @@ function resolveArtifactsDir(options) {
2284
2286
  };
2285
2287
  }
2286
2288
 
2289
+ /**
2290
+ * `val publish`'s build: the site, built from the checkout, as the artifacts
2291
+ * the publish API takes (valbuild/home, docs/app-mode.md, "Briefing:
2292
+ * val-publish" and "Flow B").
2293
+ *
2294
+ * The same builder the Studio runs in a tab -- `@valbuild/tanstack-build` --
2295
+ * plus the half a tab cannot run: the project's dependency layer, built from
2296
+ * its own `node_modules` (`@valbuild/tanstack-build/node`). So a CI checkout
2297
+ * and a Studio build of the same content produce the same site, and the layer
2298
+ * is the one thing only this side can make.
2299
+ *
2300
+ * Against content's build target, never the loader's: content is the only
2301
+ * address a publisher has, and it answers what the platform expects.
2302
+ *
2303
+ * Written to a directory, and then published by the upload path `val publish
2304
+ * --artifacts` has always had: one path for bytes to leave by, whoever built
2305
+ * them.
2306
+ */
2307
+
2308
+ /** What `val publish` hands on to the upload. */
2309
+
2310
+ /**
2311
+ * The builder, loaded only when a build is asked for: it brings rolldown's
2312
+ * native binary with it, and `val validate` has no use for one.
2313
+ */
2314
+ async function loadBuilder() {
2315
+ try {
2316
+ const [build, node] = await Promise.all([Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('@valbuild/tanstack-build')); }), Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('@valbuild/tanstack-build/node')); })]);
2317
+ return {
2318
+ status: "ok",
2319
+ build,
2320
+ node
2321
+ };
2322
+ } catch (e) {
2323
+ return {
2324
+ status: "error",
2325
+ 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>."
2326
+ };
2327
+ }
2328
+ }
2329
+
2330
+ /**
2331
+ * Never the project's own files, at any depth: installed packages. Dot
2332
+ * directories (`.git`, `.tanstack`, `.val`, `.output`) are tooling too.
2333
+ */
2334
+ const IGNORED_DIRS = new Set(["node_modules"]);
2335
+
2336
+ /**
2337
+ * Build output, at the project's root only: `src/routes/build/` is a route
2338
+ * called "build", not a previous build.
2339
+ */
2340
+ const ROOT_OUTPUT_DIRS = new Set(["dist", "build"]);
2341
+
2342
+ /** Lockfiles are JSON, and nothing imports them. */
2343
+ const ROOT_LOCKFILES = new Set(["package-lock.json"]);
2344
+ const CODE_FILE = /\.(tsx?|jsx?|mjs|cjs|css)$/;
2345
+
2346
+ /**
2347
+ * JSON is the project's too: `tsconfig.json` and `tsr.config.json` are read
2348
+ * by the build, a `.val.json` holds a `.jsonValues()` entry no import points
2349
+ * at, and a module may import one -- the starter's `val.server.ts` imports
2350
+ * `.prettierrc.json`, which is also why a dot FILE is read.
2351
+ */
2352
+ const JSON_FILE = /\.json$/;
2353
+
2354
+ /**
2355
+ * The project's source as it is STORED, which is what the Studio builds from:
2356
+ * every file this build read, less the stylesheets.
2357
+ *
2358
+ * This build compiles them, because it can run what a stylesheet asks for -- a
2359
+ * Tailwind `@plugin` is a module resolved and executed from `node_modules`. A
2360
+ * build in the browser cannot, and refuses the stylesheet rather than ship a
2361
+ * page that is quietly unstyled; the starter's `src/styles.css` has
2362
+ * `@plugin "@tailwindcss/typography"`. So a stored stylesheet made every
2363
+ * Studio publish after the first one fail. Without it, the Studio's build has
2364
+ * no CSS of its own and ships the live site's (`liveStylesheet` in the
2365
+ * Studio), which is right: a Studio save changes content and uploaded files,
2366
+ * never a stylesheet. It is also what the platform's own publish has always
2367
+ * stored.
2368
+ */
2369
+ function withoutStylesheets(sources) {
2370
+ return Object.fromEntries(Object.entries(sources).filter(([key]) => !key.endsWith(".css")));
2371
+ }
2372
+
2373
+ /**
2374
+ * The project's own files: its source as text, and the binaries it imports
2375
+ * (an image, a font) base64, as `BuildInput.assets` takes them. `public/` is
2376
+ * neither: it is served as is, and read by `readPublicFiles`.
2377
+ */
2378
+ function readProjectFiles(root, isAsset) {
2379
+ const sources = {};
2380
+ const assets = {};
2381
+ const walk = dir => {
2382
+ let entries;
2383
+ try {
2384
+ entries = fs__default$1["default"].readdirSync(dir, {
2385
+ withFileTypes: true
2386
+ });
2387
+ } catch {
2388
+ return;
2389
+ }
2390
+ for (const entry of entries) {
2391
+ const full = path__default["default"].join(dir, entry.name);
2392
+ if (entry.isDirectory()) {
2393
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
2394
+ if (dir === root && ROOT_OUTPUT_DIRS.has(entry.name)) continue;
2395
+ if (dir === root && entry.name === "public") continue;
2396
+ walk(full);
2397
+ continue;
2398
+ }
2399
+ const key = path__default["default"].relative(root, full).split(path__default["default"].sep).join("/");
2400
+ if (CODE_FILE.test(entry.name)) {
2401
+ sources[key] = fs__default$1["default"].readFileSync(full, "utf8");
2402
+ } else if (JSON_FILE.test(entry.name)) {
2403
+ if (!ROOT_LOCKFILES.has(key)) {
2404
+ sources[key] = fs__default$1["default"].readFileSync(full, "utf8");
2405
+ }
2406
+ } else if (isAsset(key)) {
2407
+ assets[key] = fs__default$1["default"].readFileSync(full).toString("base64");
2408
+ }
2409
+ }
2410
+ };
2411
+ walk(root);
2412
+ return {
2413
+ sources,
2414
+ assets
2415
+ };
2416
+ }
2417
+
2418
+ /** Files under `public/`, base64, keyed `public/<path>` as the build takes them. */
2419
+ function readPublicFiles(root) {
2420
+ const out = {};
2421
+ const base = path__default["default"].join(root, "public");
2422
+ const walk = dir => {
2423
+ let entries;
2424
+ try {
2425
+ entries = fs__default$1["default"].readdirSync(dir, {
2426
+ withFileTypes: true
2427
+ });
2428
+ } catch {
2429
+ return;
2430
+ }
2431
+ for (const entry of entries) {
2432
+ const full = path__default["default"].join(dir, entry.name);
2433
+ if (entry.isDirectory()) walk(full);else {
2434
+ const key = path__default["default"].relative(root, full).split(path__default["default"].sep).join("/");
2435
+ out[key] = fs__default$1["default"].readFileSync(full).toString("base64");
2436
+ }
2437
+ }
2438
+ };
2439
+ walk(base);
2440
+ return out;
2441
+ }
2442
+
2443
+ /** The project's build-time variables: `PUBLIC_*` reach the browser, by the builder's rule. */
2444
+ function publicEnv(env) {
2445
+ const out = {};
2446
+ for (const [key, value] of Object.entries(env)) {
2447
+ if (key.startsWith("PUBLIC_") && value !== undefined) out[key] = value;
2448
+ }
2449
+ return out;
2450
+ }
2451
+
2452
+ /**
2453
+ * The target, with this project's layer as built here rather than as content
2454
+ * last stored it: a build has to link against the layer it ships with.
2455
+ */
2456
+ function targetWithLayer(target, layer) {
2457
+ return {
2458
+ ...target,
2459
+ project: {
2460
+ ...target.project,
2461
+ rev: (layer === null || layer === void 0 ? void 0 : layer.rev) ?? null,
2462
+ modules: (layer === null || layer === void 0 ? void 0 : layer.modules) ?? {},
2463
+ css: (layer === null || layer === void 0 ? void 0 : layer.css) ?? {},
2464
+ workerOnly: (layer === null || layer === void 0 ? void 0 : layer.workerOnly) ?? []
2465
+ }
2466
+ };
2467
+ }
2468
+ function writeArtifact(dir, key, body) {
2469
+ const file = path__default["default"].join(dir, ...key.split("/"));
2470
+ fs__default$1["default"].mkdirSync(path__default["default"].dirname(file), {
2471
+ recursive: true
2472
+ });
2473
+ fs__default$1["default"].writeFileSync(file, body);
2474
+ }
2475
+ async function buildArtifacts(options) {
2476
+ const log = options.log ?? (() => {});
2477
+ const loaded = await loadBuilder();
2478
+ if (loaded.status === "error") return loaded;
2479
+ const {
2480
+ build: builder,
2481
+ node
2482
+ } = loaded;
2483
+ const {
2484
+ root,
2485
+ target
2486
+ } = options;
2487
+ const warnings = [];
2488
+ try {
2489
+ /*
2490
+ * Wired for the platform at publish time, as the Studio's builds are: the
2491
+ * wired `val.server.ts` reads its files from a module the BUILD emits, so
2492
+ * no checkout can carry it. `rebakeGit` would be the tab's spelling of the
2493
+ * same thing for a record that is already wired.
2494
+ */
2495
+ const {
2496
+ sources: onDisk,
2497
+ assets
2498
+ } = readProjectFiles(root, builder.isAsset);
2499
+ const wiring = builder.isValProject(onDisk) && !builder.isWired(onDisk) ? builder.wireUp(onDisk, {
2500
+ project: options.project,
2501
+ loader: options.contentHost,
2502
+ ...(options.git ? {
2503
+ git: options.git
2504
+ } : {})
2505
+ }) : null;
2506
+ // Less the one about the layer, which is the thing this goes on to build.
2507
+ warnings.push(...((wiring === null || wiring === void 0 ? void 0 : wiring.warnings) ?? []).filter(warning => !warning.includes("dependency layer built for it")));
2508
+ const sources = (wiring === null || wiring === void 0 ? void 0 : wiring.files) ?? onDisk;
2509
+ const projectSource = withoutStylesheets(sources);
2510
+
2511
+ // The dependency layer: from what the project imports, of what it declares.
2512
+ log("Building the dependency layer");
2513
+ const manifest = (() => {
2514
+ try {
2515
+ return JSON.parse(sources["package.json"] ?? "{}");
2516
+ } catch {
2517
+ return {};
2518
+ }
2519
+ })();
2520
+ const deps = node.layerDepsOf(manifest, sources);
2521
+ const css = await node.collectDependencyCss(root, node.cssSpecifiersOf(sources));
2522
+ const layer = await node.buildVendorLayer(root, deps, css, {
2523
+ base: target.base,
2524
+ // A project on the platform layers its own copies; only the platform's
2525
+ // own checkout carries pinned ones.
2526
+ resolvePlatformModule: () => null,
2527
+ // No built base layer here: the export audit is skipped, not failed.
2528
+ baseLayerDir: null
2529
+ });
2530
+ log("Building the site");
2531
+ const fileBased = await node.isFileBased(root);
2532
+ const files = fileBased ? await node.generateRouteTree(root, sources) : sources;
2533
+ /*
2534
+ * The project's copy of the splitter first, then the one beside this CLI
2535
+ * -- where a package manager puts an optional peer the project installed
2536
+ * but did not hoist. Neither is a build that does not split, which costs
2537
+ * size and not behaviour.
2538
+ */
2539
+ const splitter = (await node.routeSplitter(root)) ?? (typeof __dirname === "string" ? await node.routeSplitter(__dirname) : null);
2540
+ if (splitter === null) {
2541
+ warnings.push("Routes are not code-split: @tanstack/router-plugin is not installed.");
2542
+ }
2543
+ const built = await builder.buildUserApp({
2544
+ files,
2545
+ target: targetWithLayer(target, layer),
2546
+ env: publicEnv(options.env ?? process.env),
2547
+ publicFiles: readPublicFiles(root),
2548
+ assets,
2549
+ // The project's own files, without the generated tree or the
2550
+ // stylesheets: what the next publish -- the Studio's included -- starts
2551
+ // from. See `withoutStylesheets`.
2552
+ projectSource,
2553
+ loadCssModule: node.cssModuleLoader(root),
2554
+ ...(splitter ? {
2555
+ routeSplitter: splitter
2556
+ } : {}),
2557
+ rsc: target.project.rsc
2558
+ });
2559
+ const artifacts = await builder.publishArtifacts(built, {
2560
+ projectSource
2561
+ });
2562
+ const dir = options.outDir ?? fs__default$1["default"].mkdtempSync(path__default["default"].join(os__default["default"].tmpdir(), "val-publish-"));
2563
+ for (const artifact of artifacts) {
2564
+ writeArtifact(dir, artifact.key, artifact.body);
2565
+ }
2566
+ /*
2567
+ * The layer: sent when content does not hold this one, named when it
2568
+ * does. The rev is a digest of the built chunks, so "content has this" is
2569
+ * a fact rather than a hope.
2570
+ */
2571
+ let layerRev;
2572
+ if (layer === null) layerRev = null;else if (layer.rev === target.project.rev) layerRev = layer.rev;else writeArtifact(dir, "layer", zlib__default["default"].gzipSync(JSON.stringify(layer)));
2573
+ return {
2574
+ status: "built",
2575
+ dir,
2576
+ buildHash: built.hash,
2577
+ linksOwnCss: built.linksOwnCss,
2578
+ ...(layerRev !== undefined ? {
2579
+ layerRev
2580
+ } : {}),
2581
+ warnings
2582
+ };
2583
+ } catch (e) {
2584
+ return {
2585
+ status: "error",
2586
+ message: `The site could not be built: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`
2587
+ };
2588
+ }
2589
+ }
2590
+
2287
2591
  /**
2288
2592
  * The one host `val publish` talks to.
2289
2593
  *
@@ -2430,6 +2734,11 @@ function createPublishClient(options) {
2430
2734
  };
2431
2735
  const publishUrl = (publishId, step) => `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
2432
2736
  return {
2737
+ buildTarget: async () => internal.parseBuildTarget(await getJson({
2738
+ url: `${host}/v1/build-target`,
2739
+ headers,
2740
+ fetchImpl
2741
+ })),
2433
2742
  declare: async body => internal.parseDeclare(await postJson({
2434
2743
  url: `${host}/v1/publish`,
2435
2744
  headers,
@@ -2769,40 +3078,33 @@ const DECLARE_ROUNDS = 3;
2769
3078
  * that could reach either.
2770
3079
  */
2771
3080
  async function runPublish(options) {
3081
+ var _built;
2772
3082
  const env = options.env ?? process.env;
2773
3083
  const log = options.log ?? (line => console.log(line));
2774
3084
  const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
2775
3085
  const root = options.root ? path__default["default"].resolve(options.root) : process.cwd();
2776
- const dir = resolveArtifactsDir({
2777
- root,
2778
- ...(options.artifacts ? {
2779
- dir: options.artifacts
2780
- } : {})
2781
- });
2782
- if (dir.status === "error") {
3086
+
3087
+ /*
3088
+ * Upload-only when there is a build to upload: `--artifacts`, or the
3089
+ * directory it defaults to. Otherwise this builds the checkout -- which is
3090
+ * what a CI runner has, and `.val/` is never committed, so a fresh checkout
3091
+ * always builds.
3092
+ */
3093
+ const prebuilt = options.artifacts !== undefined || fs__default$1["default"].existsSync(path__default["default"].join(root, DEFAULT_ARTIFACTS_DIR));
3094
+ if (!prebuilt && !fs__default$1["default"].existsSync(path__default["default"].join(root, "package.json"))) {
2783
3095
  return {
2784
3096
  status: "error",
2785
- message: dir.message
3097
+ 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>"
2786
3098
  };
2787
3099
  }
2788
- const collected = await collectArtifacts(dir.dir);
2789
- for (const skipped of collected.skipped) {
2790
- log(`Skipped ${skipped}`);
2791
- }
2792
- if (collected.artifacts.length === 0) {
2793
- return {
2794
- status: "error",
2795
- message: `There is nothing in ${dir.dir}. Build the project before publishing it.`
2796
- };
3100
+ let collected = null;
3101
+ let layerRev = null;
3102
+ if (prebuilt) {
3103
+ const fromDir = await collectPrebuilt(root, options.artifacts, log);
3104
+ if (fromDir.status === "error") return fromDir;
3105
+ collected = fromDir.collected;
3106
+ layerRev = fromDir.layerRev;
2797
3107
  }
2798
- const layer = await layerRevOf(collected.artifacts);
2799
- if (layer.status === "error") {
2800
- return {
2801
- status: "error",
2802
- message: layer.message
2803
- };
2804
- }
2805
- const layerRev = options.layerRev ?? layer.layerRev;
2806
3108
  const git = await resolveGit({
2807
3109
  root,
2808
3110
  options,
@@ -2812,9 +3114,10 @@ async function runPublish(options) {
2812
3114
  return git;
2813
3115
  }
2814
3116
  const config = await server.findAndEvalValConfigFile(root).catch(() => null);
3117
+ const project = (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null;
2815
3118
  const credential = await resolvePublishCredential({
2816
3119
  root,
2817
- project: (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null,
3120
+ project,
2818
3121
  env,
2819
3122
  ...(options.fetchImpl ? {
2820
3123
  fetchImpl: options.fetchImpl
@@ -2826,16 +3129,52 @@ async function runPublish(options) {
2826
3129
  message: credential.message
2827
3130
  };
2828
3131
  }
3132
+ const contentHost = getContentHost(env);
2829
3133
  const client = createPublishClient({
2830
- host: getContentHost(env),
3134
+ host: contentHost,
2831
3135
  token: credential.credential.token,
2832
3136
  ...(options.fetchImpl ? {
2833
3137
  fetchImpl: options.fetchImpl
2834
3138
  } : {})
2835
3139
  });
2836
- const buildHash = options.buildHash ?? buildHashOf(collected.artifacts);
3140
+ let built = null;
3141
+ try {
3142
+ if (collected === null) {
3143
+ log("Asking content what to build against");
3144
+ const target = await client.buildTarget();
3145
+ const build = await buildArtifacts({
3146
+ root,
3147
+ target,
3148
+ project: project ?? "",
3149
+ contentHost,
3150
+ git: {
3151
+ commit: git.commit,
3152
+ branch: git.branch
3153
+ },
3154
+ env,
3155
+ log
3156
+ });
3157
+ if (build.status === "error") {
3158
+ return {
3159
+ status: "error",
3160
+ message: build.message
3161
+ };
3162
+ }
3163
+ built = build;
3164
+ for (const warning of build.warnings) log(`warn: ${warning}`);
3165
+ const fromBuild = await collectPrebuilt(root, build.dir, log);
3166
+ if (fromBuild.status === "error") return fromBuild;
3167
+ collected = fromBuild.collected;
3168
+ // Named when content already holds it; otherwise it is in the directory.
3169
+ layerRev = build.layerRev !== undefined ? build.layerRev : fromBuild.layerRev;
3170
+ }
3171
+ } catch (err) {
3172
+ return errorFrom(err);
3173
+ }
3174
+ const buildHash = options.buildHash ?? ((_built = built) === null || _built === void 0 ? void 0 : _built.buildHash) ?? buildHashOf(collected.artifacts);
2837
3175
  log(`${count(collected.artifacts.length, "artifact")}, ${formatBytes(collected.totalBytes)}` + `, at ${git.commit.slice(0, 7)} on ${git.branch}`);
2838
3176
  try {
3177
+ var _built2;
2839
3178
  return await publishDeclaredBuild({
2840
3179
  client,
2841
3180
  log,
@@ -2846,8 +3185,8 @@ async function runPublish(options) {
2846
3185
  buildHash,
2847
3186
  commit: git.commit,
2848
3187
  branch: git.branch,
2849
- layerRev,
2850
- linksOwnCss: options.linksOwnCss ?? null,
3188
+ layerRev: options.layerRev ?? layerRev,
3189
+ linksOwnCss: options.linksOwnCss ?? ((_built2 = built) === null || _built2 === void 0 ? void 0 : _built2.linksOwnCss) ?? null,
2851
3190
  artifacts: collected.artifacts.map(({
2852
3191
  key,
2853
3192
  sha256,
@@ -2862,7 +3201,53 @@ async function runPublish(options) {
2862
3201
  });
2863
3202
  } catch (err) {
2864
3203
  return errorFrom(err);
3204
+ } finally {
3205
+ // Ours to remove: a build is written to a fresh temporary directory.
3206
+ if (built !== null) {
3207
+ fs__default$1["default"].rmSync(built.dir, {
3208
+ recursive: true,
3209
+ force: true
3210
+ });
3211
+ }
3212
+ }
3213
+ }
3214
+
3215
+ /** A build on disk: its artifacts, and the layer it sends or names. */
3216
+ async function collectPrebuilt(root, artifacts, log) {
3217
+ const dir = resolveArtifactsDir({
3218
+ root,
3219
+ ...(artifacts ? {
3220
+ dir: artifacts
3221
+ } : {})
3222
+ });
3223
+ if (dir.status === "error") {
3224
+ return {
3225
+ status: "error",
3226
+ message: dir.message
3227
+ };
3228
+ }
3229
+ const collected = await collectArtifacts(dir.dir);
3230
+ for (const skipped of collected.skipped) {
3231
+ log(`Skipped ${skipped}`);
3232
+ }
3233
+ if (collected.artifacts.length === 0) {
3234
+ return {
3235
+ status: "error",
3236
+ message: `There is nothing in ${dir.dir}. Build the project before publishing it.`
3237
+ };
2865
3238
  }
3239
+ const layer = await layerRevOf(collected.artifacts);
3240
+ if (layer.status === "error") {
3241
+ return {
3242
+ status: "error",
3243
+ message: layer.message
3244
+ };
3245
+ }
3246
+ return {
3247
+ status: "ok",
3248
+ collected,
3249
+ layerRev: layer.layerRev
3250
+ };
2866
3251
  }
2867
3252
  async function publishDeclaredBuild(args) {
2868
3253
  const {
@@ -3356,6 +3741,125 @@ function printProblem(problem) {
3356
3741
  }
3357
3742
  }
3358
3743
 
3744
+ /**
3745
+ * `val ci-report` -- CI saying how its build of a commit went.
3746
+ *
3747
+ * The last step of a connected project's workflow, run whether the build
3748
+ * passed or not. Content learns a build failed from this and nothing else,
3749
+ * so it needs no GitHub permission to tell an editor "Published, not on the
3750
+ * site yet: the build failed" (valbuild/home, docs/app-mode.md, "Failures":
3751
+ * after the seal). The run's address goes with it, for "View run".
3752
+ *
3753
+ * Everything has a default from GitHub Actions' own environment -- the
3754
+ * commit it checked out, the branch it ran for, the run's URL -- so the step
3755
+ * is one line. Never fails the job over a report it could not send: the
3756
+ * build's own result is what the job's status is, and a report is advice.
3757
+ */
3758
+
3759
+ function ciReportFromEnv(options, env = process.env) {
3760
+ const status = options.status;
3761
+ if (status !== "failed" && status !== "succeeded") {
3762
+ return {
3763
+ status: "error",
3764
+ message: `--status is "failed" or "succeeded"${status === undefined ? "" : `, not "${status}"`}.`
3765
+ };
3766
+ }
3767
+ const commit = options.commit ?? env.GITHUB_SHA ?? env.VAL_GIT_COMMIT;
3768
+ if (!commit) {
3769
+ return {
3770
+ status: "error",
3771
+ message: "No commit to report on: pass --commit, or run this in GitHub Actions (GITHUB_SHA)."
3772
+ };
3773
+ }
3774
+ const branch = options.branch ?? env.GITHUB_REF_NAME ?? env.VAL_GIT_BRANCH;
3775
+ 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);
3776
+ return {
3777
+ status: "ok",
3778
+ report: {
3779
+ status,
3780
+ commit,
3781
+ ...(branch ? {
3782
+ branch
3783
+ } : {}),
3784
+ ...(url ? {
3785
+ url
3786
+ } : {})
3787
+ }
3788
+ };
3789
+ }
3790
+ async function reportCiRun(options) {
3791
+ const env = options.env ?? process.env;
3792
+ const config = await server.findAndEvalValConfigFile(options.root).catch(() => null);
3793
+ const credential = await resolvePublishCredential({
3794
+ root: options.root,
3795
+ project: (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null,
3796
+ env,
3797
+ ...(options.fetchImpl ? {
3798
+ fetchImpl: options.fetchImpl
3799
+ } : {})
3800
+ });
3801
+ if (credential.status === "error") {
3802
+ return {
3803
+ status: "error",
3804
+ message: credential.message
3805
+ };
3806
+ }
3807
+ const body = options.report;
3808
+ try {
3809
+ await postJson({
3810
+ url: `${getContentHost(env)}/v1/ci-runs`,
3811
+ headers: {
3812
+ Authorization: `Bearer ${credential.credential.token}`
3813
+ },
3814
+ body,
3815
+ ...(options.fetchImpl ? {
3816
+ fetchImpl: options.fetchImpl
3817
+ } : {})
3818
+ });
3819
+ return {
3820
+ status: "reported"
3821
+ };
3822
+ } catch (e) {
3823
+ return {
3824
+ status: "error",
3825
+ message: e instanceof ContentHostError ? `Content did not take the report (${e.statusCode}): ${e.message}` : e instanceof Error ? e.message : String(e)
3826
+ };
3827
+ }
3828
+ }
3829
+
3830
+ /**
3831
+ * `val ci-report` -- a thin shell around `reportCiRun`. It never fails the
3832
+ * job: the build's own result is what the job's status is, and a report that
3833
+ * could not be sent is said and then left.
3834
+ */
3835
+ async function ciReport(options) {
3836
+ const read = ciReportFromEnv({
3837
+ status: options.status,
3838
+ ...(options.commit ? {
3839
+ commit: options.commit
3840
+ } : {}),
3841
+ ...(options.branch ? {
3842
+ branch: options.branch
3843
+ } : {}),
3844
+ ...(options.url ? {
3845
+ url: options.url
3846
+ } : {})
3847
+ });
3848
+ if (read.status === "error") {
3849
+ console.error(pc__default["default"].yellow(`val ci-report: ${read.message}`));
3850
+ return;
3851
+ }
3852
+ const sent = await reportCiRun({
3853
+ root: options.root ?? process.cwd(),
3854
+ report: read.report
3855
+ });
3856
+ if (sent.status === "error") {
3857
+ console.error(pc__default["default"].yellow(`val ci-report: not reported. ${sent.message}`));
3858
+ return;
3859
+ }
3860
+ console.log(pc__default["default"].dim(`Reported the build of ${read.report.commit.slice(0, 7)} as ${read.report.status}.`));
3861
+ }
3862
+
3359
3863
  async function main() {
3360
3864
  const {
3361
3865
  input,
@@ -3398,19 +3902,35 @@ async function main() {
3398
3902
  Options:
3399
3903
  --root [root], -r [root] Set project root directory (default process.cwd())
3400
3904
 
3905
+ Command: ci-report
3906
+ Description: tell content how CI's build of a commit went, for a connected
3907
+ project's workflow. Run as its last step, whatever the build did:
3908
+ a failed build then reads "Published, not on the site yet" in the
3909
+ Studio, with a link to the run. Never fails the job.
3910
+ Options:
3911
+ --status [failed|succeeded] How the build went
3912
+ --commit [sha] The commit built (default GITHUB_SHA)
3913
+ --branch [name] Its branch (default GITHUB_REF_NAME, else the project's)
3914
+ --url [url] The run (default this GitHub Actions run)
3915
+
3401
3916
  Command: publish
3402
- Description: publish this project's build through content.val.build.
3403
- Declares the build's artifacts, uploads the ones content does not already hold,
3404
- and has content verify it by building and rendering a canary before the site
3405
- changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
3406
- .val/pat.json (which needs the project, as "<org>/<project>", in val.config or
3407
- VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
3408
- processes, and it is kept in shell history and in every CI log.
3917
+ Description: build this project and publish it through content.val.build.
3918
+ Asks content what to build against, builds the site and its dependency layer from
3919
+ the checkout (needs @valbuild/tanstack-build, rolldown, @tanstack/router-generator
3920
+ and @tanstack/router-plugin installed), declares the artifacts, uploads the ones
3921
+ content does not already hold, and has content verify it by building and rendering
3922
+ a canary before the site changes. With --artifacts, or a .val/publish directory,
3923
+ it publishes that build instead of making one. Authenticates with VAL_PROJECT_TOKEN,
3924
+ or with the "val login" token in .val/pat.json (which needs the project, as
3925
+ "<org>/<project>", in val.config or VAL_PROJECT). Never as a flag: an argument is
3926
+ visible to anyone who can list processes, and it is kept in shell history and in
3927
+ every CI log.
3409
3928
  Options:
3410
3929
  --root [root], -r [root] Set project root directory (default process.cwd())
3411
- --artifacts [dir] The built artifacts (default <root>/.val/publish). The path
3412
- of each file under it is its artifact key: server, client,
3413
- css, rsc, layer, or a path under chunk/server, chunk/client,
3930
+ --artifacts [dir] Publish a build made elsewhere (default: build the checkout,
3931
+ unless <root>/.val/publish exists). The path of each file
3932
+ under it is its artifact key: server, client, css, rsc,
3933
+ layer, source, or a path under chunk/server, chunk/client,
3414
3934
  chunk/rsc, asset, public
3415
3935
  --commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
3416
3936
  else GITHUB_SHA, else git HEAD)
@@ -3519,6 +4039,12 @@ async function main() {
3519
4039
  },
3520
4040
  verbose: {
3521
4041
  type: "boolean"
4042
+ },
4043
+ status: {
4044
+ type: "string"
4045
+ },
4046
+ url: {
4047
+ type: "string"
3522
4048
  }
3523
4049
  },
3524
4050
  hardRejection: false
@@ -3562,6 +4088,14 @@ async function main() {
3562
4088
  yes: flags.yes,
3563
4089
  verbose: flags.verbose
3564
4090
  });
4091
+ case "ci-report":
4092
+ return ciReport({
4093
+ root: flags.root,
4094
+ status: flags.status,
4095
+ commit: flags.commit,
4096
+ branch: flags.branch,
4097
+ url: flags.url
4098
+ });
3565
4099
  case "publish":
3566
4100
  return publish({
3567
4101
  root: flags.root,