@valbuild/cli 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.137.0",
4
+ "version": "0.138.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,15 +26,33 @@
26
26
  "picocolors": "^1.1.1",
27
27
  "zod": "^4.4.3",
28
28
  "zod-validation-error": "^5.0.0",
29
- "@valbuild/core": "0.137.0",
29
+ "@valbuild/core": "0.138.0",
30
30
  "@valbuild/eslint-plugin": "0.125.0",
31
- "@valbuild/server": "0.137.0",
32
- "@valbuild/shared": "0.137.0",
33
- "@valbuild/language-server": "0.137.0"
31
+ "@valbuild/language-server": "0.138.0",
32
+ "@valbuild/server": "0.138.0",
33
+ "@valbuild/shared": "0.138.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "prettier": "*",
37
- "typescript": ">=5.0.0"
37
+ "typescript": ">=5.0.0",
38
+ "rolldown": ">=1.2.5",
39
+ "@tanstack/router-generator": ">=1.167.0",
40
+ "@tanstack/router-plugin": ">=1.168.0",
41
+ "@valbuild/tanstack-build": "^0.136.10"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@valbuild/tanstack-build": {
45
+ "optional": true
46
+ },
47
+ "rolldown": {
48
+ "optional": true
49
+ },
50
+ "@tanstack/router-generator": {
51
+ "optional": true
52
+ },
53
+ "@tanstack/router-plugin": {
54
+ "optional": true
55
+ }
38
56
  },
39
57
  "preconstruct": {
40
58
  "entrypoints": [
@@ -47,8 +65,12 @@
47
65
  "devDependencies": {
48
66
  "@jest/globals": "^30.5.0",
49
67
  "@prettier/sync": "^0.6.1",
68
+ "@tanstack/router-generator": "1.167.32",
69
+ "@tanstack/router-plugin": "1.168.34",
50
70
  "prettier": "~3.8.5",
51
- "tsx": "^4.23.12"
71
+ "rolldown": "^1.2.5",
72
+ "tsx": "^4.23.12",
73
+ "@valbuild/tanstack-build": "0.136.10"
52
74
  },
53
75
  "scripts": {
54
76
  "start": "tsx src/cli.ts --",
@@ -0,0 +1,39 @@
1
+ import pc from "picocolors";
2
+ import { ciReportFromEnv, reportCiRun } from "./publish/reportCiRun";
3
+
4
+ /**
5
+ * `val ci-report` -- a thin shell around `reportCiRun`. It never fails the
6
+ * job: the build's own result is what the job's status is, and a report that
7
+ * could not be sent is said and then left.
8
+ */
9
+ export async function ciReport(options: {
10
+ root?: string;
11
+ status?: string;
12
+ commit?: string;
13
+ branch?: string;
14
+ url?: string;
15
+ }): Promise<void> {
16
+ const read = ciReportFromEnv({
17
+ status: options.status,
18
+ ...(options.commit ? { commit: options.commit } : {}),
19
+ ...(options.branch ? { branch: options.branch } : {}),
20
+ ...(options.url ? { url: options.url } : {}),
21
+ });
22
+ if (read.status === "error") {
23
+ console.error(pc.yellow(`val ci-report: ${read.message}`));
24
+ return;
25
+ }
26
+ const sent = await reportCiRun({
27
+ root: options.root ?? process.cwd(),
28
+ report: read.report,
29
+ });
30
+ if (sent.status === "error") {
31
+ console.error(pc.yellow(`val ci-report: not reported. ${sent.message}`));
32
+ return;
33
+ }
34
+ console.log(
35
+ pc.dim(
36
+ `Reported the build of ${read.report.commit.slice(0, 7)} as ${read.report.status}.`,
37
+ ),
38
+ );
39
+ }
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { lsp } from "./lsp";
10
10
  import { debug } from "./debug";
11
11
  import { deleteUnappliablePatches } from "./deleteUnappliablePatches";
12
12
  import { publish } from "./publish";
13
+ import { ciReport } from "./ciReport";
13
14
 
14
15
  async function main(): Promise<void> {
15
16
  const { input, flags, showHelp } = meow(
@@ -50,19 +51,35 @@ async function main(): Promise<void> {
50
51
  Options:
51
52
  --root [root], -r [root] Set project root directory (default process.cwd())
52
53
 
54
+ Command: ci-report
55
+ Description: tell content how CI's build of a commit went, for a connected
56
+ project's workflow. Run as its last step, whatever the build did:
57
+ a failed build then reads "Published, not on the site yet" in the
58
+ Studio, with a link to the run. Never fails the job.
59
+ Options:
60
+ --status [failed|succeeded] How the build went
61
+ --commit [sha] The commit built (default GITHUB_SHA)
62
+ --branch [name] Its branch (default GITHUB_REF_NAME, else the project's)
63
+ --url [url] The run (default this GitHub Actions run)
64
+
53
65
  Command: publish
54
- Description: publish this project's build through content.val.build.
55
- Declares the build's artifacts, uploads the ones content does not already hold,
56
- and has content verify it by building and rendering a canary before the site
57
- changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
58
- .val/pat.json (which needs the project, as "<org>/<project>", in val.config or
59
- VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
60
- processes, and it is kept in shell history and in every CI log.
66
+ Description: build this project and publish it through content.val.build.
67
+ Asks content what to build against, builds the site and its dependency layer from
68
+ the checkout (needs @valbuild/tanstack-build, rolldown, @tanstack/router-generator
69
+ and @tanstack/router-plugin installed), declares the artifacts, uploads the ones
70
+ content does not already hold, and has content verify it by building and rendering
71
+ a canary before the site changes. With --artifacts, or a .val/publish directory,
72
+ it publishes that build instead of making one. Authenticates with VAL_PROJECT_TOKEN,
73
+ or with the "val login" token in .val/pat.json (which needs the project, as
74
+ "<org>/<project>", in val.config or VAL_PROJECT). Never as a flag: an argument is
75
+ visible to anyone who can list processes, and it is kept in shell history and in
76
+ every CI log.
61
77
  Options:
62
78
  --root [root], -r [root] Set project root directory (default process.cwd())
63
- --artifacts [dir] The built artifacts (default <root>/.val/publish). The path
64
- of each file under it is its artifact key: server, client,
65
- css, rsc, layer, or a path under chunk/server, chunk/client,
79
+ --artifacts [dir] Publish a build made elsewhere (default: build the checkout,
80
+ unless <root>/.val/publish exists). The path of each file
81
+ under it is its artifact key: server, client, css, rsc,
82
+ layer, source, or a path under chunk/server, chunk/client,
66
83
  chunk/rsc, asset, public
67
84
  --commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
68
85
  else GITHUB_SHA, else git HEAD)
@@ -173,6 +190,12 @@ async function main(): Promise<void> {
173
190
  verbose: {
174
191
  type: "boolean",
175
192
  },
193
+ status: {
194
+ type: "string",
195
+ },
196
+ url: {
197
+ type: "string",
198
+ },
176
199
  },
177
200
  hardRejection: false,
178
201
  },
@@ -221,6 +244,14 @@ async function main(): Promise<void> {
221
244
  yes: flags.yes,
222
245
  verbose: flags.verbose,
223
246
  });
247
+ case "ci-report":
248
+ return ciReport({
249
+ root: flags.root,
250
+ status: flags.status,
251
+ commit: flags.commit,
252
+ branch: flags.branch,
253
+ url: flags.url,
254
+ });
224
255
  case "publish":
225
256
  return publish({
226
257
  root: flags.root,
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `val publish` with no artifacts: builds a small file-based TanStack project
3
+ * from its checkout and publishes it to the fake content service, twice --
4
+ * once against a content service that holds no layer for the project, and
5
+ * once against one that holds the layer the first run built.
6
+ *
7
+ * A separate process because `@rolldown/browser` is ESM-only and jest runs
8
+ * CommonJS here (the same reason as @valbuild/tanstack-build's probes). The
9
+ * assertions stay in buildPublish.test.ts -- this only reports.
10
+ *
11
+ * The project is written to a temporary directory whose `node_modules` is
12
+ * this package's, so its one layered dependency resolves the way it would in
13
+ * a checkout after `npm install`.
14
+ */
15
+ import fs from "fs";
16
+ import os from "os";
17
+ import path from "path";
18
+ import zlib from "zlib";
19
+ import {
20
+ FAKE_BUILD_TARGET,
21
+ startFakeContentService,
22
+ type FakeContentService,
23
+ } from "../fakeContent";
24
+ import { runPublish, type PublishResult } from "../runPublish";
25
+
26
+ const TOKEN = "val_pt_probe";
27
+ const COMMIT = "1234567890abcdef1234567890abcdef12345678";
28
+
29
+ const PROJECT: Record<string, string> = {
30
+ "package.json": JSON.stringify({
31
+ name: "probe-site",
32
+ private: true,
33
+ type: "module",
34
+ dependencies: {
35
+ react: "^19.0.0",
36
+ "@tanstack/react-router": "^1.0.0",
37
+ "@tanstack/react-start": "^1.0.0",
38
+ // Not in the base set, so it has to be layered.
39
+ picocolors: "^1.0.0",
40
+ },
41
+ }),
42
+ "src/router.tsx": `import { createRouter } from "@tanstack/react-router";
43
+ import { routeTree } from "./routeTree.gen";
44
+ export function getRouter() {
45
+ return createRouter({ routeTree });
46
+ }
47
+ `,
48
+ "src/routes/__root.tsx": `import { Outlet, createRootRoute } from "@tanstack/react-router";
49
+ import "../styles.css";
50
+ export const Route = createRootRoute({ component: () => <Outlet /> });
51
+ `,
52
+ "src/routes/index.tsx": `import { createFileRoute } from "@tanstack/react-router";
53
+ import pc from "picocolors";
54
+ import logo from "../logo.png";
55
+ export const Route = createFileRoute("/")({
56
+ component: () => (
57
+ <h1>
58
+ <img src={logo} alt="" />
59
+ {pc.bold("hello from the checkout")}
60
+ </h1>
61
+ ),
62
+ });
63
+ `,
64
+ // A route in a folder called "build": the project's, not a build's output.
65
+ "src/routes/build/index.tsx": `import { createFileRoute } from "@tanstack/react-router";
66
+ export const Route = createFileRoute("/build/")({
67
+ component: () => <p>the build page</p>,
68
+ });
69
+ `,
70
+ "src/styles.css": `h1 { color: rebeccapurple; }\n`,
71
+ "public/robots.txt": "User-agent: *\n",
72
+ };
73
+
74
+ type Report = {
75
+ result: PublishResult;
76
+ declared: Array<{
77
+ buildHash: string;
78
+ commit: string | null;
79
+ branch: string | null;
80
+ layerRev: string | null;
81
+ linksOwnCss: boolean | null;
82
+ keys: string[];
83
+ }>;
84
+ buildTargetCalls: number;
85
+ /** The rev inside the layer artifact, when one was uploaded. */
86
+ uploadedLayerRev: string | null;
87
+ /** Whether the source artifact holds the project's own route file. */
88
+ sourceHasRoute: boolean;
89
+ /** Whether the client build carries the page's text. */
90
+ clientHasPage: boolean;
91
+ /** Whether the imported image reached the build, inlined. */
92
+ clientHasLogo: boolean;
93
+ /** Whether the route in `src/routes/build/` was built and stored. */
94
+ buildRouteBuilt: boolean;
95
+ /** Whether the checkout was left exactly as it was found. */
96
+ checkoutUntouched: boolean;
97
+ };
98
+
99
+ /** A 1x1 PNG: small enough to inline, so the page carries it as a data URI. */
100
+ const LOGO = Buffer.from(
101
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
102
+ "base64",
103
+ );
104
+
105
+ function writeProject(): string {
106
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "val-publish-probe-"));
107
+ for (const [key, text] of Object.entries(PROJECT)) {
108
+ const file = path.join(root, ...key.split("/"));
109
+ fs.mkdirSync(path.dirname(file), { recursive: true });
110
+ fs.writeFileSync(file, text);
111
+ }
112
+ fs.writeFileSync(path.join(root, "src", "logo.png"), LOGO);
113
+ fs.symlinkSync(
114
+ path.join(__dirname, "..", "..", "..", "node_modules"),
115
+ path.join(root, "node_modules"),
116
+ "dir",
117
+ );
118
+ return root;
119
+ }
120
+
121
+ function listed(root: string): string[] {
122
+ const out: string[] = [];
123
+ const walk = (dir: string) => {
124
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
125
+ if (entry.name === "node_modules") continue;
126
+ const full = path.join(dir, entry.name);
127
+ if (entry.isDirectory()) walk(full);
128
+ else out.push(path.relative(root, full));
129
+ }
130
+ };
131
+ walk(root);
132
+ return out.sort();
133
+ }
134
+
135
+ async function publishOnce(
136
+ root: string,
137
+ fake: FakeContentService,
138
+ ): Promise<Report> {
139
+ const before = listed(root);
140
+ const result = await runPublish({
141
+ root,
142
+ commit: COMMIT,
143
+ branch: "main",
144
+ env: { VAL_PROJECT_TOKEN: TOKEN, VAL_CONTENT_URL: fake.url },
145
+ sleep: () => Promise.resolve(),
146
+ log: () => undefined,
147
+ });
148
+ const declared = [...fake.declarations.values()].map((body) => {
149
+ const d = body as {
150
+ buildHash: string;
151
+ commit: string | null;
152
+ branch: string | null;
153
+ layerRev: string | null;
154
+ linksOwnCss: boolean | null;
155
+ artifacts: Array<{ key: string }>;
156
+ };
157
+ return {
158
+ buildHash: d.buildHash,
159
+ commit: d.commit,
160
+ branch: d.branch,
161
+ layerRev: d.layerRev,
162
+ linksOwnCss: d.linksOwnCss,
163
+ keys: d.artifacts.map((a) => a.key).sort(),
164
+ };
165
+ });
166
+ const layer = fake.stored.get("layer");
167
+ const source = fake.stored.get("source");
168
+ // The route is split off, so its text is in a client chunk.
169
+ const clientText = [...fake.stored.entries()]
170
+ .filter(([key]) => key === "client" || key.startsWith("chunk/client/"))
171
+ .map(([, body]) => body.toString("utf8"))
172
+ .join("\n");
173
+ return {
174
+ result,
175
+ declared,
176
+ buildTargetCalls: fake.calls.filter((c) => c.path === "/v1/build-target")
177
+ .length,
178
+ uploadedLayerRev: layer
179
+ ? (JSON.parse(zlib.gunzipSync(layer).toString("utf8")) as { rev: string })
180
+ .rev
181
+ : null,
182
+ sourceHasRoute: source
183
+ ? "src/routes/index.tsx" in
184
+ (JSON.parse(source.toString("utf8")) as Record<string, string>)
185
+ : false,
186
+ clientHasPage: clientText.includes("hello from the checkout"),
187
+ clientHasLogo: clientText.includes(
188
+ `data:image/png;base64,${LOGO.toString("base64")}`,
189
+ ),
190
+ buildRouteBuilt:
191
+ clientText.includes("the build page") &&
192
+ (source
193
+ ? "src/routes/build/index.tsx" in
194
+ (JSON.parse(source.toString("utf8")) as Record<string, string>)
195
+ : false),
196
+ checkoutUntouched: JSON.stringify(listed(root)) === JSON.stringify(before),
197
+ };
198
+ }
199
+
200
+ (async () => {
201
+ const root = writeProject();
202
+ try {
203
+ const first = await startFakeContentService({ token: TOKEN });
204
+ const fresh = await publishOnce(root, first).finally(() => first.close());
205
+
206
+ // Content now holds the layer the first run sent, so the second names it.
207
+ const second = await startFakeContentService({
208
+ token: TOKEN,
209
+ buildTarget: {
210
+ ...FAKE_BUILD_TARGET,
211
+ project: {
212
+ ...FAKE_BUILD_TARGET.project,
213
+ rev: fresh.uploadedLayerRev,
214
+ },
215
+ },
216
+ });
217
+ const held = await publishOnce(root, second).finally(() => second.close());
218
+
219
+ process.stdout.write(`${JSON.stringify({ fresh, held })}\n`);
220
+ } finally {
221
+ fs.rmSync(root, { recursive: true, force: true });
222
+ }
223
+ })().catch((error) => {
224
+ process.stderr.write(`${error instanceof Error ? error.stack : error}\n`);
225
+ process.exit(1);
226
+ });
@@ -31,10 +31,10 @@ export type CollectedArtifacts = {
31
31
  * imports it at, so a normalised name produces a bundle whose imports resolve
32
32
  * to nothing, at runtime, in the isolate.
33
33
  *
34
- * **This is the seam with the build.** `val publish` uploads artifacts; it does
35
- * not produce them. Whatever builds the project - today the platform's own
36
- * builder, which is where the wire, rolldown and dependency-layer steps live -
37
- * writes this directory, and this reads it.
34
+ * **This is the seam with the build.** Whatever built the project writes this
35
+ * directory, and this reads it: `buildArtifacts` when `val publish` builds the
36
+ * checkout itself, or anything else that lays out the same keys, handed over
37
+ * with `--artifacts`. One path for bytes to leave by, whoever built them.
38
38
  */
39
39
  export async function collectArtifacts(
40
40
  dir: string,