@valbuild/server 0.122.0 → 0.123.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/dist/declarations/src/fixHandlers.d.ts +29 -0
- package/dist/declarations/src/index.d.ts +5 -3
- package/dist/declarations/src/valServerConfig.d.ts +37 -0
- package/dist/valbuild-server.cjs.dev.js +321 -1465
- package/dist/valbuild-server.cjs.prod.js +321 -1465
- package/dist/valbuild-server.esm.js +321 -1462
- package/package.json +3 -3
- package/dist/declarations/src/tools/createValTools.d.ts +0 -41
- package/dist/declarations/src/tools/defineTool.d.ts +0 -69
- package/dist/declarations/src/tools/index.d.ts +0 -3
- package/dist/declarations/src/tools/types.d.ts +0 -163
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { pipe, result, array } from '@valbuild/core/fp';
|
|
3
3
|
import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
|
|
4
|
-
import { derefPatch, Internal, RecordSchema, extractValModules, computeValModuleShas, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema
|
|
4
|
+
import { derefPatch, Internal, RecordSchema, extractValModules, computeValModuleShas, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
|
|
5
5
|
export { hasRemoteFileSchema } from '@valbuild/core';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import path__default from 'path';
|
|
8
8
|
import fs, { promises } from 'fs';
|
|
9
9
|
import vm from 'node:vm';
|
|
10
10
|
import { Module, createRequire } from 'node:module';
|
|
11
|
-
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api
|
|
11
|
+
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
|
|
12
12
|
import { createUIRequestHandler } from '@valbuild/ui/server';
|
|
13
13
|
import crypto$1 from 'crypto';
|
|
14
14
|
import z$1, { z } from 'zod';
|
|
15
15
|
import sizeOf from 'image-size';
|
|
16
16
|
import { fromError } from 'zod-validation-error';
|
|
17
17
|
import os from 'os';
|
|
18
|
-
import { randomUUID } from 'node:crypto';
|
|
19
18
|
import { transform } from 'sucrase';
|
|
20
19
|
import http from 'http';
|
|
21
20
|
import https from 'https';
|
|
@@ -1009,7 +1008,7 @@ function errorMessage(e) {
|
|
|
1009
1008
|
return String(e);
|
|
1010
1009
|
}
|
|
1011
1010
|
|
|
1012
|
-
const jsonOps$
|
|
1011
|
+
const jsonOps$2 = new JSONOps();
|
|
1013
1012
|
|
|
1014
1013
|
/**
|
|
1015
1014
|
* Classification of a single patch op against a module's serialized schema,
|
|
@@ -1264,7 +1263,7 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1264
1263
|
// on an entry that does not exist.
|
|
1265
1264
|
continue;
|
|
1266
1265
|
}
|
|
1267
|
-
const applied = applyPatch(deepClone(content), jsonOps$
|
|
1266
|
+
const applied = applyPatch(deepClone(content), jsonOps$2, [{
|
|
1268
1267
|
op: "add",
|
|
1269
1268
|
path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
|
|
1270
1269
|
value: patchId
|
|
@@ -1318,7 +1317,7 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1318
1317
|
patchId
|
|
1319
1318
|
};
|
|
1320
1319
|
}
|
|
1321
|
-
const applied = applyPatch(deepClone(content), jsonOps$
|
|
1320
|
+
const applied = applyPatch(deepClone(content), jsonOps$2, [rebased.value]);
|
|
1322
1321
|
if (result.isErr(applied)) {
|
|
1323
1322
|
return {
|
|
1324
1323
|
kind: "error",
|
|
@@ -1601,7 +1600,7 @@ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
|
|
|
1601
1600
|
return resolveExistingJsonPath(moduleFilePath, entry.importPath);
|
|
1602
1601
|
}
|
|
1603
1602
|
|
|
1604
|
-
const jsonOps$
|
|
1603
|
+
const jsonOps$1 = new JSONOps();
|
|
1605
1604
|
|
|
1606
1605
|
/**
|
|
1607
1606
|
* Substitutes loaded `.jsonValues()` entry content back into a module's root
|
|
@@ -1907,7 +1906,7 @@ class Service {
|
|
|
1907
1906
|
if (result.isErr(rebased)) {
|
|
1908
1907
|
throw Error(`Could not apply ${op.op} to jsonValues entry '${entryKey}' of ${moduleFilePath}: ${rebased.error.message}`);
|
|
1909
1908
|
}
|
|
1910
|
-
const applied = applyPatch(deepClone(content), jsonOps$
|
|
1909
|
+
const applied = applyPatch(deepClone(content), jsonOps$1, [rebased.value]);
|
|
1911
1910
|
if (result.isErr(applied)) {
|
|
1912
1911
|
throw Error(`Could not apply ${op.op} to ${jsonPath}: ${applied.error.message}`);
|
|
1913
1912
|
}
|
|
@@ -1981,7 +1980,7 @@ const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
|
|
|
1981
1980
|
* fails where the helper is used rather than where it is written. Annotate the
|
|
1982
1981
|
* helper's return type, or inline it.
|
|
1983
1982
|
*/
|
|
1984
|
-
function ok
|
|
1983
|
+
function ok(value, warnings) {
|
|
1985
1984
|
return warnings && warnings.length > 0 ? {
|
|
1986
1985
|
[EXTERNAL_RESULT]: true,
|
|
1987
1986
|
kind: "ok",
|
|
@@ -1993,7 +1992,7 @@ function ok$1(value, warnings) {
|
|
|
1993
1992
|
value
|
|
1994
1993
|
};
|
|
1995
1994
|
}
|
|
1996
|
-
function err
|
|
1995
|
+
function err(issue) {
|
|
1997
1996
|
return {
|
|
1998
1997
|
[EXTERNAL_RESULT]: true,
|
|
1999
1998
|
kind: "err",
|
|
@@ -2253,7 +2252,7 @@ function encodeJwt(payload, sessionKey) {
|
|
|
2253
2252
|
}
|
|
2254
2253
|
|
|
2255
2254
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2256
|
-
const jsonOps
|
|
2255
|
+
const jsonOps = new JSONOps();
|
|
2257
2256
|
const tsOps = new TSOps(document => {
|
|
2258
2257
|
return pipe(analyzeValModule(document), result.map(({
|
|
2259
2258
|
source
|
|
@@ -3023,7 +3022,7 @@ class ValOps {
|
|
|
3023
3022
|
}
|
|
3024
3023
|
const patchRes = applyPatch(deepClone(patchedSources[path]),
|
|
3025
3024
|
// applyPatch mutates the source. On add operations it adds more than once? There is something strange going on... deepClone seems to fix, but is that the right solution?
|
|
3026
|
-
jsonOps
|
|
3025
|
+
jsonOps, applicableOps.concat(...Object.values(fileFixOps)));
|
|
3027
3026
|
if (result.isErr(patchRes)) {
|
|
3028
3027
|
console.error("Could not apply patch", JSON.stringify({
|
|
3029
3028
|
path,
|
|
@@ -3723,7 +3722,7 @@ class ValOps {
|
|
|
3723
3722
|
patchHadError = true;
|
|
3724
3723
|
break;
|
|
3725
3724
|
}
|
|
3726
|
-
const applied = applyPatch(deepClone(contentRes.value), jsonOps
|
|
3725
|
+
const applied = applyPatch(deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
|
|
3727
3726
|
if (result.isErr(applied)) {
|
|
3728
3727
|
collectPatchError(applied.error, patchId, op);
|
|
3729
3728
|
patchHadError = true;
|
|
@@ -8996,6 +8995,59 @@ async function getSettings(projectName, auth) {
|
|
|
8996
8995
|
}
|
|
8997
8996
|
}
|
|
8998
8997
|
|
|
8998
|
+
function getPersonalAccessTokenPath(root) {
|
|
8999
|
+
return path__default.join(path__default.resolve(root), ".val", "pat.json");
|
|
9000
|
+
}
|
|
9001
|
+
function parsePersonalAccessTokenFile(content) {
|
|
9002
|
+
if (!content) {
|
|
9003
|
+
return {
|
|
9004
|
+
success: false,
|
|
9005
|
+
error: "Invalid content: undefined"
|
|
9006
|
+
};
|
|
9007
|
+
}
|
|
9008
|
+
let patFileContent;
|
|
9009
|
+
try {
|
|
9010
|
+
patFileContent = JSON.parse(content);
|
|
9011
|
+
} catch {
|
|
9012
|
+
return {
|
|
9013
|
+
success: false,
|
|
9014
|
+
error: `Invalid content: file is not a valid JSON file`
|
|
9015
|
+
};
|
|
9016
|
+
}
|
|
9017
|
+
if (typeof patFileContent !== "object") {
|
|
9018
|
+
return {
|
|
9019
|
+
success: false,
|
|
9020
|
+
error: "Invalid content: not an object"
|
|
9021
|
+
};
|
|
9022
|
+
}
|
|
9023
|
+
if (!patFileContent) {
|
|
9024
|
+
return {
|
|
9025
|
+
success: false,
|
|
9026
|
+
error: "Invalid content: null"
|
|
9027
|
+
};
|
|
9028
|
+
}
|
|
9029
|
+
if (!("pat" in patFileContent)) {
|
|
9030
|
+
return {
|
|
9031
|
+
success: false,
|
|
9032
|
+
error: "Invalid content: key 'pat' is missing"
|
|
9033
|
+
};
|
|
9034
|
+
}
|
|
9035
|
+
const patField = patFileContent.pat;
|
|
9036
|
+
if (typeof patField === "string") {
|
|
9037
|
+
return {
|
|
9038
|
+
success: true,
|
|
9039
|
+
data: {
|
|
9040
|
+
pat: patField
|
|
9041
|
+
}
|
|
9042
|
+
};
|
|
9043
|
+
} else {
|
|
9044
|
+
return {
|
|
9045
|
+
success: false,
|
|
9046
|
+
error: "Invalid content: pat is not a string"
|
|
9047
|
+
};
|
|
9048
|
+
}
|
|
9049
|
+
}
|
|
9050
|
+
|
|
8999
9051
|
/**
|
|
9000
9052
|
* Resolving how Val is configured, and building the data layer from it.
|
|
9001
9053
|
*
|
|
@@ -9225,57 +9277,77 @@ function warnIfInsecureUrls(urls) {
|
|
|
9225
9277
|
}
|
|
9226
9278
|
}
|
|
9227
9279
|
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9280
|
+
/**
|
|
9281
|
+
* Which credential talks to the content host about REMOTE FILES.
|
|
9282
|
+
*
|
|
9283
|
+
* A separate question from the one `createValOps` answers, and it has to be:
|
|
9284
|
+
* `ValOps` is authenticated per caller, but remote files are project-level —
|
|
9285
|
+
* looking up a project's public id and its buckets, and later pushing bytes to
|
|
9286
|
+
* them, is the same operation whoever asked for it.
|
|
9287
|
+
*
|
|
9288
|
+
* The rule, in order:
|
|
9289
|
+
*
|
|
9290
|
+
* 1. The app's API key, if there is one. Proxy mode always has one; fs mode has
|
|
9291
|
+
* one when `VAL_API_KEY` is set.
|
|
9292
|
+
* 2. In fs mode, the developer's own `val login` token, read off disk. This is
|
|
9293
|
+
* the same file `val validate --fix` reads, and it is why local remote
|
|
9294
|
+
* uploads work with no configuration beyond having logged in.
|
|
9295
|
+
* 3. Nothing, which is an error rather than a fallback.
|
|
9296
|
+
*
|
|
9297
|
+
* Lives here rather than inside `createValServer` because the MCP image tool
|
|
9298
|
+
* needs the same answer, and this is the file that exists so that two callers
|
|
9299
|
+
* cannot disagree about how a project is configured. A registry that decided it
|
|
9300
|
+
* had no credential while the Studio in the same process had one would be a
|
|
9301
|
+
* genuinely confusing afternoon.
|
|
9302
|
+
*/
|
|
9303
|
+
|
|
9304
|
+
async function resolveRemoteFileAuth(options) {
|
|
9305
|
+
if (options.apiKey) {
|
|
9248
9306
|
return {
|
|
9249
|
-
|
|
9250
|
-
|
|
9307
|
+
status: "success",
|
|
9308
|
+
auth: {
|
|
9309
|
+
apiKey: options.apiKey
|
|
9310
|
+
}
|
|
9251
9311
|
};
|
|
9252
9312
|
}
|
|
9253
|
-
if (
|
|
9313
|
+
if (options.mode !== "fs") {
|
|
9314
|
+
// Unreachable through `initHandlerOptions`, which refuses to build a proxy
|
|
9315
|
+
// config without an api key. Kept because this is exported.
|
|
9254
9316
|
return {
|
|
9255
|
-
|
|
9256
|
-
|
|
9317
|
+
status: "error",
|
|
9318
|
+
errorCode: "project-not-configured",
|
|
9319
|
+
message: "Remote file auth is not configured"
|
|
9257
9320
|
};
|
|
9258
9321
|
}
|
|
9259
|
-
|
|
9322
|
+
// `options.cwd`, which `initHandlerOptions` sets from `process.cwd()`. The
|
|
9323
|
+
// token lives in the project's own `.val/pat.json`, so this is the same file
|
|
9324
|
+
// `val login` wrote and `val validate --fix` reads.
|
|
9325
|
+
const patPath = getPersonalAccessTokenPath(options.cwd);
|
|
9326
|
+
const fs = await import('fs');
|
|
9327
|
+
let patFile;
|
|
9328
|
+
try {
|
|
9329
|
+
patFile = await fs.promises.readFile(patPath, "utf-8");
|
|
9330
|
+
} catch {
|
|
9260
9331
|
return {
|
|
9261
|
-
|
|
9262
|
-
|
|
9332
|
+
status: "error",
|
|
9333
|
+
errorCode: "pat-error",
|
|
9334
|
+
message: "Could not read personal access token file"
|
|
9263
9335
|
};
|
|
9264
9336
|
}
|
|
9265
|
-
const
|
|
9266
|
-
if (
|
|
9337
|
+
const patRes = parsePersonalAccessTokenFile(patFile);
|
|
9338
|
+
if (!patRes.success) {
|
|
9267
9339
|
return {
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
}
|
|
9272
|
-
};
|
|
9273
|
-
} else {
|
|
9274
|
-
return {
|
|
9275
|
-
success: false,
|
|
9276
|
-
error: "Invalid content: pat is not a string"
|
|
9340
|
+
status: "error",
|
|
9341
|
+
errorCode: "pat-error",
|
|
9342
|
+
message: "Could not parse personal access token file"
|
|
9277
9343
|
};
|
|
9278
9344
|
}
|
|
9345
|
+
return {
|
|
9346
|
+
status: "success",
|
|
9347
|
+
auth: {
|
|
9348
|
+
pat: patRes.data.pat
|
|
9349
|
+
}
|
|
9350
|
+
};
|
|
9279
9351
|
}
|
|
9280
9352
|
|
|
9281
9353
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
@@ -9450,6 +9522,14 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9450
9522
|
};
|
|
9451
9523
|
};
|
|
9452
9524
|
let remoteFileAuth = null;
|
|
9525
|
+
/**
|
|
9526
|
+
* The credential for remote-file work, memoised for the life of the server.
|
|
9527
|
+
*
|
|
9528
|
+
* The rule itself is `resolveRemoteFileAuth` in `valServerConfig`, shared with
|
|
9529
|
+
* the MCP image tool — what is left here is the memoisation and the shape the
|
|
9530
|
+
* api routes answer in. Memoised because in fs mode it reads a file off disk,
|
|
9531
|
+
* and every remote image in a gallery would otherwise read it again.
|
|
9532
|
+
*/
|
|
9453
9533
|
const getRemoteFileAuth = async () => {
|
|
9454
9534
|
if (remoteFileAuth) {
|
|
9455
9535
|
return {
|
|
@@ -9459,59 +9539,17 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
9459
9539
|
}
|
|
9460
9540
|
};
|
|
9461
9541
|
}
|
|
9462
|
-
|
|
9463
|
-
|
|
9464
|
-
apiKey: options.apiKey
|
|
9465
|
-
};
|
|
9466
|
-
} else if (serverOps instanceof ValOpsFS) {
|
|
9467
|
-
const projectRootDir = options.config.root || ".";
|
|
9468
|
-
if (!projectRootDir) {
|
|
9469
|
-
return {
|
|
9470
|
-
status: 400,
|
|
9471
|
-
json: {
|
|
9472
|
-
errorCode: "project-not-configured",
|
|
9473
|
-
message: "Root directory was empty"
|
|
9474
|
-
}
|
|
9475
|
-
};
|
|
9476
|
-
}
|
|
9477
|
-
const fs = await import('fs');
|
|
9478
|
-
const patPath = getPersonalAccessTokenPath(path__default.join(process.cwd()));
|
|
9479
|
-
let patFile;
|
|
9480
|
-
try {
|
|
9481
|
-
patFile = await fs.promises.readFile(patPath, "utf-8");
|
|
9482
|
-
} catch (err) {
|
|
9483
|
-
return {
|
|
9484
|
-
status: 400,
|
|
9485
|
-
json: {
|
|
9486
|
-
errorCode: "pat-error",
|
|
9487
|
-
message: "Could not read personal access token file"
|
|
9488
|
-
}
|
|
9489
|
-
};
|
|
9490
|
-
}
|
|
9491
|
-
const patRes = parsePersonalAccessTokenFile(patFile);
|
|
9492
|
-
if (patRes.success) {
|
|
9493
|
-
remoteFileAuth = {
|
|
9494
|
-
pat: patRes.data.pat
|
|
9495
|
-
};
|
|
9496
|
-
} else {
|
|
9497
|
-
return {
|
|
9498
|
-
status: 400,
|
|
9499
|
-
json: {
|
|
9500
|
-
errorCode: "pat-error",
|
|
9501
|
-
message: "Could not parse personal access token file"
|
|
9502
|
-
}
|
|
9503
|
-
};
|
|
9504
|
-
}
|
|
9505
|
-
}
|
|
9506
|
-
if (!remoteFileAuth) {
|
|
9542
|
+
const resolved = await resolveRemoteFileAuth(options);
|
|
9543
|
+
if (resolved.status === "error") {
|
|
9507
9544
|
return {
|
|
9508
9545
|
status: 400,
|
|
9509
9546
|
json: {
|
|
9510
|
-
errorCode:
|
|
9511
|
-
message:
|
|
9547
|
+
errorCode: resolved.errorCode,
|
|
9548
|
+
message: resolved.message
|
|
9512
9549
|
}
|
|
9513
9550
|
};
|
|
9514
9551
|
}
|
|
9552
|
+
remoteFileAuth = resolved.auth;
|
|
9515
9553
|
return {
|
|
9516
9554
|
status: 200,
|
|
9517
9555
|
json: {
|
|
@@ -13224,1337 +13262,68 @@ function getCookies(req, cookiesDef) {
|
|
|
13224
13262
|
return z.object(cookiesDef).safeParse(input);
|
|
13225
13263
|
}
|
|
13226
13264
|
|
|
13227
|
-
|
|
13228
|
-
|
|
13229
|
-
|
|
13230
|
-
|
|
13231
|
-
|
|
13232
|
-
* would have to be typed at its widest and every handler would start by
|
|
13233
|
-
* re-narrowing `unknown`, which is exactly where a tool and its schema drift
|
|
13234
|
-
* apart unnoticed.
|
|
13235
|
-
*/
|
|
13236
|
-
|
|
13237
|
-
/** Everything a tool is allowed to reach. Deliberately narrow. */
|
|
13238
|
-
|
|
13239
|
-
/**
|
|
13240
|
-
* Declare a tool, binding its handler to its input schema.
|
|
13241
|
-
*
|
|
13242
|
-
* The handler receives already-parsed arguments: the registry validates against
|
|
13243
|
-
* `inputSchema` before calling, so a handler never sees input its schema would
|
|
13244
|
-
* have rejected.
|
|
13245
|
-
*/
|
|
13246
|
-
function defineTool(definition, handler) {
|
|
13247
|
-
return {
|
|
13248
|
-
...definition,
|
|
13249
|
-
handler: (args, deps) => handler(args, deps)
|
|
13250
|
-
};
|
|
13251
|
-
}
|
|
13252
|
-
function ok(data) {
|
|
13253
|
-
return {
|
|
13254
|
-
status: "ok",
|
|
13255
|
-
data
|
|
13256
|
-
};
|
|
13257
|
-
}
|
|
13258
|
-
function err(code, message) {
|
|
13259
|
-
return {
|
|
13260
|
-
status: "error",
|
|
13261
|
-
code,
|
|
13262
|
-
message
|
|
13263
|
-
};
|
|
13264
|
-
}
|
|
13265
|
-
|
|
13266
|
-
/**
|
|
13267
|
-
* The tools that only read.
|
|
13268
|
-
*
|
|
13269
|
-
* Names match the Studio's chat tools exactly. MCP clients namespace by server,
|
|
13270
|
-
* so there is no `val_` prefix to add, and keeping the names identical means
|
|
13271
|
-
* converging the two definitions later is a move rather than a rename.
|
|
13272
|
-
*
|
|
13273
|
-
* All of these read from `deps.state`, which already has pending patches
|
|
13274
|
-
* applied — an agent should see the content as the Studio would show it, not the
|
|
13275
|
-
* last published version.
|
|
13276
|
-
*/
|
|
13265
|
+
const JsFileLookupMapping = [
|
|
13266
|
+
// NOTE: first one matching will be used
|
|
13267
|
+
[".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
|
|
13268
|
+
const MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100 mb
|
|
13269
|
+
const MAX_OBJECT_KEY_SIZE = 2 ** 27; // https://stackoverflow.com/questions/13367391/is-there-a-limit-on-length-of-the-key-string-in-js-object
|
|
13277
13270
|
|
|
13278
|
-
|
|
13279
|
-
|
|
13280
|
-
|
|
13281
|
-
|
|
13282
|
-
|
|
13283
|
-
|
|
13284
|
-
|
|
13285
|
-
|
|
13286
|
-
readOnlyHint: true,
|
|
13287
|
-
idempotentHint: true
|
|
13288
|
-
}
|
|
13289
|
-
}, async (_args, {
|
|
13290
|
-
state
|
|
13291
|
-
}) => ok(state.serializedSchemas)), defineTool({
|
|
13292
|
-
name: "get_source",
|
|
13293
|
-
title: "Get source",
|
|
13294
|
-
description: "Read the content of one Val module, with any unpublished changes already applied.",
|
|
13295
|
-
inputSchema: z.object({
|
|
13296
|
-
moduleFilePath: ModuleFilePathSchema$1
|
|
13297
|
-
}),
|
|
13298
|
-
annotations: {
|
|
13299
|
-
readOnlyHint: true,
|
|
13300
|
-
idempotentHint: true
|
|
13301
|
-
}
|
|
13302
|
-
}, async ({
|
|
13303
|
-
moduleFilePath
|
|
13304
|
-
}, {
|
|
13305
|
-
state
|
|
13306
|
-
}) => {
|
|
13307
|
-
const path = moduleFilePath;
|
|
13308
|
-
if (!(path in state.serializedSchemas)) {
|
|
13309
|
-
return err("not-found", unknownModuleMessage(path, state));
|
|
13310
|
-
}
|
|
13311
|
-
const source = state.sources[path];
|
|
13312
|
-
return ok(source === undefined ? null : source);
|
|
13313
|
-
}), defineTool({
|
|
13314
|
-
name: "get_record_keys",
|
|
13315
|
-
title: "Get record keys",
|
|
13316
|
-
description: "List the keys of the record or object at a path inside a module, a page at a time. Use this to enumerate entries without reading their contents — and before adding one, so you do not collide with an existing key. Fails on arrays, galleries, richtext and primitives: use count_entries for an array or richtext length, and get_source to read a gallery.",
|
|
13317
|
-
inputSchema: z.object({
|
|
13318
|
-
moduleFilePath: ModuleFilePathSchema$1,
|
|
13319
|
-
path: z.array(z.string()).default([]).describe("Path within the module to the record or object. Empty means the module root."),
|
|
13320
|
-
// Clamped by the schema rather than in the handler: a negative offset
|
|
13321
|
-
// makes `slice` read from the END and a negative limit makes it drop
|
|
13322
|
-
// the last N, so either would return a window that is not the page
|
|
13323
|
-
// asked for while `total` alongside implied it was.
|
|
13324
|
-
limit: z.number().int().min(1).default(100).describe("Maximum number of keys to return."),
|
|
13325
|
-
offset: z.number().int().min(0).default(0).describe("Number of keys to skip, for paging.")
|
|
13326
|
-
}),
|
|
13327
|
-
annotations: {
|
|
13328
|
-
readOnlyHint: true,
|
|
13329
|
-
idempotentHint: true
|
|
13330
|
-
}
|
|
13331
|
-
}, async ({
|
|
13332
|
-
moduleFilePath,
|
|
13333
|
-
path,
|
|
13334
|
-
limit,
|
|
13335
|
-
offset
|
|
13336
|
-
}, {
|
|
13337
|
-
state
|
|
13338
|
-
}) => {
|
|
13339
|
-
const described = describeContainer(state, moduleFilePath, path);
|
|
13340
|
-
if (described.kind !== "ok") {
|
|
13341
|
-
return described.result;
|
|
13342
|
-
}
|
|
13343
|
-
const {
|
|
13344
|
-
container,
|
|
13345
|
-
value
|
|
13346
|
-
} = described;
|
|
13347
|
-
// Records and objects only, matching the Studio's tool of the same name.
|
|
13348
|
-
// A gallery's keys are file paths whose bytes live elsewhere, and
|
|
13349
|
-
// richtext blocks are positional — neither is a key set to hand back.
|
|
13350
|
-
if (container !== "record" && container !== "object" || !isPlainObject(value)) {
|
|
13351
|
-
return err("invalid-args", `The value at that path is ${article(container)} ${container}. get_record_keys only works on a record or an object — ${container === "array" ? "use count_entries for the array length" : container === "richtext" ? "use count_entries for the number of blocks" : "use get_source to read the gallery's entries"}.`);
|
|
13352
|
-
}
|
|
13353
|
-
const keys = Object.keys(value);
|
|
13354
|
-
return ok({
|
|
13355
|
-
kind: container,
|
|
13356
|
-
keys: keys.slice(offset, offset + limit),
|
|
13357
|
-
// The unpaged size, so a caller can tell a short page from the end of
|
|
13358
|
-
// the record without asking for another one.
|
|
13359
|
-
total: keys.length
|
|
13360
|
-
});
|
|
13361
|
-
}), defineTool({
|
|
13362
|
-
name: "count_entries",
|
|
13363
|
-
title: "Count entries",
|
|
13364
|
-
description: "Count the entries at a path inside a module — record or gallery keys, object fields, array indices, or top-level richtext blocks — without reading them. Use this to answer 'how many?' or to size a record before paging through it.",
|
|
13365
|
-
inputSchema: z.object({
|
|
13366
|
-
moduleFilePath: ModuleFilePathSchema$1,
|
|
13367
|
-
path: z.array(z.string()).default([]).describe("Path within the module to count at. Empty means the module root.")
|
|
13368
|
-
}),
|
|
13369
|
-
annotations: {
|
|
13370
|
-
readOnlyHint: true,
|
|
13371
|
-
idempotentHint: true
|
|
13372
|
-
}
|
|
13373
|
-
}, async ({
|
|
13374
|
-
moduleFilePath,
|
|
13375
|
-
path
|
|
13376
|
-
}, {
|
|
13377
|
-
state
|
|
13378
|
-
}) => {
|
|
13379
|
-
const described = describeContainer(state, moduleFilePath, path);
|
|
13380
|
-
if (described.kind !== "ok") {
|
|
13381
|
-
return described.result;
|
|
13382
|
-
}
|
|
13383
|
-
const {
|
|
13384
|
-
container,
|
|
13385
|
-
value
|
|
13386
|
-
} = described;
|
|
13387
|
-
// Every container `describeContainerAtPath` admits can be counted, so
|
|
13388
|
-
// unlike get_record_keys this does not narrow further. Non-containers
|
|
13389
|
-
// never get this far.
|
|
13390
|
-
if (Array.isArray(value)) {
|
|
13391
|
-
return ok({
|
|
13392
|
-
kind: container,
|
|
13393
|
-
count: value.length
|
|
13394
|
-
});
|
|
13395
|
-
}
|
|
13396
|
-
if (isPlainObject(value)) {
|
|
13397
|
-
return ok({
|
|
13398
|
-
kind: container,
|
|
13399
|
-
count: Object.keys(value).length
|
|
13271
|
+
class ValModuleLoader {
|
|
13272
|
+
constructor(projectRoot, compilerOptions,
|
|
13273
|
+
// TODO: remove this?
|
|
13274
|
+
sourceFileHandler, host = {
|
|
13275
|
+
...ts.sys,
|
|
13276
|
+
writeFile: (fileName, data, encoding) => {
|
|
13277
|
+
fs.mkdirSync(path__default.dirname(fileName), {
|
|
13278
|
+
recursive: true
|
|
13400
13279
|
});
|
|
13401
|
-
|
|
13402
|
-
|
|
13403
|
-
|
|
13404
|
-
|
|
13405
|
-
|
|
13406
|
-
|
|
13407
|
-
|
|
13408
|
-
|
|
13409
|
-
}),
|
|
13410
|
-
annotations: {
|
|
13411
|
-
readOnlyHint: true
|
|
13412
|
-
}
|
|
13413
|
-
}, async ({
|
|
13414
|
-
moduleFilePath
|
|
13415
|
-
}, {
|
|
13416
|
-
ops,
|
|
13417
|
-
state
|
|
13418
|
-
}) => {
|
|
13419
|
-
const validation = await ops.validateSources(state.schemas, state.sources,
|
|
13420
|
-
// Every module. The third argument filters which modules are
|
|
13421
|
-
// validated at all, so passing the pending-patch analysis would make
|
|
13422
|
-
// a project with no pending changes report `valid: true` without
|
|
13423
|
-
// having checked anything. Scoping to one module, when asked, is done
|
|
13424
|
-
// on the results below.
|
|
13425
|
-
undefined);
|
|
13426
|
-
// `validateSources` hands back the files it could not check on its own;
|
|
13427
|
-
// running them is what turns "this path holds a file" into "that file is
|
|
13428
|
-
// actually there and matches its recorded metadata".
|
|
13429
|
-
const fileErrors = await ops.validateFiles(state.schemas, state.sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
|
|
13430
|
-
|
|
13431
|
-
// Per-module results, flattened to the by-source-path shape the filter
|
|
13432
|
-
// takes. Merged rather than overwritten: a path can pick up an error
|
|
13433
|
-
// from validation and another from its file.
|
|
13434
|
-
const bySourcePath = {};
|
|
13435
|
-
const add = (path, errors) => {
|
|
13436
|
-
bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
|
|
13437
|
-
};
|
|
13438
|
-
for (const moduleErrors of Object.values(validation.errors)) {
|
|
13439
|
-
for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
|
|
13440
|
-
add(path, errors);
|
|
13280
|
+
fs.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
|
|
13281
|
+
},
|
|
13282
|
+
rmFile: fs.rmSync,
|
|
13283
|
+
readBuffer: fileName => {
|
|
13284
|
+
try {
|
|
13285
|
+
return fs.readFileSync(fileName);
|
|
13286
|
+
} catch {
|
|
13287
|
+
return undefined;
|
|
13441
13288
|
}
|
|
13442
13289
|
}
|
|
13443
|
-
|
|
13444
|
-
|
|
13290
|
+
}, disableCache = false) {
|
|
13291
|
+
this.projectRoot = projectRoot;
|
|
13292
|
+
this.compilerOptions = compilerOptions;
|
|
13293
|
+
this.sourceFileHandler = sourceFileHandler;
|
|
13294
|
+
this.host = host;
|
|
13295
|
+
this.disableCache = disableCache;
|
|
13296
|
+
this.cache = {};
|
|
13297
|
+
this.cacheSize = 0;
|
|
13298
|
+
}
|
|
13299
|
+
getModule(modulePath) {
|
|
13300
|
+
if (!modulePath) {
|
|
13301
|
+
throw Error(`Illegal module path: "${modulePath}"`);
|
|
13445
13302
|
}
|
|
13446
|
-
|
|
13447
|
-
|
|
13448
|
-
|
|
13449
|
-
// "repair" content that is already publishable.
|
|
13450
|
-
const blocking = filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, state.sources);
|
|
13451
|
-
|
|
13452
|
-
// A module whose source could not be read at all has no source path to
|
|
13453
|
-
// hang an error on, so it is reported separately rather than lost.
|
|
13454
|
-
const unreadable = Object.entries(validation.errors).filter(([, moduleErrors]) => moduleErrors.invalidSource).map(([path, moduleErrors]) => {
|
|
13455
|
-
var _moduleErrors$invalid;
|
|
13456
|
-
return {
|
|
13457
|
-
moduleFilePath: path,
|
|
13458
|
-
message: ((_moduleErrors$invalid = moduleErrors.invalidSource) === null || _moduleErrors$invalid === void 0 ? void 0 : _moduleErrors$invalid.message) ?? "Invalid source"
|
|
13459
|
-
};
|
|
13460
|
-
});
|
|
13461
|
-
const scope = moduleFilePath;
|
|
13462
|
-
const errors = scope === undefined ? blocking : filterKeysByModule(blocking, scope);
|
|
13463
|
-
const unreadableInScope = scope === undefined ? unreadable : unreadable.filter(u => u.moduleFilePath === scope);
|
|
13464
|
-
return ok({
|
|
13465
|
-
valid: Object.keys(errors).length === 0 && unreadableInScope.length === 0,
|
|
13466
|
-
errors: Object.fromEntries(Object.entries(errors).map(([path, errs]) => [path, errs.map(toJsonValidationError)])),
|
|
13467
|
-
// Always present, empty when there are none: a caller should not have
|
|
13468
|
-
// to tell "absent" from "empty" to decide whether content is publishable.
|
|
13469
|
-
unreadableModules: unreadableInScope
|
|
13470
|
-
});
|
|
13471
|
-
}), defineTool({
|
|
13472
|
-
name: "get_patches",
|
|
13473
|
-
title: "Get patches",
|
|
13474
|
-
description: "List the unpublished changes in the project, oldest first, with who made each one. A change reported as not applying is why a module's content may not match what publishing would produce, and why writing to that module is refused.",
|
|
13475
|
-
inputSchema: z.object({
|
|
13476
|
-
moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit to changes touching one module.")
|
|
13477
|
-
}),
|
|
13478
|
-
annotations: {
|
|
13479
|
-
readOnlyHint: true
|
|
13303
|
+
const code = this.host.readFile(modulePath);
|
|
13304
|
+
if (!code) {
|
|
13305
|
+
throw Error(`Could not read file "${modulePath}"`);
|
|
13480
13306
|
}
|
|
13481
|
-
|
|
13482
|
-
|
|
13483
|
-
|
|
13484
|
-
|
|
13485
|
-
|
|
13486
|
-
|
|
13487
|
-
|
|
13488
|
-
|
|
13489
|
-
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13499
|
-
|
|
13500
|
-
|
|
13501
|
-
createdAt: patch.createdAt,
|
|
13502
|
-
authorId: patch.authorId,
|
|
13503
|
-
// `appliedAt` non-null means this change is already committed, so
|
|
13504
|
-
// it is history rather than something still pending.
|
|
13505
|
-
published: patch.appliedAt !== null,
|
|
13506
|
-
// Always present, so "applies cleanly" is stated rather than
|
|
13507
|
-
// inferred from the absence of a field.
|
|
13508
|
-
appliesCleanly: failure === undefined,
|
|
13509
|
-
...(failure === undefined ? {} : {
|
|
13510
|
-
applyError: failure
|
|
13511
|
-
})
|
|
13512
|
-
};
|
|
13513
|
-
}));
|
|
13514
|
-
}), defineTool({
|
|
13515
|
-
name: "get_source_path_from_route",
|
|
13516
|
-
title: "Get source path from route",
|
|
13517
|
-
description: "Given a URL path on the site, find the Val module and source path that renders it. Use this when the user names a page rather than a module.",
|
|
13518
|
-
inputSchema: z.object({
|
|
13519
|
-
route: z.string().describe('A route on the site, e.g. "/blog/my-post".')
|
|
13520
|
-
}),
|
|
13521
|
-
annotations: {
|
|
13522
|
-
readOnlyHint: true,
|
|
13523
|
-
idempotentHint: true
|
|
13524
|
-
}
|
|
13525
|
-
}, async ({
|
|
13526
|
-
route
|
|
13527
|
-
}, {
|
|
13528
|
-
state
|
|
13529
|
-
}) => {
|
|
13530
|
-
const found = getSourcePathFromRoute(route, state.serializedSchemas);
|
|
13531
|
-
if (!found) {
|
|
13532
|
-
return err("not-found", `No Val module renders the route ${JSON.stringify(route)}. Routes come from modules with a router configured; get_all_schema shows which have one.`);
|
|
13533
|
-
}
|
|
13534
|
-
return ok(found);
|
|
13535
|
-
})];
|
|
13536
|
-
}
|
|
13537
|
-
|
|
13538
|
-
/**
|
|
13539
|
-
* Resolve a module and classify the value at a path inside it.
|
|
13540
|
-
*
|
|
13541
|
-
* Shared by `get_record_keys` and `count_entries` so the two cannot drift on
|
|
13542
|
-
* what counts as a missing module, and so both map the same failure to the same
|
|
13543
|
-
* error code: a path that is not there is `not-found`, while a path that is
|
|
13544
|
-
* there but holds a string or an image is `invalid-args` — the caller should
|
|
13545
|
-
* reach for a different tool, not go looking for the path again.
|
|
13546
|
-
*/
|
|
13547
|
-
function describeContainer(state, moduleFilePath, path) {
|
|
13548
|
-
const modulePath = moduleFilePath;
|
|
13549
|
-
const schema = state.serializedSchemas[modulePath];
|
|
13550
|
-
if (!schema) {
|
|
13551
|
-
return {
|
|
13552
|
-
kind: "error",
|
|
13553
|
-
result: err("not-found", unknownModuleMessage(modulePath, state))
|
|
13554
|
-
};
|
|
13555
|
-
}
|
|
13556
|
-
const described = describeContainerAtPath(schema, state.sources[modulePath], path);
|
|
13557
|
-
if (described.kind === "error") {
|
|
13558
|
-
return {
|
|
13559
|
-
kind: "error",
|
|
13560
|
-
result: err(described.reason === "missing" ? "not-found" : "invalid-args", described.message)
|
|
13561
|
-
};
|
|
13562
|
-
}
|
|
13563
|
-
return described;
|
|
13564
|
-
}
|
|
13565
|
-
|
|
13566
|
-
/** "a record", but "an object" and "an array". */
|
|
13567
|
-
function article(container) {
|
|
13568
|
-
return container === "object" || container === "array" ? "an" : "a";
|
|
13569
|
-
}
|
|
13570
|
-
function unknownModuleMessage(path, state) {
|
|
13571
|
-
const known = Object.keys(state.serializedSchemas);
|
|
13572
|
-
return `No Val module at ${JSON.stringify(path)}. Known modules: ${known.length === 0 ? "(none)" : known.join(", ")}`;
|
|
13573
|
-
}
|
|
13574
|
-
|
|
13575
|
-
/**
|
|
13576
|
-
* Project a validation error into something JSON-safe and worth reading.
|
|
13577
|
-
*
|
|
13578
|
-
* `ValidationError.value` is dropped rather than serialized: it is `unknown` (so
|
|
13579
|
-
* not `Json` to begin with) and it holds the offending source value, which can
|
|
13580
|
-
* be arbitrarily large. A caller already has the source path and can read the
|
|
13581
|
-
* value with `get_source` if it needs to — putting it here would bloat every
|
|
13582
|
-
* result for the rare case that wants it.
|
|
13583
|
-
*
|
|
13584
|
-
* `fixes` is kept, because it names what Val already knows how to repair, which
|
|
13585
|
-
* is directly actionable.
|
|
13586
|
-
*/
|
|
13587
|
-
function toJsonValidationError(error) {
|
|
13588
|
-
return {
|
|
13589
|
-
message: error.message,
|
|
13590
|
-
fixes: error.fixes ? [...error.fixes] : [],
|
|
13591
|
-
typeError: error.typeError === true,
|
|
13592
|
-
schemaError: error.schemaError === true,
|
|
13593
|
-
keyError: error.keyError === true
|
|
13594
|
-
};
|
|
13595
|
-
}
|
|
13596
|
-
function isPlainObject(value) {
|
|
13597
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13598
|
-
}
|
|
13599
|
-
|
|
13600
|
-
/**
|
|
13601
|
-
* Keep only the entries belonging to one module.
|
|
13602
|
-
*
|
|
13603
|
-
* Keyed by SourcePath, which begins with the module file path, so a prefix match
|
|
13604
|
-
* is the right test — there is no per-module grouping left to index by.
|
|
13605
|
-
*/
|
|
13606
|
-
function filterKeysByModule(record, moduleFilePath) {
|
|
13607
|
-
const out = {};
|
|
13608
|
-
for (const [path, value] of Object.entries(record)) {
|
|
13609
|
-
if (path.startsWith(moduleFilePath)) {
|
|
13610
|
-
out[path] = value;
|
|
13611
|
-
}
|
|
13612
|
-
}
|
|
13613
|
-
return out;
|
|
13614
|
-
}
|
|
13615
|
-
|
|
13616
|
-
/**
|
|
13617
|
-
* Everything the Studio does client-side before a patch can be saved, done
|
|
13618
|
-
* server-side.
|
|
13619
|
-
*
|
|
13620
|
-
* Three things had no server equivalent, and each is a way to be quietly wrong:
|
|
13621
|
-
* where the patch id comes from, what the patch says its parent is, and whether
|
|
13622
|
-
* the result would even be valid. `docs/plans/mcp.md` Part C is the design.
|
|
13623
|
-
*/
|
|
13624
|
-
|
|
13625
|
-
const jsonOps = new JSONOps();
|
|
13626
|
-
|
|
13627
|
-
/**
|
|
13628
|
-
* A patch id, minted before the write is attempted.
|
|
13629
|
-
*
|
|
13630
|
-
* Same shape the Studio mints (a v4 UUID), and minting one that never gets used
|
|
13631
|
-
* costs nothing — ids are not registered anywhere until a patch carries them.
|
|
13632
|
-
*/
|
|
13633
|
-
function mintPatchId() {
|
|
13634
|
-
// A branded string has no constructor; this is the same conversion the Studio
|
|
13635
|
-
// and ValServer both make.
|
|
13636
|
-
return randomUUID();
|
|
13637
|
-
}
|
|
13638
|
-
|
|
13639
|
-
/**
|
|
13640
|
-
* What the new patch should hang off.
|
|
13641
|
-
*
|
|
13642
|
-
* The last known patch if there is one, otherwise the current head. Note the
|
|
13643
|
-
* asymmetry between the two backends: `ValOpsFS` ignores `parentRef` entirely
|
|
13644
|
-
* because its append-only ordering log defines order, while `ValOpsHttp` sends
|
|
13645
|
-
* it up as `parentPatchId` for optimistic concurrency. So a wrong value here is
|
|
13646
|
-
* invisible locally and a conflict in production — which is why this is derived
|
|
13647
|
-
* fresh rather than remembered.
|
|
13648
|
-
*/
|
|
13649
|
-
async function deriveParentRef(ops,
|
|
13650
|
-
// Only the ids matter, so this accepts either shape `fetchPatches` can
|
|
13651
|
-
// return — the metadata-only variant omits the ops but keeps the ids.
|
|
13652
|
-
patches) {
|
|
13653
|
-
const last = patches.patches[patches.patches.length - 1];
|
|
13654
|
-
if (last) {
|
|
13655
|
-
return {
|
|
13656
|
-
type: "patch",
|
|
13657
|
-
patchId: last.patchId
|
|
13658
|
-
};
|
|
13659
|
-
}
|
|
13660
|
-
return {
|
|
13661
|
-
type: "head",
|
|
13662
|
-
headBaseSha: await ops.getBaseSha()
|
|
13663
|
-
};
|
|
13664
|
-
}
|
|
13665
|
-
|
|
13666
|
-
/**
|
|
13667
|
-
* Would this patch leave the content valid?
|
|
13668
|
-
*
|
|
13669
|
-
* Applied to a **clone** of the sources, never the real ones: `applyPatch`
|
|
13670
|
-
* mutates the document it is given, and ValOps carries a standing note that
|
|
13671
|
-
* add operations misbehave without a clone. Validating in place would corrupt
|
|
13672
|
-
* the sources every later call in this process reads.
|
|
13673
|
-
*
|
|
13674
|
-
* Server-side this is strictly better than the Studio's speculative check.
|
|
13675
|
-
* `getSchemas()` returns real `Schema` instances, so the user's own `validate`
|
|
13676
|
-
* closures run — and those are not carried by the serialized schema the browser
|
|
13677
|
-
* has, which means the browser cannot run them at all.
|
|
13678
|
-
*/
|
|
13679
|
-
async function validateSpeculatively(ops, state, moduleFilePath, patch) {
|
|
13680
|
-
const current = state.sources[moduleFilePath];
|
|
13681
|
-
if (current === undefined) {
|
|
13682
|
-
return {
|
|
13683
|
-
status: "unapplicable",
|
|
13684
|
-
result: {
|
|
13685
|
-
status: "error",
|
|
13686
|
-
code: "not-found",
|
|
13687
|
-
message: `No Val module at ${JSON.stringify(moduleFilePath)}.`
|
|
13688
|
-
}
|
|
13689
|
-
};
|
|
13690
|
-
}
|
|
13691
|
-
const applied = applyPatch(deepClone(current), jsonOps, patch);
|
|
13692
|
-
if (result.isErr(applied)) {
|
|
13693
|
-
return {
|
|
13694
|
-
status: "unapplicable",
|
|
13695
|
-
result: {
|
|
13696
|
-
status: "error",
|
|
13697
|
-
code: "invalid-args",
|
|
13698
|
-
message: `The patch cannot be applied to ${moduleFilePath}: ${applied.error.message}`
|
|
13699
|
-
}
|
|
13700
|
-
};
|
|
13701
|
-
}
|
|
13702
|
-
const speculativeSources = {
|
|
13703
|
-
...state.sources,
|
|
13704
|
-
[moduleFilePath]: applied.value
|
|
13705
|
-
};
|
|
13706
|
-
const after = await blockingErrorsIn(ops, state, speculativeSources, moduleFilePath);
|
|
13707
|
-
if (after.length === 0) {
|
|
13708
|
-
return {
|
|
13709
|
-
status: "valid"
|
|
13710
|
-
};
|
|
13711
|
-
}
|
|
13712
|
-
|
|
13713
|
-
// Only the errors this patch *introduces*. A module can already be broken for
|
|
13714
|
-
// reasons this change has nothing to do with -- the example app ships with a
|
|
13715
|
-
// missing image file -- and refusing on the total would make every such module
|
|
13716
|
-
// permanently read-only: an agent could not fix a typo in a file that also
|
|
13717
|
-
// holds a broken image reference. Paid for only when there is something to
|
|
13718
|
-
// refuse, so an ordinary clean edit still validates once.
|
|
13719
|
-
const before = await blockingErrorsIn(ops, state, state.sources, moduleFilePath);
|
|
13720
|
-
const existing = new Set(before.map(identify));
|
|
13721
|
-
const introduced = after.filter(error => !existing.has(identify(error)));
|
|
13722
|
-
if (introduced.length === 0) {
|
|
13723
|
-
return {
|
|
13724
|
-
status: "valid"
|
|
13725
|
-
};
|
|
13726
|
-
}
|
|
13727
|
-
return {
|
|
13728
|
-
status: "invalid",
|
|
13729
|
-
errors: describeErrors(introduced)
|
|
13730
|
-
};
|
|
13731
|
-
}
|
|
13732
|
-
/** Path and message together: the same message at another path is another problem. */
|
|
13733
|
-
function identify(error) {
|
|
13734
|
-
return `${error.path}\u0000${error.message}`;
|
|
13735
|
-
}
|
|
13736
|
-
|
|
13737
|
-
/**
|
|
13738
|
-
* The publishing-blocking errors in one module, for a given set of sources.
|
|
13739
|
-
*
|
|
13740
|
-
* Scoped to one module by source path, which starts with the module file path.
|
|
13741
|
-
* Errors elsewhere in the project are somebody else's: refusing on them would
|
|
13742
|
-
* let the first broken module in a repo make every other module read-only.
|
|
13743
|
-
*/
|
|
13744
|
-
async function blockingErrorsIn(ops, state, sources, moduleFilePath) {
|
|
13745
|
-
const validation = await ops.validateSources(state.schemas, sources,
|
|
13746
|
-
// Every module, deliberately -- `patchesByModule` is a FILTER on which
|
|
13747
|
-
// modules get validated, not context for validating them. Passing the
|
|
13748
|
-
// analysis from before this write skips the very module being written
|
|
13749
|
-
// whenever it had no pending patch, so the first change to a module went
|
|
13750
|
-
// unchecked; and a change that breaks a `keyOf` or a router in a *different*
|
|
13751
|
-
// module reports its error there, which a filtered run never visits.
|
|
13752
|
-
undefined);
|
|
13753
|
-
// `validateSources` hands back the files it could not check on its own;
|
|
13754
|
-
// running them is what turns "this path holds a file" into "that file is
|
|
13755
|
-
// actually there and matches its recorded metadata".
|
|
13756
|
-
const fileErrors = await ops.validateFiles(state.schemas, sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
|
|
13757
|
-
|
|
13758
|
-
// Merged rather than overwritten: a path can pick up an error from validation
|
|
13759
|
-
// and another from its file.
|
|
13760
|
-
const bySourcePath = {};
|
|
13761
|
-
const add = (path, errors) => {
|
|
13762
|
-
bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
|
|
13763
|
-
};
|
|
13764
|
-
for (const moduleErrors of Object.values(validation.errors)) {
|
|
13765
|
-
for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
|
|
13766
|
-
add(path, errors);
|
|
13767
|
-
}
|
|
13768
|
-
}
|
|
13769
|
-
for (const [path, errors] of Object.entries(fileErrors)) {
|
|
13770
|
-
add(path, errors);
|
|
13771
|
-
}
|
|
13772
|
-
const blocking = filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, sources);
|
|
13773
|
-
const located = [];
|
|
13774
|
-
for (const [path, errors] of Object.entries(blocking)) {
|
|
13775
|
-
if (!path.startsWith(moduleFilePath)) {
|
|
13776
|
-
continue;
|
|
13777
|
-
}
|
|
13778
|
-
for (const error of errors) {
|
|
13779
|
-
located.push({
|
|
13780
|
-
path: path,
|
|
13781
|
-
message: error.message
|
|
13782
|
-
});
|
|
13783
|
-
}
|
|
13784
|
-
}
|
|
13785
|
-
return located;
|
|
13786
|
-
}
|
|
13787
|
-
function describeErrors(errors) {
|
|
13788
|
-
return errors.map(e => `${e.path}: ${e.message}`).join("; ");
|
|
13789
|
-
}
|
|
13790
|
-
|
|
13791
|
-
/**
|
|
13792
|
-
* What to do when the change would leave the content invalid.
|
|
13793
|
-
*
|
|
13794
|
-
* `"reject"` for a tool that is editing existing content: an agent should not be
|
|
13795
|
-
* able to break a site, and a rejected patch stores nothing.
|
|
13796
|
-
*
|
|
13797
|
-
* `"report"` for a tool whose whole purpose is to create something incomplete.
|
|
13798
|
-
* `empty_at_path` scaffolds an entry the caller is then expected to fill in, so
|
|
13799
|
-
* on most real schemas — anything with a non-empty string — the value it creates
|
|
13800
|
-
* is invalid by construction. Rejecting that would make the tool useless on
|
|
13801
|
-
* exactly the schemas it exists for, so instead the patch is saved and the
|
|
13802
|
-
* remaining errors come back as a to-do list. This mirrors the Studio, where
|
|
13803
|
-
* creating an empty entry is normal and the errors show until it is filled in.
|
|
13804
|
-
*/
|
|
13805
|
-
|
|
13806
|
-
/**
|
|
13807
|
-
* Validate, then save — and retry once if someone else got there first.
|
|
13808
|
-
*
|
|
13809
|
-
* The retry exists because the parent ref is derived from a read that happened
|
|
13810
|
-
* before the write. A conflict means the chain moved underneath us, and
|
|
13811
|
-
* re-deriving is usually enough. Once only: a loop here would be an agent
|
|
13812
|
-
* fighting a human editor in the Studio, and losing slowly is worse than
|
|
13813
|
-
* failing clearly.
|
|
13814
|
-
*/
|
|
13815
|
-
async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
|
|
13816
|
-
var _ctx$auth;
|
|
13817
|
-
const {
|
|
13818
|
-
ops,
|
|
13819
|
-
ctx,
|
|
13820
|
-
state
|
|
13821
|
-
} = deps;
|
|
13822
|
-
const unapplied = state.unappliedPatches[moduleFilePath];
|
|
13823
|
-
if (unapplied && unapplied.length > 0) {
|
|
13824
|
-
// Refused before anything is validated, because the state to validate
|
|
13825
|
-
// against is wrong. `sources` for this module silently lacks these pending
|
|
13826
|
-
// changes, so a patch built on it would be based on content that will never
|
|
13827
|
-
// exist -- and its parent ref would chain onto changes that do not apply.
|
|
13828
|
-
return {
|
|
13829
|
-
status: "error",
|
|
13830
|
-
code: "internal",
|
|
13831
|
-
message: `Cannot write to ${moduleFilePath}: it has ${unapplied.length} pending change${unapplied.length === 1 ? "" : "s"} that will not apply, so what is stored is not what publishing would produce. Resolve or discard ${unapplied.map(u => u.patchId).join(", ")} first -- get_patches shows them.`
|
|
13832
|
-
};
|
|
13833
|
-
}
|
|
13834
|
-
const speculative = await validateSpeculatively(ops, state, moduleFilePath, patch);
|
|
13835
|
-
if (speculative.status === "unapplicable") {
|
|
13836
|
-
// Never negotiable: the patch does not fit the content, so there is nothing
|
|
13837
|
-
// to save whatever the caller's tolerance for invalid results.
|
|
13838
|
-
return speculative.result;
|
|
13839
|
-
}
|
|
13840
|
-
let unresolved = null;
|
|
13841
|
-
if (speculative.status === "invalid") {
|
|
13842
|
-
if (onInvalid === "reject") {
|
|
13843
|
-
return {
|
|
13844
|
-
status: "error",
|
|
13845
|
-
code: "validation-failed",
|
|
13846
|
-
message: `The change was rejected and nothing was saved, because it would leave the content invalid: ${speculative.errors}`
|
|
13847
|
-
};
|
|
13848
|
-
}
|
|
13849
|
-
unresolved = speculative.errors;
|
|
13850
|
-
}
|
|
13851
|
-
|
|
13852
|
-
/**
|
|
13853
|
-
* The verified profile, or null when there was nothing to verify.
|
|
13854
|
-
*
|
|
13855
|
-
* An author is written only when somebody checked it. On the token path the
|
|
13856
|
-
* host verified a signature over a key it does not hold, so the profile is
|
|
13857
|
-
* checked rather than claimed, and the backend has no token of its own to
|
|
13858
|
-
* attribute from — the call reaches it under the app's API key. If this
|
|
13859
|
-
* stayed null there, every edit made through a signed-in editor's own session
|
|
13860
|
-
* would land with no author at all, which is worse than useless on a CMS
|
|
13861
|
-
* whose review screen is organised by who changed what.
|
|
13862
|
-
*
|
|
13863
|
-
* Null is what local filesystem mode gets, where there is no credential to
|
|
13864
|
-
* resolve, exactly as the Studio does locally. It is also what the removed
|
|
13865
|
-
* personal-access-token path got, and for a reason worth keeping in view: an
|
|
13866
|
-
* id derived from a credential the app cannot resolve is an unverified claim
|
|
13867
|
-
* dressed up as a checked one. Should another unverified credential ever
|
|
13868
|
-
* reach here, null remains its only honest author.
|
|
13869
|
-
*/
|
|
13870
|
-
const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
|
|
13871
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
13872
|
-
const patchId = mintPatchId();
|
|
13873
|
-
// Re-derived on the retry rather than reused: reusing the ref that just
|
|
13874
|
-
// conflicted would conflict again by definition.
|
|
13875
|
-
const patches = attempt === 0 ? state.patches : await ops.fetchPatches({
|
|
13876
|
-
excludePatchOps: true
|
|
13877
|
-
});
|
|
13878
|
-
const parentRef = await deriveParentRef(ops, patches);
|
|
13879
|
-
const saved = await ops.createPatch(moduleFilePath, patch, patchId, parentRef, ctx.sessionId, authorId);
|
|
13880
|
-
if (result.isOk(saved)) {
|
|
13881
|
-
return {
|
|
13882
|
-
status: "ok",
|
|
13883
|
-
data: {
|
|
13884
|
-
patchId: saved.value.patchId,
|
|
13885
|
-
moduleFilePath,
|
|
13886
|
-
createdAt: saved.value.createdAt,
|
|
13887
|
-
// Always present, so a caller does not have to tell "absent" from
|
|
13888
|
-
// "nothing left to do" to know whether the content is publishable.
|
|
13889
|
-
unresolvedValidationErrors: unresolved
|
|
13890
|
-
}
|
|
13891
|
-
};
|
|
13892
|
-
}
|
|
13893
|
-
if (saved.error.errorType === "patch-head-conflict") {
|
|
13894
|
-
continue;
|
|
13895
|
-
}
|
|
13896
|
-
return {
|
|
13897
|
-
status: "error",
|
|
13898
|
-
code: "internal",
|
|
13899
|
-
// Note the nesting: createPatch wraps the underlying flat error as
|
|
13900
|
-
// `{ errorType: "other", error: <that> }`.
|
|
13901
|
-
message: saved.error.error.message
|
|
13902
|
-
};
|
|
13903
|
-
}
|
|
13904
|
-
return {
|
|
13905
|
-
status: "error",
|
|
13906
|
-
code: "conflict",
|
|
13907
|
-
message: "Another change was saved while this one was being written, twice in a row. Read the content again before retrying — it has moved."
|
|
13908
|
-
};
|
|
13909
|
-
}
|
|
13910
|
-
|
|
13911
|
-
/**
|
|
13912
|
-
* The tools that change content.
|
|
13913
|
-
*
|
|
13914
|
-
* Every one of them goes through {@link savePatch}, so they all inherit the same
|
|
13915
|
-
* guarantees: the change is validated against the real schemas before anything
|
|
13916
|
-
* is stored, a rejected change stores nothing, and a lost race with another
|
|
13917
|
-
* writer is retried once and then reported rather than looped on.
|
|
13918
|
-
*
|
|
13919
|
-
* Images are not here. The Studio's image tools work from a handle into Val's
|
|
13920
|
-
* AI session store — bytes the browser got from the vision system — and MCP has
|
|
13921
|
-
* no equivalent, so they need a different affordance (a local file path, or
|
|
13922
|
-
* inline base64) rather than a port. `docs/plans/mcp.md` Part B has the reasoning.
|
|
13923
|
-
*/
|
|
13924
|
-
|
|
13925
|
-
const ModuleFilePathSchema = z.string().describe('Path of the Val module, e.g. "/content/pages.val.ts".');
|
|
13926
|
-
function writeTools() {
|
|
13927
|
-
return [defineTool({
|
|
13928
|
-
name: "create_patch",
|
|
13929
|
-
title: "Create patch",
|
|
13930
|
-
description: "Change content in a Val module by applying JSON Patch operations. The change is validated first and is rejected outright if it would make the content invalid. Text and JSON values only — not files or images.",
|
|
13931
|
-
inputSchema: z.object({
|
|
13932
|
-
moduleFilePath: ModuleFilePathSchema,
|
|
13933
|
-
patch: z.array(z.unknown()).describe('JSON Patch operations, e.g. [{"op":"replace","path":["title"],"value":"New title"}]. Paths are arrays of keys, not slash-separated strings.')
|
|
13934
|
-
}),
|
|
13935
|
-
annotations: {
|
|
13936
|
-
idempotentHint: false
|
|
13937
|
-
}
|
|
13938
|
-
}, async ({
|
|
13939
|
-
moduleFilePath,
|
|
13940
|
-
patch
|
|
13941
|
-
}, deps) => {
|
|
13942
|
-
// Before parsing, not after: a file op that is also malformed should be
|
|
13943
|
-
// told that files are not supported, rather than handed a schema error
|
|
13944
|
-
// about the shape of a thing it was never going to be allowed to do.
|
|
13945
|
-
const rejected = rejectFileOps(patch);
|
|
13946
|
-
if (rejected) {
|
|
13947
|
-
return rejected;
|
|
13948
|
-
}
|
|
13949
|
-
const parsed = safeParsePatch(patch);
|
|
13950
|
-
if (parsed.kind !== "ok") {
|
|
13951
|
-
return fromBuildResult(parsed);
|
|
13952
|
-
}
|
|
13953
|
-
return savePatch(deps, moduleFilePath, parsed.patch);
|
|
13954
|
-
}), defineTool({
|
|
13955
|
-
name: "duplicate_source",
|
|
13956
|
-
title: "Duplicate source",
|
|
13957
|
-
description: "Copy the value at one path in a module to another path. Use this to add an entry modelled on an existing one, rather than composing it field by field.",
|
|
13958
|
-
inputSchema: z.object({
|
|
13959
|
-
moduleFilePath: ModuleFilePathSchema,
|
|
13960
|
-
sourcePath: z.array(z.string()).describe("Path of the value to copy."),
|
|
13961
|
-
destinationPath: z.array(z.string()).describe("Path to copy it to. Must not already exist.")
|
|
13962
|
-
}),
|
|
13963
|
-
annotations: {
|
|
13964
|
-
idempotentHint: false
|
|
13965
|
-
}
|
|
13966
|
-
}, async ({
|
|
13967
|
-
moduleFilePath,
|
|
13968
|
-
sourcePath,
|
|
13969
|
-
destinationPath
|
|
13970
|
-
}, deps) => {
|
|
13971
|
-
const modulePath = moduleFilePath;
|
|
13972
|
-
const schema = deps.state.serializedSchemas[modulePath];
|
|
13973
|
-
if (!schema) {
|
|
13974
|
-
return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
|
|
13975
|
-
}
|
|
13976
|
-
const built = buildDuplicatePatch({
|
|
13977
|
-
sourcePath,
|
|
13978
|
-
destinationPath
|
|
13979
|
-
}, schema, deps.state.sources[modulePath]);
|
|
13980
|
-
if (built.kind !== "ok") {
|
|
13981
|
-
return fromBuildResult(built);
|
|
13982
|
-
}
|
|
13983
|
-
return savePatch(deps, modulePath, built.patch);
|
|
13984
|
-
}), defineTool({
|
|
13985
|
-
name: "empty_at_path",
|
|
13986
|
-
title: "Create an empty value at a path",
|
|
13987
|
-
description: "Create a new, schema-correct empty value at a path — an empty entry in a record or array, for instance. Prefer this over composing one by hand: it derives the shape from the schema, including required fields. The value it creates is usually not yet publishable; the result lists what still needs filling in, which you can then do with create_patch.",
|
|
13988
|
-
inputSchema: z.object({
|
|
13989
|
-
moduleFilePath: ModuleFilePathSchema,
|
|
13990
|
-
destinationPath: z.array(z.string()).describe("Path to create the empty value at.")
|
|
13991
|
-
}),
|
|
13992
|
-
annotations: {
|
|
13993
|
-
idempotentHint: false
|
|
13994
|
-
}
|
|
13995
|
-
}, async ({
|
|
13996
|
-
moduleFilePath,
|
|
13997
|
-
destinationPath
|
|
13998
|
-
}, deps) => {
|
|
13999
|
-
const modulePath = moduleFilePath;
|
|
14000
|
-
const schema = deps.state.serializedSchemas[modulePath];
|
|
14001
|
-
if (!schema) {
|
|
14002
|
-
return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
|
|
14003
|
-
}
|
|
14004
|
-
const built = buildEmptyAtPathPatch({
|
|
14005
|
-
destinationPath
|
|
14006
|
-
}, schema, deps.state.sources[modulePath]);
|
|
14007
|
-
if (built.kind !== "ok") {
|
|
14008
|
-
return fromBuildResult(built);
|
|
14009
|
-
}
|
|
14010
|
-
// "report", not "reject": an empty entry is invalid by construction on
|
|
14011
|
-
// any schema with a required non-empty field, which is most of them.
|
|
14012
|
-
// See OnInvalid in writePath.ts.
|
|
14013
|
-
return savePatch(deps, modulePath, built.patch, "report");
|
|
14014
|
-
}), defineTool({
|
|
14015
|
-
name: "remove_image_gallery_entry",
|
|
14016
|
-
title: "Remove an image gallery entry",
|
|
14017
|
-
description: "Remove one image from an image gallery module by its file path. This deletes the entry and the file it refers to.",
|
|
14018
|
-
inputSchema: z.object({
|
|
14019
|
-
moduleFilePath: ModuleFilePathSchema.describe("The gallery module, i.e. one declared with s.images() or s.files()."),
|
|
14020
|
-
filePath: z.string().describe('The gallery key to remove, e.g. "/public/val/photo_a1b2c.jpg".')
|
|
14021
|
-
}),
|
|
14022
|
-
// Destructive: it removes content and the underlying file, so a host
|
|
14023
|
-
// that asks for confirmation should ask here.
|
|
14024
|
-
annotations: {
|
|
14025
|
-
destructiveHint: true,
|
|
14026
|
-
idempotentHint: false
|
|
14027
|
-
}
|
|
14028
|
-
}, async ({
|
|
14029
|
-
moduleFilePath,
|
|
14030
|
-
filePath
|
|
14031
|
-
}, deps) => {
|
|
14032
|
-
const modulePath = moduleFilePath;
|
|
14033
|
-
const schema = deps.state.serializedSchemas[modulePath];
|
|
14034
|
-
if (!schema) {
|
|
14035
|
-
return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
|
|
14036
|
-
}
|
|
14037
|
-
const built = buildRemoveImageGalleryEntryPatch({
|
|
14038
|
-
filePath
|
|
14039
|
-
}, schema, deps.state.sources[modulePath]);
|
|
14040
|
-
if (built.kind !== "ok") {
|
|
14041
|
-
return fromBuildResult(built);
|
|
14042
|
-
}
|
|
14043
|
-
return savePatch(deps, modulePath, built.patch);
|
|
14044
|
-
})];
|
|
14045
|
-
}
|
|
14046
|
-
|
|
14047
|
-
/**
|
|
14048
|
-
* Turn a helper's build failure into a tool error.
|
|
14049
|
-
*
|
|
14050
|
-
* `wrong-tool` is worth keeping distinct: the helpers can tell that the caller
|
|
14051
|
-
* reached for the wrong tool and which one it should have used, and passing that
|
|
14052
|
-
* through is what lets a model correct itself in one step instead of retrying
|
|
14053
|
-
* the same call.
|
|
14054
|
-
*/
|
|
14055
|
-
function fromBuildResult(built) {
|
|
14056
|
-
if (built.kind === "wrong-tool") {
|
|
14057
|
-
return {
|
|
14058
|
-
status: "error",
|
|
14059
|
-
code: "invalid-args",
|
|
14060
|
-
message: `${built.reason} Use the ${built.suggestedTool} tool instead.`
|
|
14061
|
-
};
|
|
14062
|
-
}
|
|
14063
|
-
return {
|
|
14064
|
-
status: "error",
|
|
14065
|
-
code: "invalid-args",
|
|
14066
|
-
message: built.message
|
|
14067
|
-
};
|
|
14068
|
-
}
|
|
14069
|
-
|
|
14070
|
-
/**
|
|
14071
|
-
* File operations are refused rather than half-supported.
|
|
14072
|
-
*
|
|
14073
|
-
* A `file` op carries binary content that has to be uploaded before the patch
|
|
14074
|
-
* is synced — a two-phase flow this pass does not implement. Letting one through
|
|
14075
|
-
* would store a patch referring to bytes that were never uploaded, which fails
|
|
14076
|
-
* later and a long way from the cause.
|
|
14077
|
-
*
|
|
14078
|
-
* Takes the unparsed patch, so this answer does not depend on the op being
|
|
14079
|
-
* otherwise well formed. All it needs is the caller's own claim about what the
|
|
14080
|
-
* op is.
|
|
14081
|
-
*/
|
|
14082
|
-
function rejectFileOps(patch) {
|
|
14083
|
-
const hasFileOp = patch.some(op => typeof op === "object" && op !== null && "op" in op && op.op === "file");
|
|
14084
|
-
if (!hasFileOp) {
|
|
14085
|
-
return null;
|
|
14086
|
-
}
|
|
14087
|
-
return {
|
|
14088
|
-
status: "error",
|
|
14089
|
-
code: "unsupported",
|
|
14090
|
-
message: "This patch contains a file operation. Uploading files is not supported over MCP yet — only text and JSON values can be changed."
|
|
14091
|
-
};
|
|
14092
|
-
}
|
|
14093
|
-
|
|
14094
|
-
/**
|
|
14095
|
-
* The public surface of Val's server-side tool registry.
|
|
14096
|
-
*
|
|
14097
|
-
* Types only, deliberately: this file is the contract that the MCP hosts, the
|
|
14098
|
-
* CLI's stdio transport and the tools themselves are all written against, and
|
|
14099
|
-
* keeping it free of implementation means those can be built in any order
|
|
14100
|
-
* without one of them owning the shape.
|
|
14101
|
-
*
|
|
14102
|
-
* The design this implements is `docs/plans/mcp.md`, Part A. Two constraints
|
|
14103
|
-
* from it are load-bearing and easy to break by accident:
|
|
14104
|
-
*
|
|
14105
|
-
* 1. **Nothing here may import an MCP SDK.** That is what lets hosts other
|
|
14106
|
-
* than the template consume these tools, and it is not hypothetical
|
|
14107
|
-
* hygiene — the TypeScript SDK reorganised itself at v2.0.0, and a registry
|
|
14108
|
-
* coupled to it would have moved with it.
|
|
14109
|
-
* 2. **The result type is deliberately not MCP's `CallToolResult`.** Each host
|
|
14110
|
-
* adapts {@link ValToolResult} at its own edge, which is also where an
|
|
14111
|
-
* error becomes an in-band `isError` result the model can recover from
|
|
14112
|
-
* rather than a transport failure.
|
|
14113
|
-
*/
|
|
14114
|
-
|
|
14115
|
-
/** Why a tool call failed, in a form a host can map onto its own errors. */
|
|
14116
|
-
|
|
14117
|
-
/**
|
|
14118
|
-
* The same definition with `inputSchema` as JSON Schema, for hosts that want the
|
|
14119
|
-
* wire shape rather than a Standard Schema.
|
|
14120
|
-
*
|
|
14121
|
-
* Typed as whatever zod's own converter produces, so deriving it needs no cast
|
|
14122
|
-
* and no second hand-written description of the same input.
|
|
14123
|
-
*/
|
|
14124
|
-
|
|
14125
|
-
/**
|
|
14126
|
-
* How the caller was established, and there is one acceptable answer: the host
|
|
14127
|
-
* **checked a signature**.
|
|
14128
|
-
*
|
|
14129
|
-
* A union of one, deliberately. It carried a second variant — a personal access
|
|
14130
|
-
* token relayed to the backend unchecked, on the reasoning that the app cannot
|
|
14131
|
-
* resolve one and the backend can. The reasoning held; the shape did not. A
|
|
14132
|
-
* credential the host cannot check is one it also cannot refuse, so accepting
|
|
14133
|
-
* one made "a deployed endpoint that authenticates nobody" a supported
|
|
14134
|
-
* configuration, and it let a host serve these tools without ever being told
|
|
14135
|
-
* where callers should authorize. The discriminant stays so that adding a
|
|
14136
|
-
* second *verified* kind stays a one-line change at every call site.
|
|
14137
|
-
*/
|
|
14138
|
-
|
|
14139
|
-
/**
|
|
14140
|
-
* Who is calling, established once per request by the host.
|
|
14141
|
-
*
|
|
14142
|
-
* `null` means local fs mode, where there is no credential to hold and patches
|
|
14143
|
-
* are written with no author, exactly as the Studio does locally (D.1). In
|
|
14144
|
-
* proxy mode `null` is refused rather than falling back to the app's own API
|
|
14145
|
-
* key: that key can do more than any single user, and quietly substituting it
|
|
14146
|
-
* would turn a missing credential into full access.
|
|
14147
|
-
*/
|
|
14148
|
-
|
|
14149
|
-
/**
|
|
14150
|
-
* Brand a verified subject as an {@link AuthorId}.
|
|
14151
|
-
*
|
|
14152
|
-
* `AuthorId` is a branded string so that an id cannot be conjured from any
|
|
14153
|
-
* string that happens to be lying around — which is exactly the mistake this
|
|
14154
|
-
* type is guarding against. That makes one assertion unavoidable at the boundary
|
|
14155
|
-
* where a real id enters the system, so it lives here, once, with a name that
|
|
14156
|
-
* says what makes it legitimate: the caller has *verified* this subject, not
|
|
14157
|
-
* received it.
|
|
14158
|
-
*
|
|
14159
|
-
* Do not reach for this to satisfy a type. If you are holding a string you did
|
|
14160
|
-
* not verify, the honest value is `null`.
|
|
14161
|
-
*/
|
|
14162
|
-
function authorIdFromVerifiedSubject(subject) {
|
|
14163
|
-
return subject;
|
|
14164
|
-
}
|
|
14165
|
-
|
|
14166
|
-
/** Read access. Every call needs it, the writes included. */
|
|
14167
|
-
const VAL_SCOPE_READ = "val:read";
|
|
14168
|
-
/** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
|
|
14169
|
-
const VAL_SCOPE_WRITE = "val:write";
|
|
14170
|
-
|
|
14171
|
-
/**
|
|
14172
|
-
* Val's server-side tool registry.
|
|
14173
|
-
*
|
|
14174
|
-
* This is the piece Val did not have: the Studio's chat tools are defined *and
|
|
14175
|
-
* executed in the browser*, against its client stores, so nothing here could be
|
|
14176
|
-
* re-exposed. These tools run against {@link ValOps} instead, which is what lets
|
|
14177
|
-
* an MCP server — or a stdio transport, or anything else — drive Val content
|
|
14178
|
-
* without a browser.
|
|
14179
|
-
*
|
|
14180
|
-
* Transport-agnostic on purpose: nothing under `tools/` imports an MCP SDK. A
|
|
14181
|
-
* host adapts {@link ValToolResult} at its own edge.
|
|
14182
|
-
*/
|
|
14183
|
-
function createValTools(valModules, options) {
|
|
14184
|
-
const resolveOps = createOpsResolver(valModules, options);
|
|
14185
|
-
const tools = [...readTools(), ...writeTools()];
|
|
14186
|
-
const byName = new Map(tools.map(tool => [tool.name, tool]));
|
|
14187
|
-
return {
|
|
14188
|
-
list() {
|
|
14189
|
-
return tools.map(({
|
|
14190
|
-
handler: _handler,
|
|
14191
|
-
...definition
|
|
14192
|
-
}) => definition);
|
|
14193
|
-
},
|
|
14194
|
-
listJsonSchema() {
|
|
14195
|
-
return tools.map(({
|
|
14196
|
-
handler: _handler,
|
|
14197
|
-
inputSchema,
|
|
14198
|
-
...rest
|
|
14199
|
-
}) => ({
|
|
14200
|
-
...rest,
|
|
14201
|
-
// zod 4 derives this itself, so there is no JSON-Schema-to-zod
|
|
14202
|
-
// converter anywhere in the stack and no second description of the
|
|
14203
|
-
// same input to keep in step.
|
|
14204
|
-
inputSchema: z.toJSONSchema(inputSchema, {
|
|
14205
|
-
io: "input"
|
|
14206
|
-
})
|
|
14207
|
-
}));
|
|
14208
|
-
},
|
|
14209
|
-
async call(name, args, ctx) {
|
|
14210
|
-
const tool = byName.get(name);
|
|
14211
|
-
if (!tool) {
|
|
14212
|
-
return {
|
|
14213
|
-
status: "error",
|
|
14214
|
-
code: "unknown-tool",
|
|
14215
|
-
message: `No tool named ${JSON.stringify(name)}. Available: ${tools.map(t => t.name).join(", ")}`
|
|
14216
|
-
};
|
|
14217
|
-
}
|
|
14218
|
-
const parsed = tool.inputSchema.safeParse(args ?? {});
|
|
14219
|
-
if (!parsed.success) {
|
|
14220
|
-
return {
|
|
14221
|
-
status: "error",
|
|
14222
|
-
code: "invalid-args",
|
|
14223
|
-
message: describeZodError(parsed.error)
|
|
14224
|
-
};
|
|
14225
|
-
}
|
|
14226
|
-
const insufficient = refuseInsufficientScope(tool, ctx);
|
|
14227
|
-
if (insufficient) {
|
|
14228
|
-
return insufficient;
|
|
14229
|
-
}
|
|
14230
|
-
const resolved = resolveOps(ctx);
|
|
14231
|
-
if (resolved.status === "error") {
|
|
14232
|
-
return resolved.result;
|
|
14233
|
-
}
|
|
14234
|
-
const ops = resolved.ops;
|
|
14235
|
-
try {
|
|
14236
|
-
const state = await loadState(ops);
|
|
14237
|
-
if (state.status === "error") {
|
|
14238
|
-
return state.result;
|
|
14239
|
-
}
|
|
14240
|
-
const deps = {
|
|
14241
|
-
ops,
|
|
14242
|
-
ctx,
|
|
14243
|
-
state: state.state
|
|
14244
|
-
};
|
|
14245
|
-
return await tool.handler(parsed.data, deps);
|
|
14246
|
-
} catch (error) {
|
|
14247
|
-
// A thrown error here is a bug or an unreachable backend, not something
|
|
14248
|
-
// the model can act on — but it still comes back in-band so the client
|
|
14249
|
-
// sees a tool failure rather than a dead transport.
|
|
14250
|
-
return {
|
|
14251
|
-
status: "error",
|
|
14252
|
-
code: "internal",
|
|
14253
|
-
message: error instanceof Error ? error.message : String(error)
|
|
14254
|
-
};
|
|
14255
|
-
}
|
|
14256
|
-
},
|
|
14257
|
-
async dispose() {
|
|
14258
|
-
// Nothing to release today: ValOps holds no handle that needs closing, and
|
|
14259
|
-
// the fs watcher it can start is owned by the Studio's server. Kept in the
|
|
14260
|
-
// contract so hosts wire up teardown now rather than when it starts to
|
|
14261
|
-
// matter.
|
|
14262
|
-
}
|
|
14263
|
-
};
|
|
14264
|
-
}
|
|
14265
|
-
|
|
14266
|
-
/**
|
|
14267
|
-
* Pick the data layer for a call, which in proxy mode means picking whose
|
|
14268
|
-
* credential the backend will see.
|
|
14269
|
-
*
|
|
14270
|
-
* This is the one place authorization is decided, and in proxy mode there is
|
|
14271
|
-
* exactly one credential it will act on: an access token whose signature,
|
|
14272
|
-
* issuer, audience and expiry the host verified against the authorization
|
|
14273
|
-
* server's published key. Anything less is refused here rather than forwarded.
|
|
14274
|
-
*
|
|
14275
|
-
* There used to be a second route — the caller's personal access token, passed
|
|
14276
|
-
* through unread on the reasoning that the backend, not the app, is the
|
|
14277
|
-
* authority on what it may do. That was true, and it was still the wrong shape:
|
|
14278
|
-
* it made an unauthenticated bearer token on a deployed endpoint a supported
|
|
14279
|
-
* configuration, and it meant `initValMcp` had a path where an app served MCP
|
|
14280
|
-
* without ever being told where to authorize. A host that has not verified
|
|
14281
|
-
* anything now gets a refusal that names the missing `oauth` config.
|
|
14282
|
-
*
|
|
14283
|
-
* What has *not* changed is why a verified token does not become the app's own
|
|
14284
|
-
* API key by some other name. The app authenticates to the backend with its own
|
|
14285
|
-
* key here, and who did what travels as the patch's `authorId` — so the
|
|
14286
|
-
* profile has to be one the host checked cryptographically, never one it was
|
|
14287
|
-
* handed. An `authenticate()` that decided a credential's rights inside the app
|
|
14288
|
-
* would make every bug in it full access to every project that key can reach.
|
|
14289
|
-
*/
|
|
14290
|
-
function createOpsResolver(valModules, options) {
|
|
14291
|
-
if (options.mode === "fs") {
|
|
14292
|
-
// One instance, built once: fs mode is a developer's own working tree, so
|
|
14293
|
-
// there is no credential to vary by and no reason to re-evaluate modules.
|
|
14294
|
-
const ops = createValOps(valModules, options);
|
|
14295
|
-
return ctx => {
|
|
14296
|
-
if (ctx.auth) {
|
|
14297
|
-
// Refused rather than ignored. A host that thinks it is passing a
|
|
14298
|
-
// credential should not silently get local filesystem access instead —
|
|
14299
|
-
// and the difference matters, because fs mode writes straight to disk
|
|
14300
|
-
// with no backend permission check at all.
|
|
14301
|
-
//
|
|
14302
|
-
// A verified access token is not something the caller chose to send: it
|
|
14303
|
-
// only exists because this app advertised an authorization server, so
|
|
14304
|
-
// the developer seeing this did not do anything wrong — a config file
|
|
14305
|
-
// did, and naming it is the difference between a two-minute fix and an
|
|
14306
|
-
// afternoon.
|
|
14307
|
-
return {
|
|
14308
|
-
status: "error",
|
|
14309
|
-
result: {
|
|
14310
|
-
status: "error",
|
|
14311
|
-
code: "unsupported",
|
|
14312
|
-
message: "This Val project is running in local filesystem mode, so there is nothing to authenticate against, but it is configured with an `oauth` issuer and is therefore asking clients for an access token it cannot use. Remove the `oauth` config (or `VAL_OAUTH_ISSUER` from your local `.env`) for local development."
|
|
14313
|
-
}
|
|
14314
|
-
};
|
|
14315
|
-
}
|
|
14316
|
-
return {
|
|
14317
|
-
status: "ok",
|
|
14318
|
-
ops
|
|
14319
|
-
};
|
|
14320
|
-
};
|
|
14321
|
-
}
|
|
14322
|
-
|
|
14323
|
-
/**
|
|
14324
|
-
* One instance for every verified caller, and that is correct rather than a
|
|
14325
|
-
* shortcut: this instance authenticates with the app's own API key, so there
|
|
14326
|
-
* is nothing per-caller in it to keep apart. Who did what travels as the
|
|
14327
|
-
* patch's `authorId` instead — see `writePath`.
|
|
14328
|
-
*
|
|
14329
|
-
* One instance is also all proxy mode keeps now. It could not share while a
|
|
14330
|
-
* personal access token reached this function: each token needed its own
|
|
14331
|
-
* `ValOpsHttp` to hold it, each of those cached the project's evaluated
|
|
14332
|
-
* modules, and the bounded cache that kept that memory in check turned an
|
|
14333
|
-
* eviction into a re-evaluation of every module on the next call.
|
|
14334
|
-
*/
|
|
14335
|
-
let sharedOps = null;
|
|
14336
|
-
return ctx => {
|
|
14337
|
-
var _ctx$auth;
|
|
14338
|
-
if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
|
|
14339
|
-
return {
|
|
14340
|
-
status: "error",
|
|
14341
|
-
result: {
|
|
14342
|
-
status: "error",
|
|
14343
|
-
code: "forbidden",
|
|
14344
|
-
message: "This Val project talks to the Val content backend, so every call needs an access token from the Val authorization server. If this endpoint is not asking clients to authorize, it has no `oauth` config — give `initValMcp` one, or run the project in local filesystem mode for development."
|
|
14345
|
-
}
|
|
14346
|
-
};
|
|
14347
|
-
}
|
|
14348
|
-
if (!options.apiKey) {
|
|
14349
|
-
// Proxy mode is inferred from the api key being present, so this is
|
|
14350
|
-
// unreachable through `initHandlerOptions`. It stays because the
|
|
14351
|
-
// alternative to refusing is building ops with no credential at all.
|
|
14352
|
-
return {
|
|
14353
|
-
status: "error",
|
|
14354
|
-
result: {
|
|
14355
|
-
status: "error",
|
|
14356
|
-
code: "forbidden",
|
|
14357
|
-
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
14358
|
-
}
|
|
14359
|
-
};
|
|
14360
|
-
}
|
|
14361
|
-
if (!sharedOps) {
|
|
14362
|
-
sharedOps = createValOps(valModules, options);
|
|
14363
|
-
}
|
|
14364
|
-
return {
|
|
14365
|
-
status: "ok",
|
|
14366
|
-
ops: sharedOps
|
|
14367
|
-
};
|
|
14368
|
-
};
|
|
14369
|
-
}
|
|
14370
|
-
/**
|
|
14371
|
-
* The content as the caller should see it, loaded once per call.
|
|
14372
|
-
*
|
|
14373
|
-
* Pending patches are applied, because an agent looking at a project mid-edit
|
|
14374
|
-
* should see what the Studio would show rather than the last published state.
|
|
14375
|
-
*
|
|
14376
|
-
* Deliberately not cached across calls. In fs mode a save recomputes the base
|
|
14377
|
-
* sha within the same process, so a cached view would go stale silently — and
|
|
14378
|
-
* the cost of being wrong here is an agent writing a patch against content that
|
|
14379
|
-
* has already moved.
|
|
14380
|
-
*
|
|
14381
|
-
* Exported for `toolsFixture`, not for consumers: `tools/index.ts` re-exports by
|
|
14382
|
-
* name, so this stays inside the package. A test that assembled its own state
|
|
14383
|
-
* would be asserting against a view no real call ever sees.
|
|
14384
|
-
*/
|
|
14385
|
-
async function loadState(ops) {
|
|
14386
|
-
const patches = await ops.fetchPatches({
|
|
14387
|
-
excludePatchOps: false
|
|
14388
|
-
});
|
|
14389
|
-
// fetchPatches resolves with its failures on the result rather than rejecting,
|
|
14390
|
-
// so not checking these reads as "no pending changes" — which would quietly
|
|
14391
|
-
// hand back published content and let a write be based on it.
|
|
14392
|
-
if (patches.unauthorized) {
|
|
14393
|
-
return {
|
|
14394
|
-
status: "error",
|
|
14395
|
-
result: {
|
|
14396
|
-
status: "error",
|
|
14397
|
-
code: "forbidden",
|
|
14398
|
-
message: "Not authorized to read this project's pending changes. Check that the credential is valid and has access."
|
|
14399
|
-
}
|
|
14400
|
-
};
|
|
14401
|
-
}
|
|
14402
|
-
if (patches.networkError) {
|
|
14403
|
-
return {
|
|
14404
|
-
status: "error",
|
|
14405
|
-
result: {
|
|
14406
|
-
status: "error",
|
|
14407
|
-
code: "internal",
|
|
14408
|
-
message: "Could not reach the Val content backend."
|
|
14409
|
-
}
|
|
14410
|
-
};
|
|
14411
|
-
}
|
|
14412
|
-
if (patches.error) {
|
|
14413
|
-
return {
|
|
14414
|
-
status: "error",
|
|
14415
|
-
result: {
|
|
14416
|
-
status: "error",
|
|
14417
|
-
code: "internal",
|
|
14418
|
-
message: patches.error.message
|
|
14419
|
-
}
|
|
14420
|
-
};
|
|
14421
|
-
}
|
|
14422
|
-
const analysis = ops.analyzePatches(patches.patches);
|
|
14423
|
-
// getSourcesWithPatchesApplied, not getSources(analysis): the latter returns
|
|
14424
|
-
// only the modules that had patches, and validating that subset reports
|
|
14425
|
-
// spurious errors for anything that looks across modules, like keyOf or a
|
|
14426
|
-
// router.
|
|
14427
|
-
const sourcesRes = await ops.getSourcesWithPatchesApplied({
|
|
14428
|
-
...analysis,
|
|
14429
|
-
...patches
|
|
14430
|
-
});
|
|
14431
|
-
const [schemas, serializedSchemas] = await Promise.all([ops.getSchemas(), ops.getSerializedSchemas()]);
|
|
14432
|
-
return {
|
|
14433
|
-
status: "ok",
|
|
14434
|
-
state: {
|
|
14435
|
-
schemas,
|
|
14436
|
-
serializedSchemas,
|
|
14437
|
-
sources: sourcesRes.sources,
|
|
14438
|
-
patches,
|
|
14439
|
-
analysis,
|
|
14440
|
-
// Which modules hold a pending patch that would not apply. Carried rather
|
|
14441
|
-
// than discarded because their `sources` silently lack that change: the
|
|
14442
|
-
// content here is not what publishing would produce, so a write against
|
|
14443
|
-
// it would be based on a state that does not exist. See
|
|
14444
|
-
// `unappliedPatchesFor`.
|
|
14445
|
-
unappliedPatches: sourcesRes.errors
|
|
14446
|
-
}
|
|
14447
|
-
};
|
|
14448
|
-
}
|
|
14449
|
-
function describeZodError(error) {
|
|
14450
|
-
return error.issues.map(issue => {
|
|
14451
|
-
const path = issue.path.join(".");
|
|
14452
|
-
return path ? `${path}: ${issue.message}` : issue.message;
|
|
14453
|
-
}).join("; ");
|
|
14454
|
-
}
|
|
14455
|
-
|
|
14456
|
-
/**
|
|
14457
|
-
* Refuse a call the token was not granted, before anything is attempted.
|
|
14458
|
-
*
|
|
14459
|
-
* Derived from `readOnlyHint` rather than from a second list of tool names,
|
|
14460
|
-
* because a second list is a thing that drifts. The derivation also fails in
|
|
14461
|
-
* the safe direction: a tool that forgets the hint is treated as a write and
|
|
14462
|
-
* demands the wider scope, rather than a write slipping through as a read.
|
|
14463
|
-
*
|
|
14464
|
-
* The early return is a call carrying no verified credential, and there is no
|
|
14465
|
-
* scope to check because nothing granted one. In Val's own host that means
|
|
14466
|
-
* local filesystem mode, where a project writing a developer's own working tree
|
|
14467
|
-
* has no wider authority to withhold. A host assembling its own context can
|
|
14468
|
-
* also reach it with an unauthenticated proxy-mode call — refused a few lines
|
|
14469
|
-
* later, by `resolveOps`, for the credential rather than the scope. Every other
|
|
14470
|
-
* caller arrives as a verified profile, carrying the scopes its token was
|
|
14471
|
-
* issued with.
|
|
14472
|
-
*/
|
|
14473
|
-
function refuseInsufficientScope(tool, ctx) {
|
|
14474
|
-
var _ctx$auth2, _tool$annotations;
|
|
14475
|
-
if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
|
|
14476
|
-
return null;
|
|
14477
|
-
}
|
|
14478
|
-
// Read is needed by every call, including the writes: a tool that changes
|
|
14479
|
-
// content reads it first, and `ValToolAuth` says as much. Checking only the
|
|
14480
|
-
// wider scope would let a write-but-not-read token through here — today's
|
|
14481
|
-
// verifier refuses such a token before this point, but `createValTools` is
|
|
14482
|
-
// exported and another host may not.
|
|
14483
|
-
const needed = (_tool$annotations = tool.annotations) !== null && _tool$annotations !== void 0 && _tool$annotations.readOnlyHint ? [VAL_SCOPE_READ] : [VAL_SCOPE_READ, VAL_SCOPE_WRITE];
|
|
14484
|
-
const granted = ctx.auth.scopes;
|
|
14485
|
-
const missing = needed.filter(scope => !granted.includes(scope));
|
|
14486
|
-
if (missing.length === 0) {
|
|
14487
|
-
return null;
|
|
14488
|
-
}
|
|
14489
|
-
return {
|
|
14490
|
-
status: "error",
|
|
14491
|
-
code: "forbidden",
|
|
14492
|
-
message: `This access token does not have the ${missing.join(" and ")} scope, which ${tool.name} requires. Granted: ${granted.length > 0 ? granted.join(" ") : "(none)"}.`
|
|
14493
|
-
};
|
|
14494
|
-
}
|
|
14495
|
-
|
|
14496
|
-
const JsFileLookupMapping = [
|
|
14497
|
-
// NOTE: first one matching will be used
|
|
14498
|
-
[".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
|
|
14499
|
-
const MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100 mb
|
|
14500
|
-
const MAX_OBJECT_KEY_SIZE = 2 ** 27; // https://stackoverflow.com/questions/13367391/is-there-a-limit-on-length-of-the-key-string-in-js-object
|
|
14501
|
-
|
|
14502
|
-
class ValModuleLoader {
|
|
14503
|
-
constructor(projectRoot, compilerOptions,
|
|
14504
|
-
// TODO: remove this?
|
|
14505
|
-
sourceFileHandler, host = {
|
|
14506
|
-
...ts.sys,
|
|
14507
|
-
writeFile: (fileName, data, encoding) => {
|
|
14508
|
-
fs.mkdirSync(path__default.dirname(fileName), {
|
|
14509
|
-
recursive: true
|
|
14510
|
-
});
|
|
14511
|
-
fs.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
|
|
14512
|
-
},
|
|
14513
|
-
rmFile: fs.rmSync,
|
|
14514
|
-
readBuffer: fileName => {
|
|
14515
|
-
try {
|
|
14516
|
-
return fs.readFileSync(fileName);
|
|
14517
|
-
} catch {
|
|
14518
|
-
return undefined;
|
|
14519
|
-
}
|
|
14520
|
-
}
|
|
14521
|
-
}, disableCache = false) {
|
|
14522
|
-
this.projectRoot = projectRoot;
|
|
14523
|
-
this.compilerOptions = compilerOptions;
|
|
14524
|
-
this.sourceFileHandler = sourceFileHandler;
|
|
14525
|
-
this.host = host;
|
|
14526
|
-
this.disableCache = disableCache;
|
|
14527
|
-
this.cache = {};
|
|
14528
|
-
this.cacheSize = 0;
|
|
14529
|
-
}
|
|
14530
|
-
getModule(modulePath) {
|
|
14531
|
-
if (!modulePath) {
|
|
14532
|
-
throw Error(`Illegal module path: "${modulePath}"`);
|
|
14533
|
-
}
|
|
14534
|
-
const code = this.host.readFile(modulePath);
|
|
14535
|
-
if (!code) {
|
|
14536
|
-
throw Error(`Could not read file "${modulePath}"`);
|
|
14537
|
-
}
|
|
14538
|
-
let compiledCode;
|
|
14539
|
-
if (this.cache[code] && !this.disableCache) {
|
|
14540
|
-
// TODO: use hash instead of code as key
|
|
14541
|
-
compiledCode = this.cache[code];
|
|
14542
|
-
} else {
|
|
14543
|
-
compiledCode = transform(code, {
|
|
14544
|
-
filePath: modulePath,
|
|
14545
|
-
disableESTransforms: true,
|
|
14546
|
-
transforms: ["typescript"]
|
|
14547
|
-
}).code;
|
|
14548
|
-
if (!this.disableCache) {
|
|
14549
|
-
if (this.cacheSize > MAX_CACHE_SIZE) {
|
|
14550
|
-
console.warn("Cache size exceeded, clearing cache");
|
|
14551
|
-
this.cache = {};
|
|
14552
|
-
this.cacheSize = 0;
|
|
14553
|
-
}
|
|
14554
|
-
if (code.length < MAX_OBJECT_KEY_SIZE) {
|
|
14555
|
-
this.cache[code] = compiledCode;
|
|
14556
|
-
this.cacheSize += code.length + compiledCode.length; // code is mostly ASCII so 1 byte per char
|
|
14557
|
-
}
|
|
13307
|
+
let compiledCode;
|
|
13308
|
+
if (this.cache[code] && !this.disableCache) {
|
|
13309
|
+
// TODO: use hash instead of code as key
|
|
13310
|
+
compiledCode = this.cache[code];
|
|
13311
|
+
} else {
|
|
13312
|
+
compiledCode = transform(code, {
|
|
13313
|
+
filePath: modulePath,
|
|
13314
|
+
disableESTransforms: true,
|
|
13315
|
+
transforms: ["typescript"]
|
|
13316
|
+
}).code;
|
|
13317
|
+
if (!this.disableCache) {
|
|
13318
|
+
if (this.cacheSize > MAX_CACHE_SIZE) {
|
|
13319
|
+
console.warn("Cache size exceeded, clearing cache");
|
|
13320
|
+
this.cache = {};
|
|
13321
|
+
this.cacheSize = 0;
|
|
13322
|
+
}
|
|
13323
|
+
if (code.length < MAX_OBJECT_KEY_SIZE) {
|
|
13324
|
+
this.cache[code] = compiledCode;
|
|
13325
|
+
this.cacheSize += code.length + compiledCode.length; // code is mostly ASCII so 1 byte per char
|
|
13326
|
+
}
|
|
14558
13327
|
}
|
|
14559
13328
|
}
|
|
14560
13329
|
return compiledCode;
|
|
@@ -14703,6 +13472,72 @@ async function extractFileMetadata(filename, _input // TODO: use buffer to deter
|
|
|
14703
13472
|
};
|
|
14704
13473
|
}
|
|
14705
13474
|
|
|
13475
|
+
/**
|
|
13476
|
+
* A gallery entry's key, read as what it actually is.
|
|
13477
|
+
*
|
|
13478
|
+
* A gallery is a record keyed by its entries' file paths. A REMOTE gallery keys
|
|
13479
|
+
* an uploaded entry by the remote URL instead — which encodes that same path —
|
|
13480
|
+
* so every entry has a local path, and the interesting question is whether the
|
|
13481
|
+
* bytes are expected to be at it.
|
|
13482
|
+
*
|
|
13483
|
+
* **They are not, for a remote entry added the ordinary way.** `saveOrUploadFiles`
|
|
13484
|
+
* uploads the remote descriptors to the content host and copies only the local
|
|
13485
|
+
* ones into the working tree, so an image added through the Studio (or over MCP)
|
|
13486
|
+
* and published has no file in the repo, by design — putting one there is what
|
|
13487
|
+
* remote storage exists to avoid. A remote entry CAN have one, because
|
|
13488
|
+
* `val validate --fix` promotes a local file to a remote ref and leaves the file
|
|
13489
|
+
* where it was; so "remote" means "do not require it", never "there is not one".
|
|
13490
|
+
*
|
|
13491
|
+
* One copy, in its own file, because this used to be two: a `remoteKeyToLocalPath`
|
|
13492
|
+
* in `fixHandlers` and a `galleryKeyToLocalPath` in `createFixPatch`, each
|
|
13493
|
+
* normalising the key correctly and each then treating the result as a file that
|
|
13494
|
+
* must exist. That agreement is what made a published remote image report as
|
|
13495
|
+
* missing from both sides at once.
|
|
13496
|
+
*/
|
|
13497
|
+
|
|
13498
|
+
/**
|
|
13499
|
+
* A key that names somewhere else, whether or not it is a ref we can read.
|
|
13500
|
+
*
|
|
13501
|
+
* Case-insensitive, because a scheme is (RFC 3986) — and because
|
|
13502
|
+
* `splitRemoteRef`'s pattern is not, so `HTTPS://…` is precisely one of the
|
|
13503
|
+
* spellings that arrives here unparsed and must not be mistaken for a path.
|
|
13504
|
+
*/
|
|
13505
|
+
function isRemoteUrl(key) {
|
|
13506
|
+
return /^https?:\/\//i.test(key);
|
|
13507
|
+
}
|
|
13508
|
+
function galleryEntryOf(key) {
|
|
13509
|
+
const remoteRefRes = Internal.remote.splitRemoteRef(key);
|
|
13510
|
+
if (remoteRefRes.status === "success") {
|
|
13511
|
+
return {
|
|
13512
|
+
localPath: `/${remoteRefRes.filePath}`,
|
|
13513
|
+
remote: true
|
|
13514
|
+
};
|
|
13515
|
+
}
|
|
13516
|
+
if (isRemoteUrl(key)) {
|
|
13517
|
+
// A URL that is not a ref this version can read: truncated by a hand edit,
|
|
13518
|
+
// a path outside `public/`, a `..` segment — or written by a core version
|
|
13519
|
+
// whose format `splitRemoteRef` rejects.
|
|
13520
|
+
//
|
|
13521
|
+
// Still not a path in the working tree, so the on-disk checks must not run
|
|
13522
|
+
// against it. They would find nothing at `<projectRoot>/https://…`, report
|
|
13523
|
+
// the entry as missing, and `--fix` would DELETE it — the exact failure
|
|
13524
|
+
// this module exists to prevent, arriving at the one moment the key is the
|
|
13525
|
+
// only remaining record of where the bytes went.
|
|
13526
|
+
//
|
|
13527
|
+
// Nothing is being hidden by this: a malformed key is already reported, by
|
|
13528
|
+
// the record schema's own "Invalid remote URL format". That is an error for
|
|
13529
|
+
// a person to look at, not a file for `--fix` to go missing.
|
|
13530
|
+
return {
|
|
13531
|
+
localPath: key,
|
|
13532
|
+
remote: true
|
|
13533
|
+
};
|
|
13534
|
+
}
|
|
13535
|
+
return {
|
|
13536
|
+
localPath: key,
|
|
13537
|
+
remote: false
|
|
13538
|
+
};
|
|
13539
|
+
}
|
|
13540
|
+
|
|
14706
13541
|
/**
|
|
14707
13542
|
* The media path a validation error is about, if it is about media at all.
|
|
14708
13543
|
*
|
|
@@ -14898,14 +13733,6 @@ async function downloadFileFromRemote(ref, filePath) {
|
|
|
14898
13733
|
// record-level fix expands into per-entry errors that should point at the
|
|
14899
13734
|
// individual entry (e.g. `?p="/public/val/logo.png"`) rather than the record.
|
|
14900
13735
|
|
|
14901
|
-
// Gallery entries are keyed by their file path. Remote galleries key uploaded
|
|
14902
|
-
// entries by a remote URL while keeping the file on disk at its local path, so
|
|
14903
|
-
// normalize a remote-URL key back to that local path for on-disk reads.
|
|
14904
|
-
function galleryKeyToLocalPath(key) {
|
|
14905
|
-
const res = Internal.remote.splitRemoteRef(key);
|
|
14906
|
-
return res.status === "success" ? `/${res.filePath}` : key;
|
|
14907
|
-
}
|
|
14908
|
-
|
|
14909
13736
|
// Patch path of the record that holds a media entry. Media keys are file paths
|
|
14910
13737
|
// that can contain dots, which sourceToPatchPath cannot round-trip, so strip
|
|
14911
13738
|
// the (JSON-encoded) key segment off the entry source path and convert the
|
|
@@ -15214,7 +14041,18 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
|
|
|
15214
14041
|
}
|
|
15215
14042
|
const gallerySource = moduleSource;
|
|
15216
14043
|
for (const [entryKey, storedEntry] of Object.entries(gallerySource)) {
|
|
15217
|
-
const
|
|
14044
|
+
const entry = galleryEntryOf(entryKey);
|
|
14045
|
+
if (entry.remote) {
|
|
14046
|
+
// A remote entry's bytes are on the content host, so there may be no
|
|
14047
|
+
// local file to re-derive its metadata from — and where there is one,
|
|
14048
|
+
// rewriting the metadata from it would invalidate the ref, which has
|
|
14049
|
+
// that metadata baked into its validation hash. Whether a remote
|
|
14050
|
+
// entry is sound is `image:check-remote`'s question, and it already
|
|
14051
|
+
// asks it. Reading the file here reported every published remote
|
|
14052
|
+
// image as unreadable, and with `--fix` REMOVED it from the gallery.
|
|
14053
|
+
continue;
|
|
14054
|
+
}
|
|
14055
|
+
const filename = path__default.join(config.projectRoot, entry.localPath);
|
|
15218
14056
|
let buffer;
|
|
15219
14057
|
try {
|
|
15220
14058
|
buffer = fs.readFileSync(filename);
|
|
@@ -15880,15 +14718,48 @@ async function handleUniqueFolderCheck(ctx) {
|
|
|
15880
14718
|
};
|
|
15881
14719
|
}
|
|
15882
14720
|
|
|
15883
|
-
|
|
15884
|
-
|
|
15885
|
-
|
|
15886
|
-
|
|
15887
|
-
|
|
15888
|
-
|
|
15889
|
-
|
|
14721
|
+
/**
|
|
14722
|
+
* What is out of step between a gallery's entries and its directory.
|
|
14723
|
+
*
|
|
14724
|
+
* Two questions, and they treat a remote entry differently — which is the whole
|
|
14725
|
+
* reason this is separate from the handler around it:
|
|
14726
|
+
*
|
|
14727
|
+
* - **Missing**: an entry with no bytes at its local path. Asked of LOCAL
|
|
14728
|
+
* entries only. A remote entry's bytes live on the content host, and nothing
|
|
14729
|
+
* puts a copy in the working tree: `saveOrUploadFiles` uploads the remote
|
|
14730
|
+
* descriptors and copies only the local ones into the tree, so a remote entry
|
|
14731
|
+
* added through the Studio (or over MCP) has no local file by design, and
|
|
14732
|
+
* demanding one would mean committing remote bytes to git — which is what
|
|
14733
|
+
* remote storage exists to avoid. Whether those bytes really are on the host
|
|
14734
|
+
* is a different check, `image:check-remote`, which already runs for exactly
|
|
14735
|
+
* these entries.
|
|
14736
|
+
* - **Untracked**: a file in the directory that no entry claims. Asked of every
|
|
14737
|
+
* entry, remote included, and that is why they are normalised to their local
|
|
14738
|
+
* path: `val validate --fix` promotes a local file to a remote ref and leaves
|
|
14739
|
+
* the file where it was, so a remote entry can perfectly well have one.
|
|
14740
|
+
*/
|
|
14741
|
+
function checkGalleryFiles(input) {
|
|
14742
|
+
const {
|
|
14743
|
+
directory,
|
|
14744
|
+
projectRoot,
|
|
14745
|
+
fs
|
|
14746
|
+
} = input;
|
|
14747
|
+
const entries = input.entryKeys.map(galleryEntryOf);
|
|
14748
|
+
const trackedFiles = new Set(entries.map(entry => entry.localPath));
|
|
14749
|
+
const missingTrackedFiles = entries.filter(entry => !entry.remote && !fs.fileExists(path__default.join(projectRoot, entry.localPath))).map(entry => entry.localPath);
|
|
14750
|
+
const filesInDir = [];
|
|
14751
|
+
try {
|
|
14752
|
+
const found = fs.readDirectory(path__default.join(projectRoot, directory), undefined, undefined, ["**/*"]);
|
|
14753
|
+
for (const entry of found) {
|
|
14754
|
+
filesInDir.push("/" + path__default.relative(projectRoot, entry).split(path__default.sep).join("/"));
|
|
14755
|
+
}
|
|
14756
|
+
} catch {
|
|
14757
|
+
// directory doesn't exist — no untracked files possible
|
|
15890
14758
|
}
|
|
15891
|
-
return
|
|
14759
|
+
return {
|
|
14760
|
+
missingTrackedFiles,
|
|
14761
|
+
untrackedFiles: filesInDir.filter(f => !trackedFiles.has(f))
|
|
14762
|
+
};
|
|
15892
14763
|
}
|
|
15893
14764
|
async function handleCheckAllFiles(ctx) {
|
|
15894
14765
|
const value = ctx.validationError.value;
|
|
@@ -15908,14 +14779,14 @@ async function handleCheckAllFiles(ctx) {
|
|
|
15908
14779
|
errorMessage: `Could not get source for ${ctx.sourcePath}`
|
|
15909
14780
|
};
|
|
15910
14781
|
}
|
|
15911
|
-
|
|
15912
|
-
|
|
15913
|
-
|
|
15914
|
-
|
|
15915
|
-
|
|
15916
|
-
|
|
15917
|
-
|
|
15918
|
-
|
|
14782
|
+
const {
|
|
14783
|
+
missingTrackedFiles,
|
|
14784
|
+
untrackedFiles
|
|
14785
|
+
} = checkGalleryFiles({
|
|
14786
|
+
entryKeys: Object.keys(source),
|
|
14787
|
+
directory,
|
|
14788
|
+
projectRoot: ctx.projectRoot,
|
|
14789
|
+
fs: ctx.fs
|
|
15919
14790
|
});
|
|
15920
14791
|
if (missingTrackedFiles.length > 0) {
|
|
15921
14792
|
if (!ctx.fix) {
|
|
@@ -15930,18 +14801,6 @@ async function handleCheckAllFiles(ctx) {
|
|
|
15930
14801
|
shouldApplyPatch: true
|
|
15931
14802
|
};
|
|
15932
14803
|
}
|
|
15933
|
-
const dirPath = path__default.join(ctx.projectRoot, directory);
|
|
15934
|
-
const filesInDir = [];
|
|
15935
|
-
try {
|
|
15936
|
-
const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
|
|
15937
|
-
for (const entry of entries) {
|
|
15938
|
-
const relPath = "/" + path__default.relative(ctx.projectRoot, entry).split(path__default.sep).join("/");
|
|
15939
|
-
filesInDir.push(relPath);
|
|
15940
|
-
}
|
|
15941
|
-
} catch {
|
|
15942
|
-
// directory doesn't exist — no untracked files possible
|
|
15943
|
-
}
|
|
15944
|
-
const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
|
|
15945
14804
|
if (untrackedFiles.length > 0) {
|
|
15946
14805
|
return {
|
|
15947
14806
|
success: false,
|
|
@@ -16692,4 +15551,4 @@ function readCapturedReport(snapshotDir) {
|
|
|
16692
15551
|
return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
16693
15552
|
}
|
|
16694
15553
|
|
|
16695
|
-
export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service,
|
|
15554
|
+
export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, resolveRemoteFileAuth, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|