@valbuild/cli 0.97.7 → 0.99.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.
@@ -0,0 +1,20 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ export default c.define(
4
+ "/content/basic-gallery-remote-existing.val.ts",
5
+ s.images({
6
+ directory: "/public/val/images-remote2",
7
+ accept: "image/*",
8
+ remote: true,
9
+ }),
10
+
11
+ {
12
+ "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc1/f/def4/p/public/val/images-remote2/image.png":
13
+ {
14
+ width: 1,
15
+ height: 1,
16
+ mimeType: "image/png",
17
+ alt: null,
18
+ },
19
+ },
20
+ );
@@ -0,0 +1,19 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ export default c.define(
4
+ "/content/basic-gallery-remote.val.ts",
5
+ s.images({
6
+ directory: "/public/val/images-remote",
7
+ accept: "image/*",
8
+ remote: true,
9
+ }),
10
+
11
+ {
12
+ "/public/val/images-remote/image.png": {
13
+ width: 1,
14
+ height: 1,
15
+ mimeType: "image/png",
16
+ alt: null,
17
+ },
18
+ },
19
+ );
@@ -1,5 +1,7 @@
1
1
  import { initVal } from "@valbuild/core";
2
2
 
3
- const { s, c, config } = initVal();
3
+ // NOTE: initVal() must be called with an options object for `config` to be
4
+ // defined - the CLI requires val.config to export a config object.
5
+ const { s, c, config } = initVal({});
4
6
 
5
7
  export { s, c, config };
@@ -13,4 +13,6 @@ export default modules(config, [
13
13
  { def: () => import("./content/basic-gallery-fail-on-non-unique-dir.val") },
14
14
  { def: () => import("./content/basic-gallery-missing-tracked.val") },
15
15
  { def: () => import("./content/basic-gallery-wrong-metadata.val") },
16
+ { def: () => import("./content/basic-gallery-remote.val") },
17
+ { def: () => import("./content/basic-gallery-remote-existing.val") },
16
18
  ]);
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import { getVersions } from "./getVersions";
6
6
  import { connect } from "./connect";
7
7
  import chalk from "chalk";
8
8
  import { login } from "./login";
9
+ import { lsp } from "./lsp";
9
10
  import { debug } from "./debug";
10
11
  import { deleteUnappliablePatches } from "./deleteUnappliablePatches";
11
12
 
@@ -24,6 +25,7 @@ async function main(): Promise<void> {
24
25
  files
25
26
  connect
26
27
  versions
28
+ lsp
27
29
  debug
28
30
  delete-unappliable-patches
29
31
 
@@ -53,6 +55,13 @@ async function main(): Promise<void> {
53
55
  Options:
54
56
  --root [root], -r [root] Set project root directory (default process.cwd())
55
57
 
58
+ Command: lsp
59
+ Description: start the Val language server (used by editor integrations)
60
+ Options:
61
+ --stdio Communicate over stdin/stdout
62
+ --node-ipc Communicate over Node IPC
63
+ --socket=[port] Communicate over a socket
64
+
56
65
  Command: debug
57
66
  Description: Create a self-contained snapshot (the pending patches plus the modules they
58
67
  touch or reference) that Val developers can replay to reproduce validation and save
@@ -83,11 +92,6 @@ async function main(): Promise<void> {
83
92
  `,
84
93
  {
85
94
  flags: {
86
- port: {
87
- type: "number",
88
- alias: "p",
89
- default: 4123,
90
- },
91
95
  root: {
92
96
  type: "string",
93
97
  alias: "r",
@@ -155,6 +159,8 @@ async function main(): Promise<void> {
155
159
  });
156
160
  case "versions":
157
161
  return versions();
162
+ case "lsp":
163
+ return lsp();
158
164
  case "debug":
159
165
  return debug({
160
166
  root: flags.root,
package/src/connect.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import pc from "picocolors";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
- import { evalValConfigFile } from "./utils/evalValConfigFile";
4
+ import { findAndEvalValConfigFile } from "./utils/evalValConfigFile";
5
5
 
6
6
  const host = process.env.VAL_BUILD_URL || "https://admin.val.build";
7
7
 
@@ -39,9 +39,7 @@ async function tryGetProject(projectRoot: string): Promise<{
39
39
  orgName: string;
40
40
  projectName: string;
41
41
  } | null> {
42
- const valConfigFile =
43
- (await evalValConfigFile(projectRoot, "val.config.ts")) ||
44
- (await evalValConfigFile(projectRoot, "val.config.js"));
42
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
45
43
 
46
44
  if (valConfigFile && valConfigFile.project) {
47
45
  const parts = valConfigFile.project.split("/");
@@ -9,7 +9,7 @@ import {
9
9
  ValOpsFS,
10
10
  ValOpsHttp,
11
11
  } from "@valbuild/server";
12
- import { evalValConfigFile } from "../utils/evalValConfigFile";
12
+ import { findAndEvalValConfigFile } from "../utils/evalValConfigFile";
13
13
 
14
14
  /**
15
15
  * Everything the debug commands need in order to talk to the same ops the
@@ -56,9 +56,7 @@ export async function createDebugContext(options: {
56
56
  if (!fs.existsSync(projectRoot)) {
57
57
  throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
58
58
  }
59
- const config =
60
- (await evalValConfigFile(projectRoot, "val.config.ts")) ||
61
- (await evalValConfigFile(projectRoot, "val.config.js"));
59
+ const config = await findAndEvalValConfigFile(projectRoot);
62
60
  if (!config) {
63
61
  throw new DebugContextError(
64
62
  `Could not find val.config.ts nor val.config.js in: ${projectRoot}`,
@@ -8,14 +8,12 @@ import {
8
8
  import { createService } from "@valbuild/server";
9
9
  import { glob } from "fast-glob";
10
10
  import path from "path";
11
- import { evalValConfigFile } from "./utils/evalValConfigFile";
11
+ import { findAndEvalValConfigFile } from "./utils/evalValConfigFile";
12
12
 
13
13
  export async function listUnusedFiles({ root }: { root?: string }) {
14
14
  const projectRoot = root ? path.resolve(root) : process.cwd();
15
15
 
16
- const valConfigFile =
17
- (await evalValConfigFile(projectRoot, "val.config.ts")) ||
18
- (await evalValConfigFile(projectRoot, "val.config.js"));
16
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
19
17
  // Strip the leading "/" so it is relative to the project root (e.g. "public/val").
20
18
  const managedDir = (valConfigFile?.files?.directory ?? "/public/val").replace(
21
19
  /^\//,
package/src/login.ts CHANGED
@@ -1,56 +1,44 @@
1
1
  import pc from "picocolors";
2
- import fs from "fs";
3
- import path from "path";
4
- import { getPersonalAccessTokenPath } from "@valbuild/server";
5
-
6
- const host = process.env.VAL_BUILD_URL || "https://admin.val.build";
7
-
2
+ import {
3
+ awaitValLoginConfirmation,
4
+ persistPersonalAccessToken,
5
+ startValLogin,
6
+ ValLoginError,
7
+ } from "@valbuild/server";
8
+
9
+ /**
10
+ * Terminal wrapper around the login device flow in `@valbuild/server`.
11
+ * The flow itself (polling, token persistence) is shared with
12
+ * `@valbuild/language-server`.
13
+ */
8
14
  export async function login(options: { root?: string }) {
9
15
  try {
10
16
  console.log(pc.cyan("\nStarting login process...\n"));
11
17
 
12
- // Step 1: Initiate login and get token and URL
13
- const response = await fetch(`${host}/api/login`, {
14
- method: "POST",
15
- headers: {
16
- "Content-Type": "application/json",
17
- },
18
- });
19
- let token;
20
- let url;
21
- if (!response.headers.get("content-type")?.includes("application/json")) {
22
- const text = await response.text();
23
- console.error(
24
- pc.red(
25
- "Unexpected failure while trying to login (content type was not JSON). ",
26
- ),
27
- text
28
- ? `Server response: ${text} (status: ${response.status})`
29
- : `Status: ${response.status}`,
30
- );
31
- process.exit(1);
32
- }
33
- const json = await response.json();
34
- if (json) {
35
- token = json.nonce;
36
- url = json.url;
37
- }
38
- if (!token || !url) {
39
- console.error(pc.red("Unexpected response from the server."), json);
40
- process.exit(1);
41
- }
18
+ const { nonce, url } = await startValLogin();
42
19
 
43
20
  console.log(pc.green("Open the following URL in your browser to log in:"));
44
21
  console.log(pc.underline(pc.blue(url)));
45
22
  console.log(pc.dim("\nWaiting for login confirmation...\n"));
46
23
 
47
- // Step 2: Poll for login confirmation
48
- const result = await pollForConfirmation(token);
24
+ const result = await awaitValLoginConfirmation(nonce);
49
25
 
50
- // Step 3: Save the token
51
- const filePath = getPersonalAccessTokenPath(options.root || process.cwd());
52
- saveToken(result, filePath);
26
+ const filePath = persistPersonalAccessToken(
27
+ options.root || process.cwd(),
28
+ result,
29
+ );
30
+ console.log(
31
+ pc.green(
32
+ `Token for ${pc.cyan(
33
+ result.profile.email,
34
+ )} saved to ${pc.cyan(filePath)}`,
35
+ ),
36
+ );
53
37
  } catch (error) {
38
+ if (error instanceof ValLoginError) {
39
+ console.error(pc.red(error.message), error.details ?? "");
40
+ process.exit(1);
41
+ }
54
42
  console.error(
55
43
  pc.red(
56
44
  "An error occurred during the login process. Check your internet connection. Details:",
@@ -60,56 +48,3 @@ export async function login(options: { root?: string }) {
60
48
  process.exit(1);
61
49
  }
62
50
  }
63
-
64
- const MAX_DURATION = 5 * 60 * 1000; // 5 minutes
65
- async function pollForConfirmation(token: string): Promise<{
66
- profile: { email: string };
67
- pat: string;
68
- }> {
69
- const start = Date.now();
70
- while (Date.now() - start < MAX_DURATION) {
71
- await new Promise((resolve) => setTimeout(resolve, 1000));
72
- const response = await fetch(
73
- `${host}/api/login?token=${token}&consume=true`,
74
- { method: "POST" },
75
- );
76
- if (response.status === 500) {
77
- console.error(pc.red("An error occurred on the server."));
78
- process.exit(1);
79
- }
80
- if (response.status === 200) {
81
- const json = await response.json();
82
- if (json) {
83
- if (
84
- typeof json.profile.email === "string" &&
85
- typeof json.pat === "string"
86
- ) {
87
- return json;
88
- } else {
89
- console.error(pc.red("Unexpected response from the server."));
90
- process.exit(1);
91
- }
92
- }
93
- }
94
- }
95
- console.error(pc.red("Login confirmation timed out."));
96
- process.exit(1);
97
- }
98
-
99
- function saveToken(
100
- result: {
101
- profile: { email: string };
102
- pat: string;
103
- },
104
- filePath: string,
105
- ) {
106
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
107
- fs.writeFileSync(filePath, JSON.stringify(result, null, 2));
108
- console.log(
109
- pc.green(
110
- `Token for ${pc.cyan(
111
- result.profile.email,
112
- )} saved to ${pc.cyan(filePath)}`,
113
- ),
114
- );
115
- }
package/src/lsp.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Start the Val language server.
3
+ *
4
+ * Editors normally resolve `@valbuild/language-server` from the project's
5
+ * `node_modules` and launch its `val-language-server` binary directly. This
6
+ * subcommand exists so the server is discoverable from the CLI and easy to
7
+ * start by hand when debugging an editor integration:
8
+ *
9
+ * npx val lsp --stdio
10
+ *
11
+ * The import is dynamic so that `vscode-languageserver` is not loaded on every
12
+ * `val` invocation.
13
+ */
14
+ export async function lsp(): Promise<void> {
15
+ const { main } = await import("@valbuild/language-server");
16
+ // Transport (`--stdio`, `--node-ipc`, `--socket=`) is read from argv by the
17
+ // server itself, so the flags pass straight through.
18
+ main();
19
+ }
@@ -383,4 +383,105 @@ describe("runValidation", () => {
383
383
  service.dispose();
384
384
  }
385
385
  });
386
+
387
+ test("reports upload-remote error for local entry in a remote gallery", async () => {
388
+ const events: ValidationEvent[] = [];
389
+
390
+ for await (const event of runValidation({
391
+ root: tmpDir,
392
+ fix: false,
393
+ valFiles: ["content/basic-gallery-remote.val.ts"],
394
+ project: undefined,
395
+ remote: mockRemote,
396
+ fs: createDefaultValFSHost(),
397
+ })) {
398
+ events.push(event);
399
+ }
400
+
401
+ expect(events.at(-1)).toEqual({
402
+ type: "summary-errors",
403
+ count: expect.any(Number),
404
+ });
405
+ const errors = events.filter((e) => e.type === "validation-error");
406
+ expect(
407
+ errors.some(
408
+ (e) =>
409
+ "message" in e &&
410
+ (e.message as string).includes("needs to be uploaded"),
411
+ ),
412
+ ).toBe(true);
413
+ });
414
+
415
+ test("uploads local entry in a remote gallery and rewrites the key when fix is true", async () => {
416
+ const uploadRemote: IValRemote = {
417
+ remoteHost: DEFAULT_VAL_REMOTE_HOST,
418
+ getSettings: async () => ({
419
+ success: true,
420
+ data: {
421
+ publicProjectId: "pubproj",
422
+ remoteFileBuckets: [{ bucket: "01" }],
423
+ },
424
+ }),
425
+ uploadFile: async () => ({ success: true }),
426
+ };
427
+ // Upload requires a personal access token on disk.
428
+ fs.mkdirSync(path.join(tmpDir, ".val"), { recursive: true });
429
+ fs.writeFileSync(
430
+ path.join(tmpDir, ".val", "pat.json"),
431
+ JSON.stringify({ pat: "test-pat" }),
432
+ );
433
+
434
+ const events: ValidationEvent[] = [];
435
+ for await (const event of runValidation({
436
+ root: tmpDir,
437
+ fix: true,
438
+ valFiles: ["content/basic-gallery-remote.val.ts"],
439
+ project: "test/project",
440
+ remote: uploadRemote,
441
+ fs: createDefaultValFSHost(),
442
+ })) {
443
+ events.push(event);
444
+ }
445
+
446
+ expect(events.some((e) => e.type === "remote-uploaded")).toBe(true);
447
+ expect(events.some((e) => e.type === "fix-applied")).toBe(true);
448
+
449
+ const service = await createService(tmpDir, createDefaultValFSHost());
450
+ try {
451
+ const result = await service.get(
452
+ "/content/basic-gallery-remote.val.ts" as ModuleFilePath,
453
+ "" as ModulePath,
454
+ { validate: false },
455
+ );
456
+ const source = result.source as Record<string, unknown>;
457
+ // The local-path key is replaced by a remote URL key (file kept on disk).
458
+ expect(source).not.toHaveProperty("/public/val/images-remote/image.png");
459
+ const keys = Object.keys(source);
460
+ expect(keys).toHaveLength(1);
461
+ expect(keys[0]).toContain("pubproj");
462
+ expect(keys[0]).toContain("public/val/images-remote/image.png");
463
+ } finally {
464
+ service.dispose();
465
+ }
466
+ });
467
+
468
+ test("does not flag a kept-on-disk file behind a remote gallery key", async () => {
469
+ const events: ValidationEvent[] = [];
470
+
471
+ for await (const event of runValidation({
472
+ root: tmpDir,
473
+ fix: false,
474
+ valFiles: ["content/basic-gallery-remote-existing.val.ts"],
475
+ project: undefined,
476
+ remote: mockRemote,
477
+ fs: createDefaultValFSHost(),
478
+ })) {
479
+ events.push(event);
480
+ }
481
+
482
+ // The file kept on disk under the remote URL key must not be reported as
483
+ // untracked, and the remote URL key must not be reported as missing.
484
+ expect(events.at(-1)).toEqual({ type: "summary-success" });
485
+ expect(events.filter((e) => e.type === "validation-error")).toHaveLength(0);
486
+ });
386
487
  });