@valbuild/cli 0.132.1 → 0.134.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.132.1",
4
+ "version": "0.134.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -24,11 +24,13 @@
24
24
  "jszip": "^3.10.1",
25
25
  "meow": "^9.0.0",
26
26
  "picocolors": "^1.1.1",
27
- "@valbuild/core": "0.130.0",
27
+ "zod": "^4.4.3",
28
+ "zod-validation-error": "^5.0.0",
29
+ "@valbuild/core": "0.134.0",
28
30
  "@valbuild/eslint-plugin": "0.125.0",
29
- "@valbuild/language-server": "0.132.1",
30
- "@valbuild/server": "0.132.1",
31
- "@valbuild/shared": "0.130.0"
31
+ "@valbuild/language-server": "0.134.0",
32
+ "@valbuild/shared": "0.134.0",
33
+ "@valbuild/server": "0.134.0"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "prettier": "*",
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { login } from "./login";
9
9
  import { lsp } from "./lsp";
10
10
  import { debug } from "./debug";
11
11
  import { deleteUnappliablePatches } from "./deleteUnappliablePatches";
12
+ import { publish } from "./publish";
12
13
 
13
14
  async function main(): Promise<void> {
14
15
  const { input, flags, showHelp } = meow(
@@ -24,6 +25,7 @@ async function main(): Promise<void> {
24
25
  login
25
26
  files
26
27
  connect
28
+ publish
27
29
  versions
28
30
  lsp
29
31
  debug
@@ -48,6 +50,32 @@ async function main(): Promise<void> {
48
50
  Options:
49
51
  --root [root], -r [root] Set project root directory (default process.cwd())
50
52
 
53
+ 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.
61
+ Options:
62
+ --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,
66
+ chunk/rsc, asset, public
67
+ --commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
68
+ else GITHUB_SHA, else git HEAD)
69
+ --branch [name] The branch content commits saves to (default VAL_GIT_BRANCH,
70
+ else GITHUB_REF_NAME, else the current git branch)
71
+ --layer-rev [rev] Name the dependency layer content already holds, when this
72
+ build does not send one
73
+ --build-hash [hash] Identify the build (default: a hash of its artifacts).
74
+ Declaring the same one twice resumes that publish
75
+ --links-own-css The app links its own CSS (--no-links-own-css for the
76
+ opposite; omit it when the build did not say)
77
+ --dry-run Verify, and stop before the site changes
78
+
51
79
  Command: list-unused-files
52
80
  Description: EXPERIMENTAL.
53
81
  List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
@@ -112,6 +140,18 @@ async function main(): Promise<void> {
112
140
  out: {
113
141
  type: "string",
114
142
  },
143
+ artifacts: {
144
+ type: "string",
145
+ },
146
+ layerRev: {
147
+ type: "string",
148
+ },
149
+ buildHash: {
150
+ type: "string",
151
+ },
152
+ linksOwnCss: {
153
+ type: "boolean",
154
+ },
115
155
  commit: {
116
156
  type: "string",
117
157
  },
@@ -181,6 +221,17 @@ async function main(): Promise<void> {
181
221
  yes: flags.yes,
182
222
  verbose: flags.verbose,
183
223
  });
224
+ case "publish":
225
+ return publish({
226
+ root: flags.root,
227
+ artifacts: flags.artifacts,
228
+ commit: flags.commit,
229
+ branch: flags.branch,
230
+ layerRev: flags.layerRev,
231
+ buildHash: flags.buildHash,
232
+ linksOwnCss: flags.linksOwnCss,
233
+ dryRun: flags.dryRun,
234
+ });
184
235
  case "login":
185
236
  return login({
186
237
  root: flags.root,
@@ -120,8 +120,7 @@ export async function createDebugContext(options: {
120
120
  serverOps: new ValOpsHttp(
121
121
  contentUrl,
122
122
  project,
123
- commit,
124
- branch,
123
+ { commit, branch },
125
124
  auth,
126
125
  valModules,
127
126
  { root: config.root, config },
@@ -0,0 +1,194 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import zlib from "zlib";
5
+ import { DeclaredArtifact } from "./protocol";
6
+
7
+ /**
8
+ * Where an artifact's bytes are, and what it is called on the wire.
9
+ *
10
+ * The key namespace is flat and is content's: `server`, `client`, `css`,
11
+ * `rsc`, `layer`, and paths under `chunk/server/`, `chunk/client/`,
12
+ * `chunk/rsc/`, `asset/` and `public/`. Content validates it and answers with
13
+ * every problem at once, so this does not re-implement the rules - a second,
14
+ * slightly different copy of them here is how a CLI comes to refuse a publish
15
+ * the service would have taken.
16
+ */
17
+ export type Artifact = DeclaredArtifact & { file: string };
18
+
19
+ export type CollectedArtifacts = {
20
+ artifacts: Artifact[];
21
+ totalBytes: number;
22
+ skipped: string[];
23
+ };
24
+
25
+ /**
26
+ * The artifacts to publish, read from a directory laid out by key.
27
+ *
28
+ * The path under the directory IS the artifact key: a file at `public/app.css`
29
+ * is the artifact `public/app.css`, and `server` is the server bundle. Nothing
30
+ * is renamed on the way - an asset is addressed by the path the built code
31
+ * imports it at, so a normalised name produces a bundle whose imports resolve
32
+ * to nothing, at runtime, in the isolate.
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.
38
+ */
39
+ export async function collectArtifacts(
40
+ dir: string,
41
+ ): Promise<CollectedArtifacts> {
42
+ const artifacts: Artifact[] = [];
43
+ const skipped: string[] = [];
44
+ let totalBytes = 0;
45
+
46
+ const walk = async (current: string): Promise<void> => {
47
+ const entries = await fs.promises.readdir(current, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ const absolute = path.join(current, entry.name);
50
+ const key = path.relative(dir, absolute).split(path.sep).join("/");
51
+ if (entry.isDirectory()) {
52
+ await walk(absolute);
53
+ continue;
54
+ }
55
+ if (entry.isSymbolicLink()) {
56
+ // A symlinked file is published as its bytes; a symlinked directory is
57
+ // not walked, because one usually comes with a cycle, and a publisher
58
+ // that hangs is worse than one that says what it left out.
59
+ const stat = await fs.promises.stat(absolute).catch(() => null);
60
+ if (stat === null) {
61
+ skipped.push(`${key} (broken symlink)`);
62
+ continue;
63
+ }
64
+ if (!stat.isFile()) {
65
+ skipped.push(
66
+ `${key} (symlink to a ${stat.isDirectory() ? "directory" : "special file"})`,
67
+ );
68
+ continue;
69
+ }
70
+ } else if (!entry.isFile()) {
71
+ skipped.push(`${key} (not a regular file)`);
72
+ continue;
73
+ }
74
+ const { sha256, bytes } = await hashFile(absolute);
75
+ artifacts.push({ key, sha256, bytes, file: absolute });
76
+ totalBytes += bytes;
77
+ }
78
+ };
79
+
80
+ await walk(dir);
81
+ // Sorted so two publishes of one build declare the same list in the same
82
+ // order, which is what makes the build hash below reproducible.
83
+ artifacts.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
84
+ return { artifacts, totalBytes, skipped };
85
+ }
86
+
87
+ async function hashFile(
88
+ absolute: string,
89
+ ): Promise<{ sha256: string; bytes: number }> {
90
+ const hash = crypto.createHash("sha256");
91
+ let bytes = 0;
92
+ // Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
93
+ // it is a needless way to run a CI runner out of memory.
94
+ const stream = fs.createReadStream(absolute);
95
+ await new Promise<void>((resolve, reject) => {
96
+ stream.on("data", (chunk) => {
97
+ hash.update(chunk);
98
+ bytes += chunk.length;
99
+ });
100
+ stream.on("end", resolve);
101
+ stream.on("error", reject);
102
+ });
103
+ return { sha256: hash.digest("hex"), bytes };
104
+ }
105
+
106
+ /**
107
+ * The build's own hash, when the build did not say.
108
+ *
109
+ * Content uses it for idempotency - declaring the same `buildHash` twice
110
+ * returns the same publish, which is what makes a retried CI job resume rather
111
+ * than start again - so it has to be a function of the build and nothing else.
112
+ * Every artifact's key and hash, in order: two runs of one build agree, and
113
+ * any change to any artifact is a different publish.
114
+ */
115
+ export function buildHashOf(artifacts: Artifact[]): string {
116
+ const hash = crypto.createHash("sha256");
117
+ for (const artifact of artifacts) {
118
+ hash.update(`${artifact.key} ${artifact.sha256}\n`);
119
+ }
120
+ return hash.digest("hex");
121
+ }
122
+
123
+ /**
124
+ * Which dependency layer this build was built against.
125
+ *
126
+ * A layer is sent or named, never neither: the loader reads the head's layer
127
+ * by rev, and a publish that declares no layer at all loses every dependency
128
+ * on the next render. When the layer is being uploaded, its rev is inside it -
129
+ * it is gzipped JSON with a `rev` - so there is nothing to ask the caller for.
130
+ * When it is not, the build knows which stored layer it used, and says so with
131
+ * `--layer-rev`.
132
+ */
133
+ export async function layerRevOf(
134
+ artifacts: Artifact[],
135
+ ): Promise<
136
+ | { status: "ok"; layerRev: string | null }
137
+ | { status: "error"; message: string }
138
+ > {
139
+ const layer = artifacts.find((artifact) => artifact.key === "layer");
140
+ if (!layer) {
141
+ return { status: "ok", layerRev: null };
142
+ }
143
+ const bytes = await fs.promises.readFile(layer.file);
144
+ let parsed: unknown;
145
+ try {
146
+ parsed = JSON.parse(zlib.gunzipSync(bytes).toString("utf-8"));
147
+ } catch (err) {
148
+ return {
149
+ status: "error",
150
+ message:
151
+ `The layer artifact is not gzipped JSON: ${
152
+ err instanceof Error ? err.message : String(err)
153
+ }\n\n` +
154
+ "A layer is `{ rev, worker, browser, ... }`, gzipped. Pass --layer-rev to\n" +
155
+ "name a layer content already holds instead of sending one.",
156
+ };
157
+ }
158
+ const rev =
159
+ typeof parsed === "object" && parsed !== null
160
+ ? Reflect.get(parsed, "rev")
161
+ : undefined;
162
+ if (typeof rev !== "string" || rev === "") {
163
+ return {
164
+ status: "error",
165
+ message:
166
+ "The layer artifact has no rev, and a layer that is sent has to say which\n" +
167
+ "it is: that is the name the loader stores and later reuses it by.",
168
+ };
169
+ }
170
+ return { status: "ok", layerRev: rev };
171
+ }
172
+
173
+ /** Where the artifacts are, if the caller did not say. */
174
+ export const DEFAULT_ARTIFACTS_DIR = ".val/publish";
175
+
176
+ export function resolveArtifactsDir(options: {
177
+ root: string;
178
+ dir?: string;
179
+ }): { status: "ok"; dir: string } | { status: "error"; message: string } {
180
+ const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
181
+ const dir = path.resolve(options.root, named);
182
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
183
+ return {
184
+ status: "error",
185
+ message:
186
+ `No artifacts to publish: ${dir} is not a directory.\n\n` +
187
+ "Build the project first, then point at what it produced:\n\n" +
188
+ " npx val publish --artifacts <directory>\n\n" +
189
+ "The path of each file under it is its artifact key - `server`, `client`,\n" +
190
+ "`public/...`, `chunk/client/...`, `layer`.",
191
+ };
192
+ }
193
+ return { status: "ok", dir };
194
+ }
@@ -0,0 +1,177 @@
1
+ import fs from "fs";
2
+ import { getJson, postJson } from "./contentHost";
3
+ import {
4
+ ArtifactsResponse,
5
+ DeclareBody,
6
+ DeclareResponse,
7
+ PromoteResponse,
8
+ StatusResponse,
9
+ UploadSlot,
10
+ VerifyResponse,
11
+ parseArtifacts,
12
+ parseDeclare,
13
+ parsePromote,
14
+ parseStatus,
15
+ parseVerify,
16
+ } from "./protocol";
17
+
18
+ export type PublishClient = {
19
+ declare(body: DeclareBody): Promise<DeclareResponse>;
20
+ confirmArtifacts(publishId: string): Promise<ArtifactsResponse>;
21
+ verify(publishId: string): Promise<VerifyResponse>;
22
+ promote(publishId: string): Promise<PromoteResponse>;
23
+ status(publishId: string): Promise<StatusResponse>;
24
+ upload(slot: UploadSlot, file: string): Promise<void>;
25
+ };
26
+
27
+ export function createPublishClient(options: {
28
+ host: string;
29
+ token: string;
30
+ fetchImpl?: typeof fetch;
31
+ }): PublishClient {
32
+ const { host, token } = options;
33
+ const fetchImpl = options.fetchImpl ?? fetch;
34
+ // Every call, and nothing else: not a cookie, not the project's api key, not
35
+ // a personal access token.
36
+ const headers = { Authorization: `Bearer ${token}` };
37
+ const publishUrl = (publishId: string, step?: string) =>
38
+ `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
39
+
40
+ return {
41
+ declare: async (body) =>
42
+ parseDeclare(
43
+ await postJson({ url: `${host}/v1/publish`, headers, body, fetchImpl }),
44
+ ),
45
+ confirmArtifacts: async (publishId) =>
46
+ parseArtifacts(
47
+ await postJson({
48
+ url: publishUrl(publishId, "artifacts"),
49
+ headers,
50
+ fetchImpl,
51
+ }),
52
+ ),
53
+ verify: async (publishId) =>
54
+ parseVerify(
55
+ await postJson({
56
+ url: publishUrl(publishId, "verify"),
57
+ headers,
58
+ fetchImpl,
59
+ }),
60
+ ),
61
+ promote: async (publishId) =>
62
+ parsePromote(
63
+ await postJson({
64
+ url: publishUrl(publishId, "promote"),
65
+ headers,
66
+ fetchImpl,
67
+ }),
68
+ ),
69
+ status: async (publishId) =>
70
+ parseStatus(
71
+ await getJson({ url: publishUrl(publishId), headers, fetchImpl }),
72
+ ),
73
+ upload: (slot, file) => putArtifact(slot, file, fetchImpl),
74
+ };
75
+ }
76
+
77
+ /** Object storage refused the bytes, or could not be reached. */
78
+ export class UploadError extends Error {
79
+ readonly statusCode: number;
80
+ readonly key: string;
81
+ constructor(statusCode: number, key: string, message: string) {
82
+ super(message);
83
+ this.name = "UploadError";
84
+ this.statusCode = statusCode;
85
+ this.key = key;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A slot's permission to write has run out.
91
+ *
92
+ * Not a failed publish: declaring again mints fresh slots and the publish
93
+ * carries on from where it was, which is why this is its own kind of failure
94
+ * rather than an error message.
95
+ */
96
+ export function isExpiredSlot(err: UploadError): boolean {
97
+ return err.statusCode === 403 || err.statusCode === 401;
98
+ }
99
+
100
+ async function putArtifact(
101
+ slot: UploadSlot,
102
+ file: string,
103
+ fetchImpl: typeof fetch,
104
+ ): Promise<void> {
105
+ /*
106
+ * Read, rather than streamed.
107
+ *
108
+ * `ContentLength` is signed into the slot's URL, so the store rejects a body
109
+ * of a different size - and a streamed body goes out chunked, which is a
110
+ * different size as far as the signature is concerned. An artifact is capped
111
+ * at 128 MB by the API, which is the bound this trades against.
112
+ */
113
+ const body = await fs.promises.readFile(file);
114
+ let res: Response;
115
+ try {
116
+ res = await fetchImpl(slot.url, {
117
+ method: slot.method,
118
+ headers: slot.headers,
119
+ body,
120
+ });
121
+ } catch (err) {
122
+ throw new UploadError(
123
+ 0,
124
+ slot.key,
125
+ `${slot.key}: ${err instanceof Error ? err.message : String(err)}`,
126
+ );
127
+ }
128
+ if (!res.ok) {
129
+ // The store answers XML, and a whole error document in a CI log buries the
130
+ // line that matters. The status is the actionable half.
131
+ throw new UploadError(
132
+ res.status,
133
+ slot.key,
134
+ `${slot.key}: object storage answered ${res.status} ${res.statusText}`,
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Run `worker` over `items`, `limit` at a time.
141
+ *
142
+ * A project can have hundreds of small artifacts, so one at a time is minutes
143
+ * of latency and all at once is hundreds of open sockets on a CI runner. The
144
+ * first failure stops the pool: there is no point uploading the rest of a
145
+ * publish that is not going to be promoted.
146
+ */
147
+ export async function pool<T>(
148
+ items: T[],
149
+ limit: number,
150
+ worker: (item: T) => Promise<void>,
151
+ ): Promise<void> {
152
+ let next = 0;
153
+ let failure: unknown = undefined;
154
+ const run = async (): Promise<void> => {
155
+ for (;;) {
156
+ if (failure !== undefined) {
157
+ return;
158
+ }
159
+ const index = next++;
160
+ if (index >= items.length) {
161
+ return;
162
+ }
163
+ try {
164
+ await worker(items[index]);
165
+ } catch (err) {
166
+ failure = err;
167
+ return;
168
+ }
169
+ }
170
+ };
171
+ await Promise.all(
172
+ Array.from({ length: Math.min(limit, items.length) }, () => run()),
173
+ );
174
+ if (failure !== undefined) {
175
+ throw failure;
176
+ }
177
+ }