@valbuild/server 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.
@@ -6,16 +6,17 @@ import * as path from 'path';
6
6
  import path__default from 'path';
7
7
  import fs, { promises } from 'fs';
8
8
  import vm from 'node:vm';
9
- import { Module } from 'node:module';
9
+ import { Module, createRequire } from 'node:module';
10
10
  import { resolveSchemaSourceFixForError, getErrorMessageFromUnknownJson, Patch, ParentRef, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
11
11
  import { createUIRequestHandler } from '@valbuild/ui/server';
12
12
  import crypto$1 from 'crypto';
13
- import { z } from 'zod';
13
+ import z$1, { z } from 'zod';
14
14
  import sizeOf from 'image-size';
15
15
  import { fromError } from 'zod-validation-error';
16
16
  import { transform } from 'sucrase';
17
17
  import http from 'http';
18
18
  import https from 'https';
19
+ import fs$1 from 'fs/promises';
19
20
 
20
21
  class ValSyntaxError {
21
22
  constructor(message, node) {
@@ -925,6 +926,15 @@ const getCompilerOptions = (rootDir, parseConfigHost) => {
925
926
  return parsedConfigFile.options;
926
927
  };
927
928
 
929
+ /**
930
+ * The filesystem seam `loadValModules` reads through.
931
+ *
932
+ * Defaults to `ts.sys`, i.e. the real filesystem. An editor integration (see
933
+ * `@valbuild/language-server`) passes a host that overlays unsaved buffers, so
934
+ * that evaluation sees what the user is looking at rather than what was last
935
+ * saved.
936
+ */
937
+
928
938
  /**
929
939
  * Loads the project's root `val.modules.ts` (or `.js`) using Node's `vm`
930
940
  * module and returns its default export (a `ValModules` registry).
@@ -946,24 +956,24 @@ const getCompilerOptions = (rootDir, parseConfigHost) => {
946
956
  * same trust level as running the project's build. It must never be used to
947
957
  * evaluate untrusted or third-party modules.
948
958
  */
949
- function loadValModules(projectRoot) {
950
- const valModulesPath = findValModulesPath(projectRoot);
959
+ function loadValModules(projectRoot, host = ts.sys) {
960
+ const valModulesPath = findValModulesPath(projectRoot, host);
951
961
  if (!valModulesPath) {
952
962
  throw Error(`Could not find 'val.modules.ts' nor 'val.modules.js' in project root: '${projectRoot}'`);
953
963
  }
954
- const compilerOptions = getCompilerOptions(projectRoot, ts.sys);
964
+ const compilerOptions = getCompilerOptions(projectRoot, host);
955
965
  const cache = {};
956
- const loaded = loadModule(valModulesPath, cache, compilerOptions);
966
+ const loaded = loadModule(valModulesPath, cache, compilerOptions, host);
957
967
  const valModules = loaded.exports.default;
958
968
  if (!valModules) {
959
969
  throw Error(`Val modules file at path: '${valModulesPath}' must have a default export. Got: ${valModules}`);
960
970
  }
961
971
  return valModules;
962
972
  }
963
- function findValModulesPath(projectRoot) {
973
+ function findValModulesPath(projectRoot, host) {
964
974
  for (const fileName of ["val.modules.ts", "val.modules.js"]) {
965
975
  const candidate = path__default.join(projectRoot, fileName);
966
- if (fs.existsSync(candidate)) {
976
+ if (host.fileExists(candidate)) {
967
977
  return candidate;
968
978
  }
969
979
  }
@@ -1004,12 +1014,15 @@ function makeStub(spec) {
1004
1014
  const stub = new Proxy({}, handler);
1005
1015
  return stub;
1006
1016
  }
1007
- function loadModule(absPath, cache, compilerOptions) {
1017
+ function loadModule(absPath, cache, compilerOptions, host) {
1008
1018
  const cached = cache[absPath];
1009
1019
  if (cached) {
1010
1020
  return cached;
1011
1021
  }
1012
- const code = fs.readFileSync(absPath, "utf-8");
1022
+ const code = host.readFile(absPath);
1023
+ if (code === undefined) {
1024
+ throw Error(`Could not read Val module file: '${absPath}'`);
1025
+ }
1013
1026
  const transpiled = ts.transpileModule(code, {
1014
1027
  compilerOptions: {
1015
1028
  target: ts.ScriptTarget.ES2020,
@@ -1032,17 +1045,17 @@ function loadModule(absPath, cache, compilerOptions) {
1032
1045
  return makeStub(spec);
1033
1046
  }
1034
1047
  if (spec.startsWith(".") || path__default.isAbsolute(spec)) {
1035
- const resolved = resolveRelative(dirName, spec);
1048
+ const resolved = resolveRelative(dirName, spec, host);
1036
1049
  if (!resolved) {
1037
1050
  throw Error(`Could not resolve module '${spec}' from '${absPath}'`);
1038
1051
  }
1039
- return loadModule(resolved, cache, compilerOptions).exports;
1052
+ return loadModule(resolved, cache, compilerOptions, host).exports;
1040
1053
  }
1041
1054
  // Non-relative specifier: it might be a tsconfig path alias (e.g. "_/val.config")
1042
1055
  // pointing at a local source file, or an actual node_modules package.
1043
- const tsResolved = (_ts$resolveModuleName = ts.resolveModuleName(spec, absPath, compilerOptions, ts.sys).resolvedModule) === null || _ts$resolveModuleName === void 0 ? void 0 : _ts$resolveModuleName.resolvedFileName;
1056
+ const tsResolved = (_ts$resolveModuleName = ts.resolveModuleName(spec, absPath, compilerOptions, host).resolvedModule) === null || _ts$resolveModuleName === void 0 ? void 0 : _ts$resolveModuleName.resolvedFileName;
1044
1057
  if (tsResolved && !tsResolved.includes("/node_modules/") && !tsResolved.endsWith(".d.ts")) {
1045
- return loadModule(tsResolved, cache, compilerOptions).exports;
1058
+ return loadModule(tsResolved, cache, compilerOptions, host).exports;
1046
1059
  }
1047
1060
  // Real node_modules package – use the real require so user modules share
1048
1061
  // the same @valbuild/core instance as extractValModules.
@@ -1068,24 +1081,25 @@ function loadModule(absPath, cache, compilerOptions) {
1068
1081
  script.runInContext(context);
1069
1082
  return moduleObj;
1070
1083
  }
1071
- function resolveRelative(dirName, spec) {
1084
+ function resolveRelative(dirName, spec, host) {
1085
+ var _host$directoryExists;
1072
1086
  const base = path__default.resolve(dirName, spec);
1073
1087
  // Exact file (with extension)
1074
- if (fs.existsSync(base) && fs.statSync(base).isFile()) {
1088
+ if (host.fileExists(base)) {
1075
1089
  return base;
1076
1090
  }
1077
1091
  // Probe extensions (handles `./x.val` -> `./x.val.ts`)
1078
1092
  for (const ext of RESOLVE_EXTENSIONS) {
1079
1093
  const candidate = base + ext;
1080
- if (fs.existsSync(candidate)) {
1094
+ if (host.fileExists(candidate)) {
1081
1095
  return candidate;
1082
1096
  }
1083
1097
  }
1084
1098
  // Directory index
1085
- if (fs.existsSync(base) && fs.statSync(base).isDirectory()) {
1099
+ if (((_host$directoryExists = host.directoryExists) === null || _host$directoryExists === void 0 ? void 0 : _host$directoryExists.call(host, base)) ?? fs.existsSync(base)) {
1086
1100
  for (const ext of RESOLVE_EXTENSIONS) {
1087
1101
  const candidate = path__default.join(base, "index" + ext);
1088
- if (fs.existsSync(candidate)) {
1102
+ if (host.fileExists(candidate)) {
1089
1103
  return candidate;
1090
1104
  }
1091
1105
  }
@@ -1112,7 +1126,9 @@ async function createService(projectRoot, host = {
1112
1126
  }) {
1113
1127
  const compilerOptions = getCompilerOptions(projectRoot, host);
1114
1128
  const sourceFileHandler = new ValSourceFileHandler(projectRoot, compilerOptions, host);
1115
- const valModules = loadValModules(projectRoot);
1129
+ // Read val.modules (and everything it imports) through the same host, so an
1130
+ // embedder that overlays unsaved editor buffers evaluates what the user sees.
1131
+ const valModules = loadValModules(projectRoot, host);
1116
1132
  const extracted = await extractValModules(valModules);
1117
1133
  return new Service(projectRoot, sourceFileHandler, extracted);
1118
1134
  }
@@ -7728,6 +7744,33 @@ async function downloadFileFromRemote(ref, filePath) {
7728
7744
  // record-level fix expands into per-entry errors that should point at the
7729
7745
  // individual entry (e.g. `?p="/public/val/logo.png"`) rather than the record.
7730
7746
 
7747
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
7748
+ // entries by a remote URL while keeping the file on disk at its local path, so
7749
+ // normalize a remote-URL key back to that local path for on-disk reads.
7750
+ function galleryKeyToLocalPath(key) {
7751
+ const res = Internal.remote.splitRemoteRef(key);
7752
+ return res.status === "success" ? `/${res.filePath}` : key;
7753
+ }
7754
+
7755
+ // Patch path of the record that holds a media entry. Media keys are file paths
7756
+ // that can contain dots, which sourceToPatchPath cannot round-trip, so strip
7757
+ // the (JSON-encoded) key segment off the entry source path and convert the
7758
+ // remaining parent path — whose ancestors are plain object keys / array
7759
+ // indices — instead.
7760
+ function parentPatchPathOfMediaEntry(sourcePath, entryKey) {
7761
+ const jsonKey = JSON.stringify(entryKey);
7762
+ let parent = sourcePath;
7763
+ if (parent.endsWith(jsonKey)) {
7764
+ parent = parent.slice(0, parent.length - jsonKey.length);
7765
+ }
7766
+ if (parent.endsWith(".")) {
7767
+ parent = parent.slice(0, -1);
7768
+ } else if (parent.endsWith(Internal.ModuleFilePathSep)) {
7769
+ parent = parent.slice(0, parent.length - Internal.ModuleFilePathSep.length);
7770
+ }
7771
+ return sourceToPatchPath(parent);
7772
+ }
7773
+
7731
7774
  // TODO: find a better name? transformFixesToPatch?
7732
7775
  async function createFixPatch(config, apply, sourcePath, validationError, remoteFiles, moduleSource, moduleSchema) {
7733
7776
  const remainingErrors = [];
@@ -7905,6 +7948,61 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
7905
7948
  path: sourceToPatchPath(sourcePath)
7906
7949
  });
7907
7950
  }
7951
+ } else if (fix === "images:upload-remote" || fix === "files:upload-remote") {
7952
+ // Gallery entry: the record is keyed by the file path, so uploading to
7953
+ // remote means renaming the key from the local path to the remote URL
7954
+ // (remove the old key, add the new one with the same metadata).
7955
+ const remoteFile = remoteFiles[sourcePath];
7956
+ if (!remoteFile) {
7957
+ remainingErrors.push({
7958
+ ...validationError,
7959
+ message: "Cannot fix local to remote gallery entry: remote file was not uploaded",
7960
+ fixes: undefined
7961
+ });
7962
+ continue;
7963
+ }
7964
+ const metadata = remoteFile.metadata;
7965
+ if (!metadata) {
7966
+ remainingErrors.push({
7967
+ ...validationError,
7968
+ message: "Failed to get metadata for remote gallery file",
7969
+ fixes: undefined
7970
+ });
7971
+ continue;
7972
+ }
7973
+ // The entry is keyed by its (local) file path, which can contain dots
7974
+ // (e.g. image.png). sourceToPatchPath can't round-trip such keys, so
7975
+ // build the patch path from the parent record path plus the key (which
7976
+ // validateMediaKey carries as the error value), like the UI does.
7977
+ const entryKey = validationError.value;
7978
+ if (typeof entryKey !== "string") {
7979
+ remainingErrors.push({
7980
+ ...validationError,
7981
+ message: "Cannot rewrite remote gallery entry: missing entry key",
7982
+ fixes: undefined
7983
+ });
7984
+ continue;
7985
+ }
7986
+ const parentPath = parentPatchPathOfMediaEntry(sourcePath, entryKey);
7987
+ const removePath = parentPath.concat(entryKey);
7988
+ const addPath = parentPath.concat(remoteFile.ref);
7989
+ if (!isNotRoot(removePath) || !isNotRoot(addPath)) {
7990
+ remainingErrors.push({
7991
+ ...validationError,
7992
+ message: "Cannot rewrite remote gallery entry at root path",
7993
+ fixes: undefined
7994
+ });
7995
+ continue;
7996
+ }
7997
+ patch.push({
7998
+ op: "remove",
7999
+ path: removePath
8000
+ });
8001
+ patch.push({
8002
+ op: "add",
8003
+ path: addPath,
8004
+ value: metadata
8005
+ });
7908
8006
  } else if (fix === "image:download-remote" || fix === "file:download-remote") {
7909
8007
  const v = getRemoteValueFromValidationError(validationError);
7910
8008
  if (!v.success) {
@@ -7981,7 +8079,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
7981
8079
  }
7982
8080
  const gallerySource = moduleSource;
7983
8081
  for (const [entryKey, storedEntry] of Object.entries(gallerySource)) {
7984
- const filename = path__default.join(config.projectRoot, entryKey);
8082
+ const filename = path__default.join(config.projectRoot, galleryKeyToLocalPath(entryKey));
7985
8083
  let buffer;
7986
8084
  try {
7987
8085
  buffer = fs.readFileSync(filename);
@@ -8209,6 +8307,294 @@ async function getFileMetadata(projectRoot, validationError) {
8209
8307
  return extractFileMetadata(fileRef);
8210
8308
  }
8211
8309
 
8310
+ function validateMetadata(actualMetadata, expectedMetadata) {
8311
+ const missingMetadata = [];
8312
+ const erroneousMetadata = {};
8313
+ if (typeof actualMetadata !== "object" || actualMetadata === null) {
8314
+ return {
8315
+ globalErrors: ["Metadata is wrong type: must be an object."]
8316
+ };
8317
+ }
8318
+ if (Array.isArray(actualMetadata)) {
8319
+ return {
8320
+ globalErrors: ["Metadata is wrong type: cannot be an array."]
8321
+ };
8322
+ }
8323
+ const recordMetadata = actualMetadata;
8324
+ const globalErrors = [];
8325
+ for (const anyKey in expectedMetadata) {
8326
+ if (typeof anyKey !== "string") {
8327
+ globalErrors.push(`Expected metadata has key '${anyKey}' that is not typeof 'string', but: '${typeof anyKey}'. This is most likely a Val bug.`);
8328
+ } else {
8329
+ if (anyKey in actualMetadata) {
8330
+ const key = anyKey;
8331
+ if (expectedMetadata[key] !== recordMetadata[key]) {
8332
+ erroneousMetadata[key] = `Expected metadata '${key}' to be ${JSON.stringify(expectedMetadata[key])}, but got ${JSON.stringify(recordMetadata[key])}.`;
8333
+ }
8334
+ } else {
8335
+ missingMetadata.push(anyKey);
8336
+ }
8337
+ }
8338
+ }
8339
+ if (globalErrors.length === 0 && missingMetadata.length === 0 && Object.keys(erroneousMetadata).length === 0) {
8340
+ return false;
8341
+ }
8342
+ return {
8343
+ missingMetadata,
8344
+ erroneousMetadata
8345
+ };
8346
+ }
8347
+
8348
+ /**
8349
+ * NOTE: this is intentionally NOT `SharedValConfig` from `@valbuild/shared`.
8350
+ * That schema requires `files.directory` to be exactly `/public/val`, whereas
8351
+ * this one accepts any path beneath `/public`. Unifying them would change which
8352
+ * configs are accepted, so they are kept separate on purpose.
8353
+ */
8354
+ const ValConfigSchema = z$1.object({
8355
+ project: z$1.string().optional(),
8356
+ root: z$1.string().optional(),
8357
+ files: z$1.object({
8358
+ directory: z$1.string().refine(val => (val === "/public" || val.startsWith("/public/") && !val.endsWith("/")) &&
8359
+ // Reject path traversal so the directory cannot escape /public
8360
+ !val.split("/").some(segment => segment === "." || segment === ".."), {
8361
+ message: "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments"
8362
+ })
8363
+ }).optional(),
8364
+ gitCommit: z$1.string().optional(),
8365
+ gitBranch: z$1.string().optional(),
8366
+ defaultTheme: z$1.union([z$1.literal("light"), z$1.literal("dark")]).optional(),
8367
+ ai: z$1.object({
8368
+ commitMessages: z$1.object({
8369
+ disabled: z$1.boolean().optional()
8370
+ }).optional()
8371
+ }).optional()
8372
+ });
8373
+
8374
+ /**
8375
+ * `ENOENT` (nothing at this path) and `ENOTDIR` (a path segment is a file) both
8376
+ * mean the config file simply is not there.
8377
+ */
8378
+ function isFileNotFound(err) {
8379
+ const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
8380
+ return code === "ENOENT" || code === "ENOTDIR";
8381
+ }
8382
+
8383
+ /**
8384
+ * Read and evaluate a `val.config.{ts,js}` file from disk.
8385
+ *
8386
+ * Returns `null` if the file does not exist, and throws if it exists but does
8387
+ * not export a valid `config` object.
8388
+ *
8389
+ * Used by the Val CLI and by `@valbuild/language-server`, so that an editor
8390
+ * resolves project config exactly the way the CLI does.
8391
+ */
8392
+ async function evalValConfigFile(projectRoot, configFileName) {
8393
+ const valConfigPath = path__default.join(projectRoot, configFileName);
8394
+ let code;
8395
+ try {
8396
+ code = await fs$1.readFile(valConfigPath, "utf-8");
8397
+ } catch (err) {
8398
+ // A missing file means "this project does not use this config file name",
8399
+ // which callers handle by trying the next candidate. Anything else (no read
8400
+ // permission, an IO error, a directory where a file was expected) is a real
8401
+ // problem and must not be reported as "no config".
8402
+ if (isFileNotFound(err)) {
8403
+ return null;
8404
+ }
8405
+ throw Error(`Could not read Val config file at path: '${valConfigPath}': ${err instanceof Error ? err.message : String(err)}`);
8406
+ }
8407
+ if (!code.trim()) {
8408
+ return null;
8409
+ }
8410
+ const transpiled = ts.transpileModule(code, {
8411
+ compilerOptions: {
8412
+ target: ts.ScriptTarget.ES2020,
8413
+ module: ts.ModuleKind.CommonJS,
8414
+ esModuleInterop: true
8415
+ },
8416
+ fileName: valConfigPath
8417
+ });
8418
+ const projectRootRequire = createRequire(valConfigPath);
8419
+ const exportsObj = {};
8420
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8421
+ const sandbox = {
8422
+ exports: exportsObj,
8423
+ module: {
8424
+ exports: exportsObj
8425
+ },
8426
+ require: projectRootRequire,
8427
+ // NOTE: this is a security risk, but this code is running in the users own environment at the CLI level
8428
+ __filename: valConfigPath,
8429
+ __dirname: projectRoot,
8430
+ console,
8431
+ process
8432
+ };
8433
+ sandbox.global = sandbox;
8434
+ const context = vm.createContext(sandbox);
8435
+ const script = new vm.Script(transpiled.outputText, {
8436
+ filename: valConfigPath
8437
+ });
8438
+ script.runInContext(context);
8439
+ const valConfig = sandbox.module.exports.config;
8440
+ if (!valConfig) {
8441
+ throw Error(`Val config file at path: '${valConfigPath}' must export a config object. Got: ${valConfig}`);
8442
+ }
8443
+ const result = ValConfigSchema.safeParse(valConfig);
8444
+ if (!result.success) {
8445
+ throw Error(`Val config file at path: '${valConfigPath}' has invalid schema: ${result.error.message}`);
8446
+ }
8447
+ return result.data;
8448
+ }
8449
+
8450
+ /**
8451
+ * Resolve the project's Val config, trying the TypeScript file first and
8452
+ * falling back to JavaScript. Mirrors what the CLI has always done at each of
8453
+ * its call sites.
8454
+ */
8455
+ async function findAndEvalValConfigFile(projectRoot) {
8456
+ return (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
8457
+ }
8458
+
8459
+ /**
8460
+ * The Val login device flow, as reusable primitives.
8461
+ *
8462
+ * The CLI wraps these with terminal output, and `@valbuild/language-server`
8463
+ * wraps them with LSP `window/showDocument` and progress reporting. Neither the
8464
+ * polling nor the token handling is duplicated between them.
8465
+ *
8466
+ * Deliberately no `console` output and no `process.exit`: every failure is a
8467
+ * thrown {@link ValLoginError} so an embedder can decide how to surface it.
8468
+ */
8469
+
8470
+ const DEFAULT_LOGIN_HOST = "https://admin.val.build";
8471
+ function defaultHost() {
8472
+ return process.env.VAL_BUILD_URL || DEFAULT_LOGIN_HOST;
8473
+ }
8474
+ class ValLoginError extends Error {
8475
+ constructor(code, message, details) {
8476
+ super(message);
8477
+ this.code = code;
8478
+ this.details = details;
8479
+ this.name = "ValLoginError";
8480
+ }
8481
+ }
8482
+
8483
+ /** A login attempt that is waiting for the user to confirm in a browser. */
8484
+
8485
+ /**
8486
+ * Begin a login attempt. The caller is responsible for getting
8487
+ * {@link ValLoginSession.url} in front of the user.
8488
+ */
8489
+ async function startValLogin(options = {}) {
8490
+ var _response$headers$get;
8491
+ const host = options.host ?? defaultHost();
8492
+ const response = await fetch(`${host}/api/login`, {
8493
+ method: "POST",
8494
+ headers: {
8495
+ "Content-Type": "application/json"
8496
+ }
8497
+ });
8498
+ if (response.status >= 500) {
8499
+ const text = await response.text().catch(() => "");
8500
+ throw new ValLoginError("server-error", "An error occurred on the server.", text ? `Server response: ${text} (status: ${response.status})` : `Status: ${response.status}`);
8501
+ }
8502
+ if (!((_response$headers$get = response.headers.get("content-type")) !== null && _response$headers$get !== void 0 && _response$headers$get.includes("application/json"))) {
8503
+ const text = await response.text().catch(() => "");
8504
+ throw new ValLoginError("unexpected-content-type", "Unexpected failure while trying to login (content type was not JSON).", text ? `Server response: ${text} (status: ${response.status})` : `Status: ${response.status}`);
8505
+ }
8506
+ const json = await response.json();
8507
+ const nonce = json === null || json === void 0 ? void 0 : json.nonce;
8508
+ const url = json === null || json === void 0 ? void 0 : json.url;
8509
+ if (typeof nonce !== "string" || typeof url !== "string") {
8510
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
8511
+ }
8512
+ return {
8513
+ nonce,
8514
+ url
8515
+ };
8516
+ }
8517
+ const DEFAULT_LOGIN_MAX_DURATION = 5 * 60 * 1000; // 5 minutes
8518
+ const DEFAULT_LOGIN_POLL_INTERVAL = 1000;
8519
+
8520
+ /**
8521
+ * Poll until the user confirms the login in their browser.
8522
+ *
8523
+ * Accepts an `AbortSignal` so an editor can cancel the flow when the user
8524
+ * dismisses the prompt, instead of leaving a poll loop running for 5 minutes.
8525
+ */
8526
+ async function awaitValLoginConfirmation(nonce, options = {}) {
8527
+ const host = options.host ?? defaultHost();
8528
+ const maxDuration = options.maxDurationMs ?? DEFAULT_LOGIN_MAX_DURATION;
8529
+ const pollInterval = options.pollIntervalMs ?? DEFAULT_LOGIN_POLL_INTERVAL;
8530
+ const now = options.now ?? (() => Date.now());
8531
+ const start = now();
8532
+ while (now() - start < maxDuration) {
8533
+ var _options$signal, _options$signal2;
8534
+ if ((_options$signal = options.signal) !== null && _options$signal !== void 0 && _options$signal.aborted) {
8535
+ throw new ValLoginError("aborted", "Login was cancelled.");
8536
+ }
8537
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
8538
+ if ((_options$signal2 = options.signal) !== null && _options$signal2 !== void 0 && _options$signal2.aborted) {
8539
+ throw new ValLoginError("aborted", "Login was cancelled.");
8540
+ }
8541
+ const response = await fetch(`${host}/api/login?token=${nonce}&consume=true`, {
8542
+ method: "POST"
8543
+ });
8544
+ if (response.status >= 500) {
8545
+ throw new ValLoginError("server-error", "An error occurred on the server.", `Status: ${response.status}`);
8546
+ }
8547
+ if (response.status === 200) {
8548
+ var _json$profile;
8549
+ const json = await response.json().catch(() => null);
8550
+ if (!json) {
8551
+ // A 200 with an empty body is not a confirmation; keep polling rather
8552
+ // than failing the login, which is what the CLI has always done.
8553
+ continue;
8554
+ }
8555
+ if (typeof ((_json$profile = json.profile) === null || _json$profile === void 0 ? void 0 : _json$profile.email) === "string" && typeof json.pat === "string") {
8556
+ return json;
8557
+ }
8558
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
8559
+ }
8560
+ }
8561
+ throw new ValLoginError("timeout", "Login confirmation timed out.");
8562
+ }
8563
+
8564
+ /**
8565
+ * Write a completed login to the project's personal access token file.
8566
+ *
8567
+ * @returns the path the token was written to.
8568
+ */
8569
+ function persistPersonalAccessToken(projectRoot, result) {
8570
+ const filePath = getPersonalAccessTokenPath(projectRoot);
8571
+ // NOTE: no restrictive mode on the directory. This is the project's shared
8572
+ // `.val`, which also holds the pending patches the dev server reads and
8573
+ // writes; making it owner-only would lock out anyone who did not run the
8574
+ // login. Only the token file itself is tightened.
8575
+ fs.mkdirSync(path__default.dirname(filePath), {
8576
+ recursive: true
8577
+ });
8578
+ // The token is a credential: keep it owner-only rather than at the mercy of
8579
+ // the process umask. `mode` only applies when the file is created, so an
8580
+ // already existing file is chmod-ed explicitly.
8581
+ fs.writeFileSync(filePath, JSON.stringify(result, null, 2), {
8582
+ mode: 0o600
8583
+ });
8584
+ try {
8585
+ fs.chmodSync(filePath, 0o600);
8586
+ } catch {
8587
+ // Not all platforms / filesystems support POSIX modes (Windows, some
8588
+ // network mounts). Failing to tighten them must not fail the login.
8589
+ }
8590
+ return filePath;
8591
+ }
8592
+
8593
+ /**
8594
+ * NOTE: `@valbuild/language-server` has a near-identical `modulePathMap.ts`.
8595
+ * Fix traversal bugs in both, or fold them together.
8596
+ */
8597
+
8212
8598
  function getModulePathRange(modulePath, modulePathMap,
8213
8599
  // Which part of an object/record member to point at. For an object property
8214
8600
  // the resolved node's own range is the *key* (property name); the *value*
@@ -8218,31 +8604,39 @@ function getModulePathRange(modulePath, modulePathMap,
8218
8604
  // existing callers.
8219
8605
  target = "key") {
8220
8606
  var _range$children;
8221
- // Handle empty or invalid module paths gracefully
8222
- if (!modulePath || typeof modulePath !== "string") {
8223
- return undefined;
8224
- }
8225
- let segments;
8226
- try {
8227
- // Quote-aware splitter that correctly handles keys containing dots
8228
- // (e.g. file refs like `"/public/val/images/logo.png"`), unlike a naive
8229
- // split on ".". Throws on malformed input (e.g. unbalanced quotes).
8230
- segments = Internal.splitModulePath(modulePath);
8231
- } catch {
8232
- // Return undefined if the module path is malformed. This can happen when
8233
- // there are upstream errors in schema serialization.
8607
+ // Handle invalid module paths gracefully
8608
+ if (typeof modulePath !== "string") {
8234
8609
  return undefined;
8235
8610
  }
8236
- if (segments.length === 0) {
8237
- return undefined;
8238
- }
8239
- let range = modulePathMap[segments[0]];
8240
- for (const pathSegment of segments.slice(1)) {
8241
- var _range;
8242
- if (!range) {
8243
- break;
8611
+ let range;
8612
+ if (modulePath === "") {
8613
+ // The empty module path is the module root, stored under the "" key: the
8614
+ // source argument of `c.define`. This is what module-level diagnostics
8615
+ // (which have no module path) resolve to.
8616
+ range = modulePathMap[""];
8617
+ } else {
8618
+ let segments;
8619
+ try {
8620
+ // Quote-aware splitter that correctly handles keys containing dots
8621
+ // (e.g. file refs like `"/public/val/images/logo.png"`), unlike a naive
8622
+ // split on ".". Throws on malformed input (e.g. unbalanced quotes).
8623
+ segments = Internal.splitModulePath(modulePath);
8624
+ } catch {
8625
+ // Return undefined if the module path is malformed. This can happen when
8626
+ // there are upstream errors in schema serialization.
8627
+ return undefined;
8628
+ }
8629
+ if (segments.length === 0) {
8630
+ return undefined;
8631
+ }
8632
+ range = modulePathMap[segments[0]];
8633
+ for (const pathSegment of segments.slice(1)) {
8634
+ var _range;
8635
+ if (!range) {
8636
+ break;
8637
+ }
8638
+ range = (_range = range) === null || _range === void 0 || (_range = _range.children) === null || _range === void 0 ? void 0 : _range[pathSegment];
8244
8639
  }
8245
- range = (_range = range) === null || _range === void 0 || (_range = _range.children) === null || _range === void 0 ? void 0 : _range[pathSegment];
8246
8640
  }
8247
8641
  if (!range) {
8248
8642
  return undefined;
@@ -8274,7 +8668,20 @@ function createModulePathMap(sourceFile) {
8274
8668
  if (ts.isExportAssignment(child)) {
8275
8669
  const contentNode = child.expression && ts.isCallExpression(child.expression) && child.expression.arguments[2];
8276
8670
  if (contentNode) {
8277
- return traverse(contentNode, sourceFile);
8671
+ const map = traverse(contentNode, sourceFile) ?? {};
8672
+ // Always expose the module root under the "" key so diagnostics without
8673
+ // a module path still resolve to a location. `traverse` already emits ""
8674
+ // for primitive roots (with a tighter range), so keep that if present.
8675
+ // Object/record keys are never "" (see traverseObjectLiteral), so this
8676
+ // cannot collide with a real key.
8677
+ return {
8678
+ ...map,
8679
+ "": map[""] ?? {
8680
+ children: {},
8681
+ start: sourceFile.getLineAndCharacterOfPosition(contentNode.getStart(sourceFile)),
8682
+ end: sourceFile.getLineAndCharacterOfPosition(contentNode.getEnd())
8683
+ }
8684
+ };
8278
8685
  }
8279
8686
  }
8280
8687
  }
@@ -8467,4 +8874,4 @@ function readCapturedReport(snapshotDir) {
8467
8874
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
8468
8875
  }
8469
8876
 
8470
- export { Service, ValFSHost, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, compareWithCapturedReport, createFixPatch, createModulePathMap, createService, createValApiRouter, createValServer, decodeJwt, encodeJwt, formatPatchSourceError, formatSyntaxErrorTree, getCompilerOptions, getExpire, getModulePathRange, getPersonalAccessTokenPath, getSettings, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, readCapturedReport, replaySnapshot, safeReadGit, uploadRemoteFile };
8877
+ export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createFixPatch, createModulePathMap, createService, createValApiRouter, createValServer, decodeJwt, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, findAndEvalValConfigFile, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, hasRemoteFileSchema, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.97.7",
19
+ "version": "0.99.0",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0"
@@ -29,9 +29,9 @@
29
29
  "typescript": "^5.9.3",
30
30
  "zod": "^4.3.5",
31
31
  "zod-validation-error": "^5.0.0",
32
- "@valbuild/core": "0.97.7",
33
- "@valbuild/shared": "0.97.7",
34
- "@valbuild/ui": "0.97.7"
32
+ "@valbuild/core": "0.99.0",
33
+ "@valbuild/shared": "0.99.0",
34
+ "@valbuild/ui": "0.99.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.17.0"