@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.
@@ -2,16 +2,13 @@ import meow from 'meow';
2
2
  import chalk from 'chalk';
3
3
  import path from 'path';
4
4
  import pc from 'picocolors';
5
- import fs from 'fs/promises';
5
+ import fs$1 from 'fs/promises';
6
6
  import { glob } from 'fast-glob';
7
7
  import { Internal, FILE_REF_PROP, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST, VAL_EXTENSION } from '@valbuild/core';
8
- import { createService, createFixPatch, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, getModulePathRange, createModulePathMap, getSettings, uploadRemoteFile, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, formatPatchSourceError } from '@valbuild/server';
9
- import vm from 'node:vm';
10
- import ts from 'typescript';
11
- import z from 'zod';
12
- import { createRequire } from 'node:module';
8
+ import { createService, createFixPatch, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, getModulePathRange, createModulePathMap, findAndEvalValConfigFile, getSettings, uploadRemoteFile, startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, formatPatchSourceError } from '@valbuild/server';
13
9
  import { resolveSchemaSourceFixes } from '@valbuild/shared/internal';
14
- import fs$1 from 'fs';
10
+ import ts from 'typescript';
11
+ import fs from 'fs';
15
12
  import JSZip from 'jszip';
16
13
  import readline from 'readline';
17
14
 
@@ -30,76 +27,6 @@ function info(message, opts = {}) {
30
27
  console.log(chalk.blue("️ℹ️ : ") + message);
31
28
  }
32
29
 
33
- const ValConfigSchema = z.object({
34
- project: z.string().optional(),
35
- root: z.string().optional(),
36
- files: z.object({
37
- directory: z.string().refine(val => (val === "/public" || val.startsWith("/public/") && !val.endsWith("/")) &&
38
- // Reject path traversal so the directory cannot escape /public
39
- !val.split("/").some(segment => segment === "." || segment === ".."), {
40
- message: "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments"
41
- })
42
- }).optional(),
43
- gitCommit: z.string().optional(),
44
- gitBranch: z.string().optional(),
45
- defaultTheme: z.union([z.literal("light"), z.literal("dark")]).optional(),
46
- ai: z.object({
47
- commitMessages: z.object({
48
- disabled: z.boolean().optional()
49
- }).optional()
50
- }).optional()
51
- });
52
- async function evalValConfigFile(projectRoot, configFileName) {
53
- const valConfigPath = path.join(projectRoot, configFileName);
54
- let code = null;
55
- try {
56
- code = await fs.readFile(valConfigPath, "utf-8");
57
- } catch {
58
- //
59
- }
60
- if (!code) {
61
- return null;
62
- }
63
- const transpiled = ts.transpileModule(code, {
64
- compilerOptions: {
65
- target: ts.ScriptTarget.ES2020,
66
- module: ts.ModuleKind.CommonJS,
67
- esModuleInterop: true
68
- },
69
- fileName: valConfigPath
70
- });
71
- const projectRootRequire = createRequire(valConfigPath);
72
- const exportsObj = {};
73
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
- const sandbox = {
75
- exports: exportsObj,
76
- module: {
77
- exports: exportsObj
78
- },
79
- require: projectRootRequire,
80
- // NOTE: this is a security risk, but this code is running in the users own environment at the CLI level
81
- __filename: valConfigPath,
82
- __dirname: projectRoot,
83
- console,
84
- process
85
- };
86
- sandbox.global = sandbox;
87
- const context = vm.createContext(sandbox);
88
- const script = new vm.Script(transpiled.outputText, {
89
- filename: valConfigPath
90
- });
91
- script.runInContext(context);
92
- const valConfig = sandbox.module.exports.config;
93
- if (!valConfig) {
94
- throw Error(`Val config file at path: '${valConfigPath}' must export a config object. Got: ${valConfig}`);
95
- }
96
- const result = ValConfigSchema.safeParse(valConfig);
97
- if (!result.success) {
98
- throw Error(`Val config file at path: '${valConfigPath}' has invalid schema: ${result.error.message}`);
99
- }
100
- return result.data;
101
- }
102
-
103
30
  function getFileExt(filePath) {
104
31
  // NOTE: We do not import the path module. This code is copied in different projects. We want the same implementation and which means that this might running in browser where path is not available).
105
32
  return filePath.split(".").pop() || "";
@@ -141,32 +68,13 @@ async function handleFileMetadata(ctx) {
141
68
  shouldApplyPatch: true
142
69
  };
143
70
  }
144
- async function handleRemoteFileUpload(ctx) {
145
- var _resolvedRemoteFileAt;
146
- if (!ctx.fix) {
147
- return {
148
- success: false,
149
- errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`
150
- };
151
- }
152
- const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
153
- if (!ctx.valModule.source || !ctx.valModule.schema) {
154
- return {
155
- success: false,
156
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
157
- };
158
- }
159
- const resolvedRemoteFileAtSourcePath = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
160
71
 
161
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
- const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[FILE_REF_PROP];
163
- if (!fileRefProp) {
164
- return {
165
- success: false,
166
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
167
- };
168
- }
169
- const filePath = path.join(ctx.projectRoot, fileRefProp);
72
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
73
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
74
+ // how they derive the local file ref, metadata and serialized image/file
75
+ // schema; everything from auth through upload is identical.
76
+ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
77
+ const filePath = path.join(ctx.projectRoot, fileRef);
170
78
  if (!ctx.fs.fileExists(filePath)) {
171
79
  return {
172
80
  success: false,
@@ -206,21 +114,6 @@ async function handleRemoteFileUpload(ctx) {
206
114
  }]
207
115
  };
208
116
  }
209
- if (!resolvedRemoteFileAtSourcePath.schema) {
210
- return {
211
- success: false,
212
- errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
213
- };
214
- }
215
- const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
216
- const fileSourceMetadata = Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
217
- const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
218
- if (!resolveRemoteFileSchema) {
219
- return {
220
- success: false,
221
- errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`
222
- };
223
- }
224
117
  const projectName = ctx.project;
225
118
  let publicProjectId = ctx.publicProjectId;
226
119
  let remoteFileBuckets = ctx.remoteFileBuckets;
@@ -256,12 +149,6 @@ async function handleRemoteFileUpload(ctx) {
256
149
  errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
257
150
  };
258
151
  }
259
- if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
260
- return {
261
- success: false,
262
- errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
263
- };
264
- }
265
152
  remoteFilesCounter += 1;
266
153
  const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
267
154
  if (!bucket) {
@@ -287,8 +174,6 @@ async function handleRemoteFileUpload(ctx) {
287
174
  const fileHash = Internal.remote.getFileHash(fileBuffer);
288
175
  const coreVersion = Internal.VERSION.core || "unknown";
289
176
  const fileExt = getFileExt(filePath);
290
- const schema = resolveRemoteFileSchema;
291
- const metadata = fileSourceMetadata;
292
177
  const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
293
178
  publicProjectId,
294
179
  coreVersion,
@@ -308,7 +193,7 @@ async function handleRemoteFileUpload(ctx) {
308
193
  }
309
194
  ctx.remoteFiles[ctx.sourcePath] = {
310
195
  ref,
311
- metadata: fileSourceMetadata
196
+ metadata
312
197
  };
313
198
  return {
314
199
  success: true,
@@ -325,6 +210,131 @@ async function handleRemoteFileUpload(ctx) {
325
210
  }]
326
211
  };
327
212
  }
213
+ async function handleRemoteFileUpload(ctx) {
214
+ var _resolvedRemoteFileAt;
215
+ if (!ctx.fix) {
216
+ return {
217
+ success: false,
218
+ // No sourcePath in the message: the reported location already points at it.
219
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
220
+ };
221
+ }
222
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
223
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
224
+ return {
225
+ success: false,
226
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
227
+ };
228
+ }
229
+ const resolvedRemoteFileAtSourcePath = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
230
+
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[FILE_REF_PROP];
233
+ if (!fileRefProp) {
234
+ return {
235
+ success: false,
236
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
237
+ };
238
+ }
239
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
240
+ if (!resolveRemoteFileSchema) {
241
+ return {
242
+ success: false,
243
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
244
+ };
245
+ }
246
+ if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
247
+ return {
248
+ success: false,
249
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
250
+ };
251
+ }
252
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
253
+ const fileSourceMetadata = Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
254
+ return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
255
+ }
256
+
257
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
258
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
259
+ // path and the value is bare metadata (no FileSource), so we derive the file
260
+ // ref from the key and synthesize the image/file schema from the record's
261
+ // media options.
262
+ async function handleRemoteGalleryFileUpload(ctx) {
263
+ var _ctx$validationError$;
264
+ if (!ctx.fix) {
265
+ return {
266
+ success: false,
267
+ // No sourcePath in the message: the reported location already points at it.
268
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
269
+ };
270
+ }
271
+ const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
272
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
273
+
274
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
275
+ // that key as the error value.
276
+ const fileRef = ctx.validationError.value;
277
+ if (typeof fileRef !== "string") {
278
+ return {
279
+ success: false,
280
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
281
+ };
282
+ }
283
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
284
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
285
+ return {
286
+ success: false,
287
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
288
+ };
289
+ }
290
+ const resolved = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
291
+ const entrySource = resolved.source;
292
+ const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
293
+
294
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
295
+ // synthesize the serialized image/file schema (matching how single fields
296
+ // serialize) for the remote ref's validation hash.
297
+ //
298
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
299
+ // from the entry's PARENT path - not from the module root, which is only the
300
+ // record when the gallery is the whole module. A nested gallery
301
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
302
+ // with no options and bake a validation hash into the remote ref that can
303
+ // never validate, so a mismatch fails fast instead of uploading.
304
+ const [, parentModulePath] = Internal.splitModuleFilePathAndModulePath(Internal.parentOfSourcePath(ctx.sourcePath));
305
+ const recordSchema = Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
306
+ if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
307
+ return {
308
+ success: false,
309
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
310
+ };
311
+ }
312
+ const {
313
+ accept,
314
+ directory
315
+ } = recordSchema;
316
+ const schema = mediaType === "image" ? {
317
+ type: "image",
318
+ opt: false,
319
+ options: {
320
+ ...(accept ? {
321
+ accept
322
+ } : {}),
323
+ ...(directory ? {
324
+ directory
325
+ } : {})
326
+ }
327
+ } : {
328
+ type: "file",
329
+ opt: false,
330
+ options: {
331
+ ...(accept ? {
332
+ accept
333
+ } : {})
334
+ }
335
+ };
336
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
337
+ }
328
338
  async function handleRemoteFileDownload(ctx) {
329
339
  if (ctx.fix) {
330
340
  return {
@@ -382,6 +392,17 @@ async function handleUniqueFolderCheck(ctx) {
382
392
  success: true
383
393
  };
384
394
  }
395
+
396
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
397
+ // entries by a remote URL while keeping the file on disk; everything else is
398
+ // already a local path and is returned unchanged.
399
+ function remoteKeyToLocalPath(key) {
400
+ const remoteRefRes = Internal.remote.splitRemoteRef(key);
401
+ if (remoteRefRes.status === "success") {
402
+ return `/${remoteRefRes.filePath}`;
403
+ }
404
+ return key;
405
+ }
385
406
  async function handleCheckAllFiles(ctx) {
386
407
  const value = ctx.validationError.value;
387
408
  if (!value || typeof value.directory !== "string") {
@@ -400,7 +421,10 @@ async function handleCheckAllFiles(ctx) {
400
421
  errorMessage: `Could not get source for ${ctx.sourcePath}`
401
422
  };
402
423
  }
403
- const trackedFiles = new Set(Object.keys(source));
424
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
425
+ // entries by a remote URL, but the file is kept on disk at its local path, so
426
+ // normalize remote-URL keys back to that local path for the on-disk checks.
427
+ const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
404
428
 
405
429
  // Check that all tracked files exist on disk
406
430
  const missingTrackedFiles = [...trackedFiles].filter(f => {
@@ -455,6 +479,8 @@ const currentFixHandlers = {
455
479
  "file:add-metadata": handleFileMetadata,
456
480
  "image:upload-remote": handleRemoteFileUpload,
457
481
  "file:upload-remote": handleRemoteFileUpload,
482
+ "images:upload-remote": handleRemoteGalleryFileUpload,
483
+ "files:upload-remote": handleRemoteGalleryFileUpload,
458
484
  "image:download-remote": handleRemoteFileDownload,
459
485
  "file:download-remote": handleRemoteFileDownload,
460
486
  "image:check-remote": handleRemoteFileCheck,
@@ -477,15 +503,15 @@ function createDefaultValFSHost() {
477
503
  return {
478
504
  ...ts.sys,
479
505
  writeFile: (fileName, data, encoding) => {
480
- fs$1.mkdirSync(path.dirname(fileName), {
506
+ fs.mkdirSync(path.dirname(fileName), {
481
507
  recursive: true
482
508
  });
483
- fs$1.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
509
+ fs.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
484
510
  },
485
- rmFile: fs$1.rmSync,
511
+ rmFile: fs.rmSync,
486
512
  readBuffer: fileName => {
487
513
  try {
488
- return fs$1.readFileSync(fileName);
514
+ return fs.readFileSync(fileName);
489
515
  } catch {
490
516
  return undefined;
491
517
  }
@@ -826,7 +852,7 @@ function getCachedFile(moduleFilePath, projectRoot, cache) {
826
852
  const filePath = path.join(projectRoot, moduleFilePath);
827
853
  let fileContent;
828
854
  try {
829
- fileContent = fs$1.readFileSync(filePath, "utf-8");
855
+ fileContent = fs.readFileSync(filePath, "utf-8");
830
856
  } catch {
831
857
  cache.set(moduleFilePath, null);
832
858
  return null;
@@ -857,7 +883,7 @@ async function validate({
857
883
  // errors found. Re-reads config and val files each call so it always reflects
858
884
  // the latest state on disk (used both for one-shot and watch mode).
859
885
  async function runOnce() {
860
- const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
886
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
861
887
  const resolvedValConfigFile = valConfigFile ? {
862
888
  ...valConfigFile,
863
889
  project: process.env.VAL_PROJECT || valConfigFile.project
@@ -1037,11 +1063,11 @@ async function validate({
1037
1063
  if (prettier) {
1038
1064
  for (const file of fixedFiles) {
1039
1065
  const filePath = path.join(projectRoot, file);
1040
- const fileContent = await fs.readFile(filePath, "utf-8");
1066
+ const fileContent = await fs$1.readFile(filePath, "utf-8");
1041
1067
  const formattedContent = await prettier.format(fileContent, {
1042
1068
  filepath: filePath
1043
1069
  });
1044
- await fs.writeFile(filePath, formattedContent);
1070
+ await fs$1.writeFile(filePath, formattedContent);
1045
1071
  }
1046
1072
  }
1047
1073
 
@@ -1162,7 +1188,7 @@ async function listUnusedFiles({
1162
1188
  }) {
1163
1189
  var _valConfigFile$files;
1164
1190
  const projectRoot = root ? path.resolve(root) : process.cwd();
1165
- const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
1191
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
1166
1192
  // Strip the leading "/" so it is relative to the project root (e.g. "public/val").
1167
1193
  const managedDir = ((valConfigFile === null || valConfigFile === void 0 || (_valConfigFile$files = valConfigFile.files) === null || _valConfigFile$files === void 0 ? void 0 : _valConfigFile$files.directory) ?? "/public/val").replace(/^\//, "");
1168
1194
  const service = await createService(projectRoot);
@@ -1251,7 +1277,7 @@ const getVersions = () => {
1251
1277
  };
1252
1278
  };
1253
1279
 
1254
- const host$1 = process.env.VAL_BUILD_URL || "https://admin.val.build";
1280
+ const host = process.env.VAL_BUILD_URL || "https://admin.val.build";
1255
1281
  async function connect(options) {
1256
1282
  const {
1257
1283
  root
@@ -1269,11 +1295,11 @@ async function connect(options) {
1269
1295
  if (maybeGitOwnerAndRepo) {
1270
1296
  params.set("github_repo", [maybeGitOwnerAndRepo.owner, maybeGitOwnerAndRepo.repo].join("/"));
1271
1297
  }
1272
- const url = `${host$1}/connect?${params.toString()}`;
1298
+ const url = `${host}/connect?${params.toString()}`;
1273
1299
  console.log(pc.dim(`\nFollow the instructions in your browser to complete the setup:\n${pc.cyan(url)}\n`));
1274
1300
  }
1275
1301
  async function tryGetProject(projectRoot) {
1276
- const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
1302
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
1277
1303
  if (valConfigFile && valConfigFile.project) {
1278
1304
  const parts = valConfigFile.project.split("/");
1279
1305
  if (parts.length === 2) {
@@ -1326,8 +1352,8 @@ function tryGetGitConfig(root) {
1326
1352
  let lastDir = null;
1327
1353
  while (currentDir !== lastDir) {
1328
1354
  const gitConfigPath = path.join(currentDir, ".git", "config");
1329
- if (fs$1.existsSync(gitConfigPath)) {
1330
- return fs$1.readFileSync(gitConfigPath, "utf-8");
1355
+ if (fs.existsSync(gitConfigPath)) {
1356
+ return fs.readFileSync(gitConfigPath, "utf-8");
1331
1357
  }
1332
1358
  lastDir = currentDir;
1333
1359
  currentDir = path.dirname(currentDir);
@@ -1338,83 +1364,54 @@ function tryGetGitConfig(root) {
1338
1364
  return null;
1339
1365
  }
1340
1366
 
1341
- const host = process.env.VAL_BUILD_URL || "https://admin.val.build";
1367
+ /**
1368
+ * Terminal wrapper around the login device flow in `@valbuild/server`.
1369
+ * The flow itself (polling, token persistence) is shared with
1370
+ * `@valbuild/language-server`.
1371
+ */
1342
1372
  async function login(options) {
1343
1373
  try {
1344
- var _response$headers$get;
1345
1374
  console.log(pc.cyan("\nStarting login process...\n"));
1346
-
1347
- // Step 1: Initiate login and get token and URL
1348
- const response = await fetch(`${host}/api/login`, {
1349
- method: "POST",
1350
- headers: {
1351
- "Content-Type": "application/json"
1352
- }
1353
- });
1354
- let token;
1355
- let url;
1356
- if (!((_response$headers$get = response.headers.get("content-type")) !== null && _response$headers$get !== void 0 && _response$headers$get.includes("application/json"))) {
1357
- const text = await response.text();
1358
- console.error(pc.red("Unexpected failure while trying to login (content type was not JSON). "), text ? `Server response: ${text} (status: ${response.status})` : `Status: ${response.status}`);
1359
- process.exit(1);
1360
- }
1361
- const json = await response.json();
1362
- if (json) {
1363
- token = json.nonce;
1364
- url = json.url;
1365
- }
1366
- if (!token || !url) {
1367
- console.error(pc.red("Unexpected response from the server."), json);
1368
- process.exit(1);
1369
- }
1375
+ const {
1376
+ nonce,
1377
+ url
1378
+ } = await startValLogin();
1370
1379
  console.log(pc.green("Open the following URL in your browser to log in:"));
1371
1380
  console.log(pc.underline(pc.blue(url)));
1372
1381
  console.log(pc.dim("\nWaiting for login confirmation...\n"));
1373
-
1374
- // Step 2: Poll for login confirmation
1375
- const result = await pollForConfirmation(token);
1376
-
1377
- // Step 3: Save the token
1378
- const filePath = getPersonalAccessTokenPath(options.root || process.cwd());
1379
- saveToken(result, filePath);
1382
+ const result = await awaitValLoginConfirmation(nonce);
1383
+ const filePath = persistPersonalAccessToken(options.root || process.cwd(), result);
1384
+ console.log(pc.green(`Token for ${pc.cyan(result.profile.email)} saved to ${pc.cyan(filePath)}`));
1380
1385
  } catch (error) {
1381
- console.error(pc.red("An error occurred during the login process. Check your internet connection. Details:"), error instanceof Error ? error.message : JSON.stringify(error, null, 2));
1382
- process.exit(1);
1383
- }
1384
- }
1385
- const MAX_DURATION = 5 * 60 * 1000; // 5 minutes
1386
- async function pollForConfirmation(token) {
1387
- const start = Date.now();
1388
- while (Date.now() - start < MAX_DURATION) {
1389
- await new Promise(resolve => setTimeout(resolve, 1000));
1390
- const response = await fetch(`${host}/api/login?token=${token}&consume=true`, {
1391
- method: "POST"
1392
- });
1393
- if (response.status === 500) {
1394
- console.error(pc.red("An error occurred on the server."));
1386
+ if (error instanceof ValLoginError) {
1387
+ console.error(pc.red(error.message), error.details ?? "");
1395
1388
  process.exit(1);
1396
1389
  }
1397
- if (response.status === 200) {
1398
- const json = await response.json();
1399
- if (json) {
1400
- if (typeof json.profile.email === "string" && typeof json.pat === "string") {
1401
- return json;
1402
- } else {
1403
- console.error(pc.red("Unexpected response from the server."));
1404
- process.exit(1);
1405
- }
1406
- }
1407
- }
1390
+ console.error(pc.red("An error occurred during the login process. Check your internet connection. Details:"), error instanceof Error ? error.message : JSON.stringify(error, null, 2));
1391
+ process.exit(1);
1408
1392
  }
1409
- console.error(pc.red("Login confirmation timed out."));
1410
- process.exit(1);
1411
1393
  }
1412
- function saveToken(result, filePath) {
1413
- fs$1.mkdirSync(path.dirname(filePath), {
1414
- recursive: true
1415
- });
1416
- fs$1.writeFileSync(filePath, JSON.stringify(result, null, 2));
1417
- console.log(pc.green(`Token for ${pc.cyan(result.profile.email)} saved to ${pc.cyan(filePath)}`));
1394
+
1395
+ /**
1396
+ * Start the Val language server.
1397
+ *
1398
+ * Editors normally resolve `@valbuild/language-server` from the project's
1399
+ * `node_modules` and launch its `val-language-server` binary directly. This
1400
+ * subcommand exists so the server is discoverable from the CLI and easy to
1401
+ * start by hand when debugging an editor integration:
1402
+ *
1403
+ * npx val lsp --stdio
1404
+ *
1405
+ * The import is dynamic so that `vscode-languageserver` is not loaded on every
1406
+ * `val` invocation.
1407
+ */
1408
+ async function lsp() {
1409
+ const {
1410
+ main
1411
+ } = await import('@valbuild/language-server');
1412
+ // Transport (`--stdio`, `--node-ipc`, `--socket=`) is read from argv by the
1413
+ // server itself, so the flags pass straight through.
1414
+ main();
1418
1415
  }
1419
1416
 
1420
1417
  /**
@@ -1431,10 +1428,10 @@ class DebugContextError extends Error {}
1431
1428
  async function createDebugContext(options) {
1432
1429
  var _config$files;
1433
1430
  const projectRoot = options.root ? path.resolve(options.root) : process.cwd();
1434
- if (!fs$1.existsSync(projectRoot)) {
1431
+ if (!fs.existsSync(projectRoot)) {
1435
1432
  throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
1436
1433
  }
1437
- const config = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
1434
+ const config = await findAndEvalValConfigFile(projectRoot);
1438
1435
  if (!config) {
1439
1436
  throw new DebugContextError(`Could not find val.config.ts nor val.config.js in: ${projectRoot}`);
1440
1437
  }
@@ -1501,8 +1498,8 @@ async function createDebugContext(options) {
1501
1498
  */
1502
1499
  function readAuth(projectRoot) {
1503
1500
  const patFile = getPersonalAccessTokenPath(projectRoot);
1504
- if (fs$1.existsSync(patFile)) {
1505
- const contents = fs$1.readFileSync(patFile, "utf-8");
1501
+ if (fs.existsSync(patFile)) {
1502
+ const contents = fs.readFileSync(patFile, "utf-8");
1506
1503
  const parsed = parsePersonalAccessTokenFile(contents);
1507
1504
  if (parsed.success) {
1508
1505
  return {
@@ -2049,7 +2046,7 @@ function readProjectValVersions(projectRoot) {
2049
2046
  function readLocalFile(projectRoot, projectRelativePath) {
2050
2047
  const absPath = path.join(projectRoot, projectRelativePath);
2051
2048
  try {
2052
- return fs$1.readFileSync(absPath, "utf-8");
2049
+ return fs.readFileSync(absPath, "utf-8");
2053
2050
  } catch {
2054
2051
  return null;
2055
2052
  }
@@ -2191,10 +2188,10 @@ async function writeZip(outPath, entries) {
2191
2188
  type: "nodebuffer",
2192
2189
  compression: "DEFLATE"
2193
2190
  });
2194
- fs$1.mkdirSync(path.dirname(outPath), {
2191
+ fs.mkdirSync(path.dirname(outPath), {
2195
2192
  recursive: true
2196
2193
  });
2197
- fs$1.writeFileSync(outPath, buffer);
2194
+ fs.writeFileSync(outPath, buffer);
2198
2195
  }
2199
2196
  function timestamp() {
2200
2197
  return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
@@ -2331,6 +2328,7 @@ async function main() {
2331
2328
  files
2332
2329
  connect
2333
2330
  versions
2331
+ lsp
2334
2332
  debug
2335
2333
  delete-unappliable-patches
2336
2334
 
@@ -2360,6 +2358,13 @@ async function main() {
2360
2358
  Options:
2361
2359
  --root [root], -r [root] Set project root directory (default process.cwd())
2362
2360
 
2361
+ Command: lsp
2362
+ Description: start the Val language server (used by editor integrations)
2363
+ Options:
2364
+ --stdio Communicate over stdin/stdout
2365
+ --node-ipc Communicate over Node IPC
2366
+ --socket=[port] Communicate over a socket
2367
+
2363
2368
  Command: debug
2364
2369
  Description: Create a self-contained snapshot (the pending patches plus the modules they
2365
2370
  touch or reference) that Val developers can replay to reproduce validation and save
@@ -2389,11 +2394,6 @@ async function main() {
2389
2394
  --verbose List every pending patch, not just the failing ones
2390
2395
  `, {
2391
2396
  flags: {
2392
- port: {
2393
- type: "number",
2394
- alias: "p",
2395
- default: 4123
2396
- },
2397
2397
  root: {
2398
2398
  type: "string",
2399
2399
  alias: "r"
@@ -2455,6 +2455,8 @@ async function main() {
2455
2455
  });
2456
2456
  case "versions":
2457
2457
  return versions();
2458
+ case "lsp":
2459
+ return lsp();
2458
2460
  case "debug":
2459
2461
  return debug({
2460
2462
  root: flags.root,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.97.7",
4
+ "version": "0.99.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -27,10 +27,11 @@
27
27
  "open": "^9.1.0",
28
28
  "picocolors": "^1.1.1",
29
29
  "zod": "^4.3.5",
30
- "@valbuild/core": "0.97.7",
30
+ "@valbuild/core": "0.99.0",
31
31
  "@valbuild/eslint-plugin": "0.93.0",
32
- "@valbuild/server": "0.97.7",
33
- "@valbuild/shared": "0.97.7"
32
+ "@valbuild/language-server": "0.99.0",
33
+ "@valbuild/server": "0.99.0",
34
+ "@valbuild/shared": "0.99.0"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "prettier": "*",