@valbuild/cli 0.150.0 → 0.150.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.
@@ -1,4 +1,5 @@
1
- import { connectUrl, getGitHubOwnerAndRepo } from "./connect";
1
+ import path from "path";
2
+ import { connectUrl, getGitHubOwnerAndRepo, repoFolder } from "./connect";
2
3
 
3
4
  describe("val connect", () => {
4
5
  test("opens /new on your own hosting, with the repository and the project filled in", () => {
@@ -17,6 +18,36 @@ describe("val connect", () => {
17
18
  });
18
19
  });
19
20
 
21
+ test("in a monorepo, the app's folder goes with the repository", () => {
22
+ const url = new URL(
23
+ connectUrl("https://admin.val.build", {
24
+ project: null,
25
+ repo: { owner: "acme", repo: "monorepo" },
26
+ folder: "apps/web",
27
+ }),
28
+ );
29
+ expect(Object.fromEntries(url.searchParams)).toEqual({
30
+ hosting: "own",
31
+ repo: "acme/monorepo",
32
+ folder: "apps/web",
33
+ });
34
+ // At the top of the repository there is no folder to send.
35
+ expect(
36
+ connectUrl("https://admin.val.build", {
37
+ project: null,
38
+ repo: { owner: "acme", repo: "site" },
39
+ folder: "",
40
+ }),
41
+ ).toBe("https://admin.val.build/new?hosting=own&repo=acme%2Fsite");
42
+ });
43
+
44
+ test("the folder is the app's place in its repository, with forward slashes", () => {
45
+ const top = path.resolve("/work/monorepo");
46
+ expect(repoFolder(top, path.join(top, "apps", "web"))).toBe("apps/web");
47
+ expect(repoFolder(top, top)).toBe("");
48
+ expect(repoFolder(top, path.resolve("/elsewhere"))).toBe(null);
49
+ });
50
+
20
51
  test("with nothing found, it is /new on your own hosting alone", () => {
21
52
  expect(
22
53
  connectUrl("http://localhost:3000/", { project: null, repo: null }),
package/src/connect.ts CHANGED
@@ -8,17 +8,20 @@ const host = process.env.VAL_BUILD_URL || "https://admin.val.build";
8
8
 
9
9
  /**
10
10
  * `val connect`: opens admin's `/new` on "your own hosting", with what it can
11
- * tell from here filled in -- the project `val.config.ts` names and the
12
- * GitHub repository `origin` points at -- so the person only confirms. The
13
- * link is printed too, for when no browser opens (over SSH, in a container).
11
+ * tell from here filled in -- the project `val.config.ts` names, the GitHub
12
+ * repository `origin` points at, and which folder of it this app is in (a
13
+ * monorepo has several) -- so the person only confirms. The link is printed
14
+ * too, for when no browser opens (over SSH, in a container).
14
15
  */
15
16
  export async function connect(options: { root?: string }) {
16
17
  const { root } = options;
17
18
  const projectRoot = root ? path.resolve(root) : process.cwd();
18
19
 
19
20
  const project = await tryGetProject(projectRoot);
20
- const gitRemote = await tryGetGitRemote(projectRoot);
21
+ const git = findGitDir(projectRoot);
22
+ const gitRemote = git ? await tryGetGitRemote(git.config) : null;
21
23
  const repo = gitRemote !== null ? getGitHubOwnerAndRepo(gitRemote) : null;
24
+ const folder = git ? repoFolder(git.top, projectRoot) : null;
22
25
 
23
26
  console.log("");
24
27
  if (project) {
@@ -28,7 +31,7 @@ export async function connect(options: { root?: string }) {
28
31
  }
29
32
  if (repo) {
30
33
  console.log(
31
- `Found ${pc.bold(`${repo.owner}/${repo.repo}`)} on GitHub (git remote "origin")`,
34
+ `Found ${pc.bold(`${repo.owner}/${repo.repo}`)} on GitHub (git remote "origin")${folder ? `, this app in ${pc.bold(folder)}` : ""}`,
32
35
  );
33
36
  } else {
34
37
  console.log(
@@ -38,7 +41,7 @@ export async function connect(options: { root?: string }) {
38
41
  );
39
42
  }
40
43
 
41
- const url = connectUrl(host, { project, repo });
44
+ const url = connectUrl(host, { project, repo, folder });
42
45
  const opened = openInBrowser(url);
43
46
  console.log(
44
47
  `\n${opened ? "Finish in your browser. If it did not open, go to:" : "Finish in your browser:"}\n${pc.cyan(url)}\n`,
@@ -55,11 +58,14 @@ export function connectUrl(
55
58
  found: {
56
59
  project: { orgName: string; projectName: string } | null;
57
60
  repo: { owner: string; repo: string } | null;
61
+ /** The app's folder in the repository, `apps/web`; "" or null at the top. */
62
+ folder?: string | null;
58
63
  },
59
64
  ): string {
60
65
  const params = new URLSearchParams({ hosting: "own" });
61
66
  if (found.repo) {
62
67
  params.set("repo", `${found.repo.owner}/${found.repo.repo}`);
68
+ if (found.folder) params.set("folder", found.folder);
63
69
  }
64
70
  if (found.project) {
65
71
  params.set("org", found.project.orgName);
@@ -141,12 +147,19 @@ export function getGitHubOwnerAndRepo(
141
147
  return match ? { owner: match[1], repo: match[2] } : null;
142
148
  }
143
149
 
144
- async function tryGetGitRemote(root: string): Promise<string | null> {
150
+ /**
151
+ * Where the app is in its repository, with forward slashes: `apps/web`, or ""
152
+ * when it is at the top. Null when it is not inside the repository at all.
153
+ */
154
+ export function repoFolder(top: string, projectRoot: string): string | null {
155
+ const relative = path.relative(top, projectRoot);
156
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
157
+ return relative.split(path.sep).join("/");
158
+ }
159
+
160
+ async function tryGetGitRemote(gitConfigPath: string): Promise<string | null> {
145
161
  try {
146
- const gitConfig = tryGetGitConfig(root);
147
- if (!gitConfig) {
148
- return null;
149
- }
162
+ const gitConfig = fs.readFileSync(gitConfigPath, "utf-8");
150
163
  const remoteMatch = gitConfig.match(
151
164
  /\[remote "origin"\][\s\S]*?url = (.+)/,
152
165
  );
@@ -160,13 +173,14 @@ async function tryGetGitRemote(root: string): Promise<string | null> {
160
173
  }
161
174
  }
162
175
 
163
- function tryGetGitConfig(root: string): string | null {
176
+ /** The repository `root` is in: its top folder and its `.git/config`. */
177
+ function findGitDir(root: string): { top: string; config: string } | null {
164
178
  let currentDir = root;
165
179
  let lastDir = null;
166
180
  while (currentDir !== lastDir) {
167
181
  const gitConfigPath = path.join(currentDir, ".git", "config");
168
182
  if (fs.existsSync(gitConfigPath)) {
169
- return fs.readFileSync(gitConfigPath, "utf-8");
183
+ return { top: currentDir, config: gitConfigPath };
170
184
  }
171
185
  lastDir = currentDir;
172
186
  currentDir = path.dirname(currentDir);
@@ -60,6 +60,14 @@ async function loadBuilder(): Promise<
60
60
  import("@valbuild/tanstack-build"),
61
61
  import("@valbuild/tanstack-build/node"),
62
62
  ]);
63
+ /*
64
+ * Native rolldown rather than its WebAssembly build, whose glue the site's
65
+ * lockfile picks and can break (`rolldown.ts` in tanstack-build). Asked
66
+ * for, because the project's own tanstack-build may predate the choice.
67
+ */
68
+ if ("useRolldown" in build && "nativeRolldown" in node) {
69
+ build.useRolldown(node.nativeRolldown);
70
+ }
63
71
  return { status: "ok", build, node };
64
72
  } catch (e) {
65
73
  return {
@@ -74,6 +82,49 @@ async function loadBuilder(): Promise<
74
82
  }
75
83
  }
76
84
 
85
+ /**
86
+ * Whether `val publish` could build here: the builder loaded as a publish
87
+ * loads it, and its bundler made to parse and bundle a module. Nothing is
88
+ * read from the project and nothing is asked of content.
89
+ *
90
+ * What Val Build runs on an upgrade's install before a connected project
91
+ * may publish it, since the project's CI is what builds a connected publish:
92
+ * an upgrade that resolved a bundler which cannot load would otherwise land
93
+ * on the branch and fail every build after it (architecture/quirks.md,
94
+ * 2026-10-26).
95
+ */
96
+ export async function checkBuildTools(): Promise<
97
+ { status: "ok" } | { status: "error"; message: string }
98
+ > {
99
+ const loaded = await loadBuilder();
100
+ if (loaded.status === "error") return loaded;
101
+ try {
102
+ // A builder older than `loadRolldown` loaded its bundler on import.
103
+ if (!("loadRolldown" in loaded.build)) return { status: "ok" };
104
+ const { build, parseAstAsync } = await loaded.build.loadRolldown();
105
+ await parseAstAsync("export const checked = true;");
106
+ const ENTRY = "\0val-check-build-tools";
107
+ await build({
108
+ input: ENTRY,
109
+ cwd: "/",
110
+ write: false,
111
+ plugins: [
112
+ {
113
+ name: "val-check-build-tools",
114
+ resolveId: (id) => (id === ENTRY ? id : null),
115
+ load: (id) => (id === ENTRY ? "export default 1;" : null),
116
+ },
117
+ ],
118
+ });
119
+ return { status: "ok" };
120
+ } catch (e) {
121
+ return {
122
+ status: "error",
123
+ message: `rolldown loaded, and could not build: ${e instanceof Error ? e.message : String(e)}`,
124
+ };
125
+ }
126
+ }
127
+
77
128
  /**
78
129
  * The `@valbuild/ui` this checkout runs: the one its `@valbuild/tanstack`
79
130
  * resolves, which is a transitive dependency pnpm does not hoist -- so it is
package/src/publish.ts CHANGED
@@ -22,6 +22,8 @@ export async function publish(options: {
22
22
  linksOwnCss?: boolean;
23
23
  dryRun?: boolean;
24
24
  skipValidation?: boolean;
25
+ /** Let a validation warning stop the publish too, as `val validate --strict`. */
26
+ strict?: boolean;
25
27
  }): Promise<void> {
26
28
  // Content that does not validate is not published. This is the check that
27
29
  // lets the Studio trust a connected project's published content without
@@ -32,7 +34,8 @@ export async function publish(options: {
32
34
  // the Studio leaves to the server to repair (a local image in a remote
33
35
  // project, metadata to fill in) is printed and published, as it would be
34
36
  // from the Studio. Every site made on /new starts with its template's
35
- // images local, so counting those would refuse its first publish.
37
+ // images local, so counting those would refuse its first publish: they are
38
+ // warnings, and refuse a publish only under --strict.
36
39
  if (!options.skipValidation) {
37
40
  const projectRoot = options.root
38
41
  ? path.resolve(options.root)
@@ -41,6 +44,7 @@ export async function publish(options: {
41
44
  projectRoot,
42
45
  fix: false,
43
46
  blockingOnly: true,
47
+ ...(options.strict ? { strict: true } : {}),
44
48
  });
45
49
  if (errorCount > 0) {
46
50
  error(
@@ -187,6 +187,10 @@ function errorsOf(events: ValidationEvent[]) {
187
187
  );
188
188
  }
189
189
 
190
+ function warningsOf(events: ValidationEvent[]) {
191
+ return events.filter((e) => e.type === "validation-warning");
192
+ }
193
+
190
194
  describe("val validate with remote image and file collections", () => {
191
195
  let root: string;
192
196
 
@@ -214,22 +218,22 @@ describe("val validate with remote image and file collections", () => {
214
218
  });
215
219
 
216
220
  // 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
- ),
221
+ // local file still to upload is a warning, and is published; a gallery
222
+ // keyed by a ref it does not know is a person's to fix.
223
+ test("a local file still to upload is a warning, and a person's error is not repaired on save", async () => {
224
+ const events = await run(
225
+ root,
226
+ ["content/images.val.ts", "content/local-images.val.ts"],
227
+ false,
226
228
  );
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) =>
229
+ expect(warningsOf(events).map((e) => [e.sourcePath, e.files])).toEqual([
230
+ [`/content/images.val.ts?p="${LOGO}"`, [LOGO]],
231
+ ]);
232
+ const errors = errorsOf(events);
233
+ expect(
234
+ errors.filter((e) => e.sourcePath.startsWith("/content/images.val.ts")),
235
+ ).toEqual([]);
236
+ const local = errors.filter((e) =>
233
237
  e.sourcePath.startsWith("/content/local-images.val.ts"),
234
238
  );
235
239
  expect(local.length).toBeGreaterThan(0);
@@ -264,10 +268,10 @@ describe("val validate with remote image and file collections", () => {
264
268
  "content/page.val.ts",
265
269
  ];
266
270
  // Before: the page is valid against both collections, and the two
267
- // entries are fixable.
268
- expect(
269
- errorsOf(await run(root, modules, false)).map((e) => e.sourcePath),
270
- ).toEqual([
271
+ // entries are warnings: local files still to upload.
272
+ const before = await run(root, modules, false);
273
+ expect(errorsOf(before)).toEqual([]);
274
+ expect(warningsOf(before).map((e) => e.sourcePath)).toEqual([
271
275
  `/content/files.val.ts?p="${DOC}"`,
272
276
  `/content/images.val.ts?p="${LOGO}"`,
273
277
  ]);
@@ -7,6 +7,10 @@ import {
7
7
  type ModulePath,
8
8
  } from "@valbuild/core";
9
9
  import { createService } from "@valbuild/server";
10
+ import {
11
+ LOCAL_FILE_IN_REMOTE_PROJECT_HINT,
12
+ LOCAL_FILE_IN_REMOTE_PROJECT_WARNING,
13
+ } from "@valbuild/shared/internal";
10
14
  import {
11
15
  createDefaultValFSHost,
12
16
  runValidation,
@@ -430,32 +434,69 @@ describe("runValidation", () => {
430
434
  }
431
435
  });
432
436
 
433
- test("reports upload-remote error for local entry in a remote gallery", async () => {
434
- const events: ValidationEvent[] = [];
437
+ describe("a local entry in a remote gallery", () => {
438
+ const run = async (fix: boolean) => {
439
+ const events: ValidationEvent[] = [];
440
+ for await (const event of runValidation({
441
+ root: tmpDir,
442
+ fix,
443
+ valFiles: ["content/basic-gallery-remote.val.ts"],
444
+ project: undefined,
445
+ remote: mockRemote,
446
+ fs: createDefaultValFSHost(),
447
+ })) {
448
+ events.push(event);
449
+ }
450
+ return events;
451
+ };
452
+ const entry = `/content/basic-gallery-remote.val.ts?p="/public/val/images-remote/image.png"`;
435
453
 
436
- for await (const event of runValidation({
437
- root: tmpDir,
438
- fix: false,
439
- valFiles: ["content/basic-gallery-remote.val.ts"],
440
- project: undefined,
441
- remote: mockRemote,
442
- fs: createDefaultValFSHost(),
443
- })) {
444
- events.push(event);
445
- }
454
+ test("is a warning, not an error: the site serves it from public/", async () => {
455
+ const events = await run(false);
456
+ expect(events.at(-1)).toEqual({ type: "summary-success" });
457
+ expect(events.filter((e) => e.type === "validation-warning")).toEqual([
458
+ {
459
+ type: "validation-warning",
460
+ sourcePath: entry,
461
+ message: LOCAL_FILE_IN_REMOTE_PROJECT_WARNING,
462
+ files: ["/public/val/images-remote/image.png"],
463
+ hint: LOCAL_FILE_IN_REMOTE_PROJECT_HINT,
464
+ keyError: true,
465
+ },
466
+ ]);
467
+ });
446
468
 
447
- expect(events.at(-1)).toEqual({
448
- type: "summary-errors",
449
- count: expect.any(Number),
469
+ test("is an error when the file does not exist", async () => {
470
+ fs.rmSync(
471
+ path.join(tmpDir, "public", "val", "images-remote", "image.png"),
472
+ );
473
+ const events = await run(false);
474
+ expect(events.filter((e) => e.type === "validation-warning")).toEqual([]);
475
+ // The gallery's own check reports the missing file too.
476
+ expect(events.at(-1)).toEqual({
477
+ type: "summary-errors",
478
+ count: expect.any(Number),
479
+ });
480
+ expect(
481
+ events.some(
482
+ (e) =>
483
+ e.type === "validation-error" &&
484
+ e.sourcePath === entry &&
485
+ /does not exist/.test(e.message),
486
+ ),
487
+ ).toBe(true);
488
+ });
489
+
490
+ test("is an error under --fix when it cannot be uploaded: not logged in", async () => {
491
+ fs.rmSync(path.join(tmpDir, ".val", "pat.json"), { force: true });
492
+ const events = await run(true);
493
+ expect(events.filter((e) => e.type === "validation-warning")).toEqual([]);
494
+ expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 });
495
+ const error = events.find((e) => e.type === "validation-error");
496
+ expect(error && "message" in error ? error.message : "").toMatch(
497
+ /not logged in/,
498
+ );
450
499
  });
451
- const errors = events.filter((e) => e.type === "validation-error");
452
- expect(
453
- errors.some(
454
- (e) =>
455
- "message" in e &&
456
- (e.message as string).includes("needs to be uploaded"),
457
- ),
458
- ).toBe(true);
459
500
  });
460
501
 
461
502
  test("uploads local entry in a remote gallery and rewrites the key when fix is true", async () => {
@@ -276,6 +276,15 @@ export async function* runValidation({
276
276
  yield { type: "fix-applied", file, sourcePath };
277
277
  }
278
278
 
279
+ if (result.warning !== undefined) {
280
+ yield {
281
+ type: "validation-warning",
282
+ sourcePath,
283
+ ...result.warning,
284
+ ...(v.keyError ? { keyError: true } : {}),
285
+ };
286
+ }
287
+
279
288
  if (result.fixableErrorMessage !== undefined) {
280
289
  fileErrors += 1;
281
290
  yield {
@@ -221,7 +221,7 @@ describe("validate handlers", () => {
221
221
  });
222
222
 
223
223
  describe("handleRemoteFileUpload", () => {
224
- test("should return error when fix is false", () => {
224
+ test("should return a warning when fix is false", () => {
225
225
  const ctx: FixHandlerContext = {
226
226
  ...baseContext,
227
227
  fix: false,
@@ -232,8 +232,8 @@ describe("validate handlers", () => {
232
232
  };
233
233
 
234
234
  // Expected behavior:
235
- // When fix=false, handler should return error telling user to use --fix
236
- // Should return success: false with message about using --fix
235
+ // When fix=false and the file exists, handler should return a warning
236
+ // naming the file (see validate.warnings.test.ts), not an error
237
237
 
238
238
  expect(ctx.fix).toBe(false);
239
239
  });
@@ -320,7 +320,7 @@ describe("validate handlers", () => {
320
320
  expect(ctx.fix).toBe(true);
321
321
  });
322
322
 
323
- test("should return error when fix is false", () => {
323
+ test("should return a warning when fix is false", () => {
324
324
  const ctx: FixHandlerContext = {
325
325
  ...baseContext,
326
326
  fix: false,