@valbuild/cli 0.147.0 → 0.148.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.147.0",
4
+ "version": "0.148.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,11 +26,11 @@
26
26
  "picocolors": "^1.1.1",
27
27
  "zod": "^4.4.3",
28
28
  "zod-validation-error": "^5.0.0",
29
- "@valbuild/core": "0.147.0",
30
29
  "@valbuild/eslint-plugin": "0.125.0",
31
- "@valbuild/language-server": "0.147.0",
32
- "@valbuild/server": "0.147.0",
33
- "@valbuild/shared": "0.147.0"
30
+ "@valbuild/language-server": "0.148.0",
31
+ "@valbuild/core": "0.148.0",
32
+ "@valbuild/server": "0.148.0",
33
+ "@valbuild/shared": "0.148.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "prettier": "*",
@@ -38,7 +38,7 @@
38
38
  "rolldown": ">=1.2.5",
39
39
  "@tanstack/router-generator": ">=1.167.0",
40
40
  "@tanstack/router-plugin": ">=1.168.0",
41
- "@valbuild/tanstack-build": "^0.146.0"
41
+ "@valbuild/tanstack-build": "^0.148.0"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@valbuild/tanstack-build": {
@@ -70,7 +70,7 @@
70
70
  "prettier": "~3.8.5",
71
71
  "rolldown": "^1.2.5",
72
72
  "tsx": "^4.23.12",
73
- "@valbuild/tanstack-build": "0.146.0"
73
+ "@valbuild/tanstack-build": "0.148.0"
74
74
  },
75
75
  "scripts": {
76
76
  "start": "tsx src/cli.ts --",
package/src/cli.ts CHANGED
@@ -93,6 +93,8 @@ async function main(): Promise<void> {
93
93
  --links-own-css The app links its own CSS (--no-links-own-css for the
94
94
  opposite; omit it when the build did not say)
95
95
  --dry-run Verify, and stop before the site changes
96
+ --skip-validation Publish without running "val validate" first. It runs by
97
+ default, and an error stops the publish
96
98
 
97
99
  Command: list-unused-files
98
100
  Description: EXPERIMENTAL.
@@ -185,6 +187,9 @@ async function main(): Promise<void> {
185
187
  dryRun: {
186
188
  type: "boolean",
187
189
  },
190
+ skipValidation: {
191
+ type: "boolean",
192
+ },
188
193
  yes: {
189
194
  type: "boolean",
190
195
  },
@@ -263,6 +268,7 @@ async function main(): Promise<void> {
263
268
  buildHash: flags.buildHash,
264
269
  linksOwnCss: flags.linksOwnCss,
265
270
  dryRun: flags.dryRun,
271
+ skipValidation: flags.skipValidation,
266
272
  });
267
273
  case "login":
268
274
  return login({
@@ -0,0 +1,84 @@
1
+ import { error } from "./logger";
2
+ import { publish } from "./publish";
3
+ import { runPublish } from "./publish/runPublish";
4
+ import { validateOnce } from "./validate";
5
+
6
+ jest.mock("./validate");
7
+ // chalk is ESM-only, which jest cannot load as CommonJS.
8
+ jest.mock("./logger", () => ({ error: jest.fn() }));
9
+ jest.mock("./publish/runPublish", () => ({
10
+ ...jest.requireActual("./publish/runPublish"),
11
+ runPublish: jest.fn(),
12
+ }));
13
+
14
+ const mockValidateOnce = jest.mocked(validateOnce);
15
+ const mockRunPublish = jest.mocked(runPublish);
16
+ const mockError = jest.mocked(error);
17
+
18
+ /**
19
+ * `val publish` validates first: content that does not validate does not go
20
+ * live. It is what lets the Studio trust a connected project's published
21
+ * content without checking every file itself (docs/plans/studio-validate.md).
22
+ */
23
+ describe("val publish validates before it publishes", () => {
24
+ let log: jest.SpyInstance;
25
+ let errorLog: jest.SpyInstance;
26
+
27
+ beforeEach(() => {
28
+ jest.resetAllMocks();
29
+ process.exitCode = undefined;
30
+ log = jest.spyOn(console, "log").mockImplementation(() => undefined);
31
+ errorLog = jest.spyOn(console, "error").mockImplementation(() => undefined);
32
+ mockRunPublish.mockResolvedValue({
33
+ status: "verified",
34
+ publishId: "p1",
35
+ artifacts: 1,
36
+ uploaded: 0,
37
+ uploadedBytes: 0,
38
+ previewUrl: null,
39
+ });
40
+ });
41
+
42
+ afterEach(() => {
43
+ log.mockRestore();
44
+ errorLog.mockRestore();
45
+ process.exitCode = undefined;
46
+ });
47
+
48
+ test("a validation error stops the publish before anything is built", async () => {
49
+ mockValidateOnce.mockResolvedValue(2);
50
+ await publish({ root: "/project", dryRun: true });
51
+ expect(mockValidateOnce).toHaveBeenCalledWith({
52
+ projectRoot: "/project",
53
+ fix: false,
54
+ // Refused on what the Studio's Publish refuses on, and no more.
55
+ blockingOnly: true,
56
+ });
57
+ expect(mockRunPublish).not.toHaveBeenCalled();
58
+ expect(mockError).toHaveBeenCalledWith(
59
+ "Not published: 2 validation errors.",
60
+ );
61
+ expect(process.exitCode).toBe(1);
62
+ });
63
+
64
+ test("it never fixes: CI must not rewrite what it was asked to publish", async () => {
65
+ mockValidateOnce.mockResolvedValue(0);
66
+ await publish({ root: "/project" });
67
+ expect(mockValidateOnce).toHaveBeenCalledWith(
68
+ expect.objectContaining({ fix: false }),
69
+ );
70
+ });
71
+
72
+ test("content that validates is published", async () => {
73
+ mockValidateOnce.mockResolvedValue(0);
74
+ await publish({ root: "/project", dryRun: true });
75
+ expect(mockRunPublish).toHaveBeenCalledTimes(1);
76
+ expect(process.exitCode).toBeUndefined();
77
+ });
78
+
79
+ test("--skip-validation publishes without validating", async () => {
80
+ await publish({ root: "/project", dryRun: true, skipValidation: true });
81
+ expect(mockValidateOnce).not.toHaveBeenCalled();
82
+ expect(mockRunPublish).toHaveBeenCalledTimes(1);
83
+ });
84
+ });
package/src/publish.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import path from "path";
1
2
  import pc from "picocolors";
2
3
  import { error } from "./logger";
3
4
  import { PublishProblem } from "@valbuild/shared/internal";
4
5
  import { formatBytes, runPublish } from "./publish/runPublish";
6
+ import { validateOnce } from "./validate";
5
7
 
6
8
  /**
7
9
  * `val publish` - a built project in, a live site out.
@@ -19,7 +21,42 @@ export async function publish(options: {
19
21
  buildHash?: string;
20
22
  linksOwnCss?: boolean;
21
23
  dryRun?: boolean;
24
+ skipValidation?: boolean;
22
25
  }): Promise<void> {
26
+ // Content that does not validate is not published. This is the check that
27
+ // lets the Studio trust a connected project's published content without
28
+ // re-checking every file itself: whatever reached the site went through
29
+ // here. Without `--fix`, so CI never rewrites what it was asked to publish.
30
+ //
31
+ // Refused on what the Studio's Publish refuses on, and no more: an error
32
+ // the Studio leaves to the server to repair (a local image in a remote
33
+ // project, metadata to fill in) is printed and published, as it would be
34
+ // from the Studio. Every site made on /new starts with its template's
35
+ // images local, so counting those would refuse its first publish.
36
+ if (!options.skipValidation) {
37
+ const projectRoot = options.root
38
+ ? path.resolve(options.root)
39
+ : process.cwd();
40
+ const errorCount = await validateOnce({
41
+ projectRoot,
42
+ fix: false,
43
+ blockingOnly: true,
44
+ });
45
+ if (errorCount > 0) {
46
+ error(
47
+ `Not published: ${errorCount} validation error${errorCount === 1 ? "" : "s"}.`,
48
+ );
49
+ console.error(
50
+ pc.dim(
51
+ ` Run "val validate --fix" in the project, commit what it changes and push, ` +
52
+ `and fix by hand what it cannot. --skip-validation publishes anyway.`,
53
+ ),
54
+ );
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ }
59
+
23
60
  const result = await runPublish({
24
61
  ...(options.root ? { root: options.root } : {}),
25
62
  ...(options.artifacts ? { artifacts: options.artifacts } : {}),
@@ -213,6 +213,29 @@ describe("val validate with remote image and file collections", () => {
213
213
  fs.rmSync(root, { recursive: true, force: true });
214
214
  });
215
215
 
216
+ // What `val publish` refuses on (`validateOnce({ blockingOnly })`): a
217
+ // local file still to upload is the Studio's to repair, and is published;
218
+ // a gallery keyed by a ref it does not know is a person's to fix.
219
+ test("an error the Studio repairs on save is marked as such, and a person's is not", async () => {
220
+ const events = errorsOf(
221
+ await run(
222
+ root,
223
+ ["content/images.val.ts", "content/local-images.val.ts"],
224
+ false,
225
+ ),
226
+ );
227
+ const repaired = (sourcePath: string) =>
228
+ events
229
+ .filter((e) => e.sourcePath === sourcePath)
230
+ .map((e) => e.repairedOnSave === true);
231
+ expect(repaired(`/content/images.val.ts?p="${LOGO}"`)).toEqual([true]);
232
+ const local = events.filter((e) =>
233
+ e.sourcePath.startsWith("/content/local-images.val.ts"),
234
+ );
235
+ expect(local.length).toBeGreaterThan(0);
236
+ expect(local.every((e) => e.repairedOnSave !== true)).toBe(true);
237
+ });
238
+
216
239
  test("--fix uploads the entries, renames their keys, and points the page's field and rich text image at them", async () => {
217
240
  fs.mkdirSync(path.join(root, ".val"), { recursive: true });
218
241
  fs.writeFileSync(
@@ -1,5 +1,6 @@
1
1
  import path from "path";
2
2
  import {
3
+ askOncePerHost,
3
4
  createDefaultValFSHost,
4
5
  createFixPatch,
5
6
  createService,
@@ -19,6 +20,7 @@ import {
19
20
  SourcePath,
20
21
  } from "@valbuild/core";
21
22
  import {
23
+ isRepairedOnSave,
22
24
  resolveSchemaSourceFixes,
23
25
  type SchemaSourceSnapshot,
24
26
  } from "@valbuild/shared/internal";
@@ -58,6 +60,10 @@ export async function* runValidation({
58
60
  fs: IValFSHost;
59
61
  }): AsyncGenerator<ValidationEvent> {
60
62
  const projectRoot = path.resolve(root);
63
+ // One question per platform host for the whole run: each error is fixed by
64
+ // its own createFixPatch call, and a host that does not answer would
65
+ // otherwise cost a timeout per ref.
66
+ const askHostProject = askOncePerHost();
61
67
 
62
68
  const service = await createService(projectRoot, fs);
63
69
 
@@ -260,6 +266,7 @@ export async function* runValidation({
260
266
  sourcePath,
261
267
  message: result.errorMessage ?? "Unknown error",
262
268
  ...(v.keyError ? { keyError: true } : {}),
269
+ ...(isRepairedOnSave(v) ? { repairedOnSave: true } : {}),
263
270
  };
264
271
  fileErrors += 1;
265
272
  continue;
@@ -277,13 +284,18 @@ export async function* runValidation({
277
284
  message: result.fixableErrorMessage,
278
285
  fixable: true,
279
286
  ...(v.keyError ? { keyError: true } : {}),
287
+ ...(isRepairedOnSave(v) ? { repairedOnSave: true } : {}),
280
288
  };
281
289
  }
282
290
 
283
291
  // Apply patch if needed
284
292
  if (result.shouldApplyPatch) {
285
293
  const fixPatch = await createFixPatch(
286
- { projectRoot, remoteHost: remote.remoteHost },
294
+ {
295
+ projectRoot,
296
+ remoteHost: remote.remoteHost,
297
+ askHostProject,
298
+ },
287
299
  !!fix,
288
300
  sourcePath as SourcePath,
289
301
  v,
@@ -319,6 +331,7 @@ export async function* runValidation({
319
331
  message: v.message,
320
332
  fixable: true,
321
333
  ...(v.keyError ? { keyError: true } : {}),
334
+ ...(isRepairedOnSave(v) ? { repairedOnSave: true } : {}),
322
335
  };
323
336
  }
324
337
 
@@ -332,6 +345,7 @@ export async function* runValidation({
332
345
  message: e.message,
333
346
  fixable: !!(e.fixes && e.fixes.length),
334
347
  ...(e.keyError ? { keyError: true } : {}),
348
+ ...(isRepairedOnSave(e) ? { repairedOnSave: true } : {}),
335
349
  };
336
350
  }
337
351
  }