@dotcms/ai 26.10.20-1 → 26.10.21-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +203 -29
  2. package/adapter.cjs.default.js +1 -0
  3. package/adapter.cjs.js +14 -0
  4. package/adapter.cjs.mjs +2 -0
  5. package/adapter.cjs2.js +0 -0
  6. package/adapter.d.ts +1 -0
  7. package/adapter.esm.js +2 -0
  8. package/adapter.esm2.js +0 -0
  9. package/errors.cjs.js +192 -0
  10. package/errors.esm.js +180 -0
  11. package/format-result.cjs.js +833 -0
  12. package/format-result.esm.js +825 -0
  13. package/package.json +25 -20
  14. package/runtime.cjs.js +0 -0
  15. package/runtime.esm.js +0 -0
  16. package/sandbox.cjs.default.js +1 -0
  17. package/sandbox.cjs.js +69 -0
  18. package/sandbox.cjs.mjs +2 -0
  19. package/sandbox.d.ts +1 -0
  20. package/sandbox.esm.js +50 -0
  21. package/spec.cjs.default.js +1 -0
  22. package/spec.cjs.js +3 -23912
  23. package/spec.cjs.mjs +2 -0
  24. package/spec.cjs2.js +23916 -0
  25. package/spec.d.ts +1 -0
  26. package/spec.esm.js +1 -23914
  27. package/spec.esm2.js +23914 -0
  28. package/src/adapter/context.d.ts +11 -0
  29. package/src/adapter/request-core.d.ts +30 -0
  30. package/src/adapter.d.ts +1 -0
  31. package/src/runtime.d.ts +5 -4
  32. package/src/sandbox/errors.d.ts +23 -1
  33. package/src/sandbox/index.d.ts +1 -1
  34. package/src/sandbox.d.ts +1 -0
  35. package/src/spec.d.ts +1 -0
  36. package/src/tools/definitions/download-assets.d.ts +27 -0
  37. package/src/tools/definitions/execute.d.ts +15 -0
  38. package/src/tools/definitions/page-create.d.ts +23 -0
  39. package/src/tools/definitions/page-place-content.d.ts +32 -0
  40. package/src/tools/definitions/page-verify.d.ts +20 -0
  41. package/src/tools/definitions/search.d.ts +13 -0
  42. package/src/tools/definitions/upload-assets.d.ts +23 -0
  43. package/src/tools/index.d.ts +40 -0
  44. package/src/tools/operations/download-assets.d.ts +55 -0
  45. package/src/tools/operations/page-create.d.ts +81 -0
  46. package/src/tools/operations/page-place-content.d.ts +102 -0
  47. package/src/tools/operations/page-verify.d.ts +164 -0
  48. package/src/tools/operations/shared/asset-common.d.ts +28 -0
  49. package/src/tools/operations/shared/glob.d.ts +22 -0
  50. package/src/tools/operations/shared/local-root.d.ts +11 -0
  51. package/src/tools/operations/shared/page-common.d.ts +45 -0
  52. package/src/tools/operations/shared/page-path.d.ts +49 -0
  53. package/src/tools/operations/shared/resolve.d.ts +43 -0
  54. package/src/tools/operations/upload-assets.d.ts +64 -0
  55. package/src/tools/toolkit/connection.d.ts +56 -0
  56. package/src/tools/toolkit/create-tool.d.ts +66 -0
  57. package/src/tools/toolkit/endpoints.d.ts +52 -0
  58. package/src/tools/toolkit/lenient-boolean.d.ts +14 -0
  59. package/src/tools/toolkit/results.d.ts +32 -0
  60. package/src/tools/toolkit/tool-runtime.d.ts +108 -0
  61. package/src/tools/toolkit/types.d.ts +120 -0
  62. package/src/tools.d.ts +1 -0
  63. package/tools.cjs.default.js +1 -0
  64. package/tools.cjs.js +3850 -0
  65. package/tools.cjs.mjs +2 -0
  66. package/tools.d.ts +1 -0
  67. package/tools.esm.js +3817 -0
  68. package/index.cjs.default.js +0 -1
  69. package/index.cjs.js +0 -7
  70. package/index.cjs.mjs +0 -2
  71. package/index.d.ts +0 -1
  72. package/index.esm.js +0 -1
package/tools.cjs.js ADDED
@@ -0,0 +1,3850 @@
1
+ 'use strict';
2
+
3
+ var runtime = require('./runtime.cjs.js');
4
+ var errors = require('./errors.cjs.js');
5
+ var zod = require('zod');
6
+ require('node:worker_threads');
7
+ var formatResult = require('./format-result.cjs.js');
8
+ var adapter = require('./adapter.cjs2.js');
9
+ var fs = require('node:fs');
10
+ var promises = require('node:fs/promises');
11
+ var node_path = require('node:path');
12
+ var node_crypto = require('node:crypto');
13
+
14
+ function _interopNamespaceDefault(e) {
15
+ var n = Object.create(null);
16
+ if (e) {
17
+ Object.keys(e).forEach(function (k) {
18
+ if (k !== 'default') {
19
+ var d = Object.getOwnPropertyDescriptor(e, k);
20
+ Object.defineProperty(n, k, d.get ? d : {
21
+ enumerable: true,
22
+ get: function () { return e[k]; }
23
+ });
24
+ }
25
+ });
26
+ }
27
+ n.default = e;
28
+ return Object.freeze(n);
29
+ }
30
+
31
+ var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
32
+
33
+ /**
34
+ * Wall-clock deadline applied to every direct `dotcms.request()` a tool makes.
35
+ *
36
+ * `createRuntime`'s `timeout` bounds `run()` only; `request()` is documented as having no
37
+ * surrounding timeout of its own. Without a deadline a wedged instance hangs the tool call
38
+ * forever — the model gets no error, no result, and no way to tell the difference from slow
39
+ * work — and `TIMEOUT`, the one unambiguously retryable code, could never be produced.
40
+ */
41
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
42
+ /**
43
+ * Cap on any single error string handed back to the model.
44
+ *
45
+ * A dotCMS 5xx returns its full HTML stack-trace page, tens to hundreds of KB, and
46
+ * `HttpError.message` embeds the body verbatim. A transfer manifest keeps one message PER
47
+ * FAILED FILE, so 200 files against a broken instance would otherwise carry 200 copies of
48
+ * that page in a single tool result.
49
+ */
50
+ const MAX_ERROR_CHARS = 2_000;
51
+ /**
52
+ * The tool host is misconfigured — a deployment problem, not a bad tool call.
53
+ *
54
+ * Carries its own `code` so it lands in {@link ToolFailure} as `CONFIGURATION` rather than
55
+ * `UNKNOWN`, which is what lets the model tell "I called this wrong" from "this server
56
+ * cannot serve anyone right now".
57
+ */
58
+ class ConfigurationError extends Error {
59
+ /**
60
+ * `cause` carries the underlying error for the HOST (e.g. what a token resolver threw). It
61
+ * is never part of the message, which is what reaches the model.
62
+ */
63
+ constructor(message, options) {
64
+ super(message);
65
+ this.code = 'CONFIGURATION';
66
+ this.name = 'ConfigurationError';
67
+ if (options?.cause !== undefined) {
68
+ // Standard ES2022 `cause`, assigned defensively for older lib targets.
69
+ this.cause = options.cause;
70
+ }
71
+ }
72
+ }
73
+ /**
74
+ * Build the runtime one tool call runs against. One place owns the per-request deadline —
75
+ * so every tool constructs its runtime the same way instead of re-deriving it (and silently
76
+ * drifting on which options they set).
77
+ *
78
+ * Called once per tool invocation, deliberately: the runtime owns its context cache, so a
79
+ * fresh runtime is what lets the next call see sites, content types or languages the
80
+ * previous call created. That is also why no session id is threaded through — a runtime
81
+ * that lives for one call has nothing to key a cache on.
82
+ */
83
+ function createToolRuntime(config, opts) {
84
+ let runtime$1;
85
+ try {
86
+ runtime$1 = runtime.createRuntime({
87
+ ...config,
88
+ timeout: opts?.timeout,
89
+ includeSpec: opts?.includeSpec
90
+ });
91
+ }
92
+ catch (error) {
93
+ // `createRuntime` throws e.g. "token is required" when the host passed no token.
94
+ // Raw, that reads to a model as a problem with ITS call — and the transfer tools' own
95
+ // descriptions tell it "you do NOT need a dotCMS token, never go looking for them", so
96
+ // a host misconfiguration would push it toward exactly the credential-hunting those
97
+ // descriptions forbid. Say plainly whose problem it is.
98
+ throw new ConfigurationError(`The dotCMS tools are not configured: ${errorMessage(error)}. Whoever runs this ` +
99
+ `server or agent supplies the dotCMS URL and token, through the tools' dotCMS ` +
100
+ `connection. This is not a problem with the call and no argument can fix it — ` +
101
+ `report it and stop; do not look for credentials.`);
102
+ }
103
+ const requestTimeout = opts?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
104
+ return {
105
+ ...runtime$1,
106
+ /**
107
+ * `request` with a default deadline. A caller that passes its own signal keeps full
108
+ * control and is left alone; otherwise the call is bounded and a deadline hit is
109
+ * reported as `TIMEOUT` rather than `ABORT`, because the two mean opposite things to
110
+ * the model — a timeout is worth retrying, a caller-initiated abort is not.
111
+ */
112
+ request: (options, reqOpts) => {
113
+ if (reqOpts?.signal) {
114
+ return runtime$1.request(options, reqOpts);
115
+ }
116
+ const controller = new AbortController();
117
+ const timer = setTimeout(() => controller.abort(), requestTimeout);
118
+ return runtime$1
119
+ .request(options, { signal: controller.signal })
120
+ .catch((error) => {
121
+ if (controller.signal.aborted && error instanceof errors.AbortError) {
122
+ throw new errors.TimeoutError(`Request ${options.method ?? 'GET'} ${options.path} exceeded the ` +
123
+ `${requestTimeout}ms deadline and was aborted. The instance may be ` +
124
+ `overloaded or wedged; this is worth retrying.`, requestTimeout);
125
+ }
126
+ throw error;
127
+ })
128
+ .finally(() => clearTimeout(timer));
129
+ }
130
+ };
131
+ }
132
+ /**
133
+ * Normalize any thrown value to a message string, capped at {@link MAX_ERROR_CHARS}.
134
+ *
135
+ * The cap is the point: see MAX_ERROR_CHARS for why an uncapped `HttpError.message` is a
136
+ * real problem rather than a cosmetic one.
137
+ */
138
+ function errorMessage(error) {
139
+ const raw = error instanceof Error ? error.message : String(error);
140
+ return raw.length <= MAX_ERROR_CHARS
141
+ ? raw
142
+ : `${raw.slice(0, MAX_ERROR_CHARS)}… [truncated, ${raw.length} chars total]`;
143
+ }
144
+ /**
145
+ * Give a caught error a clearer message, and return that SAME error for the caller to throw.
146
+ *
147
+ * For an operation that explains a failure in its own terms ("the save was rejected as a
148
+ * net-loss conflict…") before rethrowing. Wrapping it in a new `Error` instead loses the
149
+ * error's class, and with it the `code`, the HTTP `status` and whether a retry can help: a 503
150
+ * worth retrying reaches the model as `UNKNOWN`, not retryable. Only a non-`Error` throw, which
151
+ * has no class to keep, becomes a plain `Error`.
152
+ */
153
+ function withMessage(error, message) {
154
+ if (error instanceof Error) {
155
+ error.message = message;
156
+ return error;
157
+ }
158
+ return new Error(message);
159
+ }
160
+ /**
161
+ * Whether re-issuing the same call could plausibly succeed.
162
+ *
163
+ * This is the single most useful thing a tool can tell the calling model, and the one it
164
+ * cannot work out for itself: a 429 on file 3 of 200 and a permanent 403 read identically
165
+ * once flattened to a message, so the model either abandons a transfer that would have
166
+ * succeeded or retries one that never can.
167
+ */
168
+ function isRetryable(error) {
169
+ // A deadline hit, or a request that never got a response (refused, reset, DNS): the
170
+ // instance may be restarting or briefly unreachable, and the same call can succeed.
171
+ if (error instanceof errors.TimeoutError || error instanceof errors.NetworkError) {
172
+ return true;
173
+ }
174
+ if (error instanceof errors.HttpError) {
175
+ // 408 Request Timeout and 429 Too Many Requests are explicitly transient; 5xx is the
176
+ // instance failing rather than the request being wrong. Every other 4xx is the call
177
+ // itself being wrong, and retrying it unchanged cannot help.
178
+ return error.status === 408 || error.status === 429 || error.status >= 500;
179
+ }
180
+ // ABORT is caller-initiated, VALIDATION/POLICY are the call being wrong, and
181
+ // SANDBOX/RUNTIME are bugs. None of them improve on a retry.
182
+ return false;
183
+ }
184
+ /** Stable machine-readable code for any thrown value. */
185
+ function errorCode(error) {
186
+ if (errors.isDotCMSError(error)) {
187
+ return error.code;
188
+ }
189
+ return error instanceof ConfigurationError ? error.code : 'UNKNOWN';
190
+ }
191
+ /**
192
+ * Build the failure a tool returns instead of throwing.
193
+ *
194
+ * Owns the `[dotCMS - <operation>]` prefix convention in one place rather than as a
195
+ * template string at each throw site, and preserves the typed detail (`code`, `status`,
196
+ * `retryable`) that flattening to `.message` used to discard.
197
+ *
198
+ * Deliberately NOT a parallel error hierarchy: `formatSandboxResult` remains the layer for
199
+ * sandbox results (`execute`/`search` already use it). This is only for failures a tool
200
+ * call raises outside a sandbox result — a direct request, invalid arguments, a host
201
+ * misconfiguration.
202
+ */
203
+ function toToolFailure(operation, error, extra) {
204
+ return {
205
+ // Caller context goes FIRST, so it can add fields but never overwrite `ok`, `code` or
206
+ // `retryable` — the fields a model and a host branch on.
207
+ ...extra,
208
+ ok: false,
209
+ operation,
210
+ error: `[dotCMS - ${operation}]: ${errorMessage(error)}`,
211
+ code: errorCode(error),
212
+ retryable: isRetryable(error),
213
+ ...(error instanceof errors.HttpError ? { status: error.status } : {})
214
+ };
215
+ }
216
+ /** Whether a tool result is a {@link ToolFailure} rather than the tool's normal result. */
217
+ function isToolFailure(value) {
218
+ return (typeof value === 'object' &&
219
+ value !== null &&
220
+ value.ok === false &&
221
+ typeof value.code === 'string');
222
+ }
223
+
224
+ /**
225
+ * Describe the dotCMS instance and identity the tools act as.
226
+ *
227
+ * ```ts
228
+ * const dotcms = dotcmsConnection({ url, token: () => secrets.get('dotcms') });
229
+ * const tools = [searchTool(dotcms), pageVerifyTool(dotcms)];
230
+ * ```
231
+ *
232
+ * Nothing is read, validated or fetched here — the values are resolved on each tool call.
233
+ */
234
+ function dotcmsConnection(config) {
235
+ return Object.freeze({
236
+ __dotcmsConnection: true,
237
+ config: Object.freeze({ ...config })
238
+ });
239
+ }
240
+ /** Whether `value` came from {@link dotcmsConnection}. */
241
+ function isDotCMSConnection(value) {
242
+ return (typeof value === 'object' &&
243
+ value !== null &&
244
+ value.__dotcmsConnection === true);
245
+ }
246
+ async function resolveValue(field, value, config) {
247
+ if (typeof value !== 'function') {
248
+ return value;
249
+ }
250
+ try {
251
+ return (await value()) ?? '';
252
+ }
253
+ catch (error) {
254
+ // The host's resolver failed (a secrets manager down, say). That is the host's problem,
255
+ // not the call's — reported as CONFIGURATION. The resolver's own error reaches the host
256
+ // only through `onResolveError`, never the message the model reads. (`cause` below
257
+ // stays inside the toolkit: the ToolFailure the tool returns does not carry it.)
258
+ try {
259
+ config.onResolveError?.(field, error);
260
+ }
261
+ catch {
262
+ // A throwing hook must not turn a readable CONFIGURATION failure into something else.
263
+ }
264
+ throw new ConfigurationError(`The dotCMS connection could not resolve its ${field}. The reason is withheld from ` +
265
+ `the tool result; whoever runs this server or agent can see it through the ` +
266
+ `connection's onResolveError hook. This is not a problem with the call and no ` +
267
+ `argument can fix it — report it and stop; do not look for credentials.`, { cause: error });
268
+ }
269
+ }
270
+ /**
271
+ * The URL and token for one call. Throws `ConfigurationError` when the tool was given no
272
+ * connection at all (a JavaScript caller, or a cast) or a resolver failed; an EMPTY value is
273
+ * left to `createToolRuntime`, which reports it the same way.
274
+ */
275
+ async function resolveConnection(connection) {
276
+ if (!isDotCMSConnection(connection)) {
277
+ throw new ConfigurationError('The dotCMS tools are not configured: the tool was created without a dotCMS ' +
278
+ 'connection. Whoever builds the tools passes one — searchTool(dotcmsConnection({ ' +
279
+ 'url, token })). This is not a problem with the call and no argument can fix it ' +
280
+ '— report it and stop; do not look for credentials.');
281
+ }
282
+ const [url, token] = await Promise.all([
283
+ resolveValue('url', connection.config.url, connection.config),
284
+ resolveValue('token', connection.config.token, connection.config)
285
+ ]);
286
+ return { url, token, config: connection.config };
287
+ }
288
+
289
+ /**
290
+ * What loading instance context reads. The runtime does it on a tool's behalf, so every tool
291
+ * that touches context includes these — they are the one endpoint list the toolkit owns.
292
+ * Every other list lives next to the operation that makes those requests.
293
+ */
294
+ const CONTEXT_ENDPOINTS = Object.values(adapter.CONTEXT_PATHS).map((path) => `GET ${path}`);
295
+ function escapeRegExp(value) {
296
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
297
+ }
298
+ function compile(endpoint) {
299
+ const space = endpoint.indexOf(' ');
300
+ const method = endpoint.slice(0, space);
301
+ const path = endpoint.slice(space + 1);
302
+ const deep = path.endsWith('/**');
303
+ const fixed = deep ? path.slice(0, -3) : path;
304
+ const body = fixed
305
+ .split(/(\{[^/}]+\})/)
306
+ .map((part) => (/^\{[^/}]+\}$/.test(part) ? '[^/]+' : escapeRegExp(part)))
307
+ .join('');
308
+ return { method, pattern: new RegExp(`^${body}${deep ? '(?:/.*)?' : ''}$`) };
309
+ }
310
+ /** Whether `req` is one of `endpoints`. */
311
+ function matchesEndpoint(endpoints, req) {
312
+ return endpoints.some((endpoint) => {
313
+ const { method, pattern } = compile(endpoint);
314
+ return method === req.method.toUpperCase() && pattern.test(req.path);
315
+ });
316
+ }
317
+ /**
318
+ * The policy for a fixed-purpose tool: exactly the endpoints it owns, and nothing else.
319
+ *
320
+ * The consumer never configures this — the tool knows what it calls. It is least privilege by
321
+ * construction: a bug in `page_verify`, or a page path shaped to reach somewhere else, cannot
322
+ * get past the render and site-lookup endpoints the tool actually needs.
323
+ */
324
+ function toolPolicy(toolName, endpoints, hint) {
325
+ return (req) => {
326
+ if (matchesEndpoint(endpoints, req)) {
327
+ return true;
328
+ }
329
+ // Thrown rather than returned `false`, so the model reads WHY instead of a bare
330
+ // "rejected by policy" — the request core turns this message into the PolicyError.
331
+ throw new Error(`The ${toolName} tool can only reach the endpoints it owns, and ${req.method} ` +
332
+ `${req.path} is not one of them.${hint ? ` ${hint}` : ''}`);
333
+ };
334
+ }
335
+ /**
336
+ * The policy for `execute`, where the MODEL chooses the endpoints: the consumer's `allow`
337
+ * bounds its code, plus the context reads the runtime makes on the tool's behalf — so a narrow
338
+ * `allow` never silently empties the `sites` / `contentTypes` globals the code relies on.
339
+ * Those reads expose nothing the code does not already hold as globals.
340
+ *
341
+ * No `allow` means no policy: the model's code may call anything the token permits.
342
+ */
343
+ function modelChosenPolicy(allow) {
344
+ const consumer = adapter.toRequestPolicy(allow);
345
+ if (!consumer) {
346
+ return undefined;
347
+ }
348
+ return (req) => matchesEndpoint(CONTEXT_ENDPOINTS, req) || consumer(req);
349
+ }
350
+
351
+ /**
352
+ * How a tool shows one of its results to the model — the one place that is decided.
353
+ *
354
+ * `toText` is the TOOL's declaration that it has a text form, typed against its own result:
355
+ * nothing is inferred from the value's shape, so a result can never be mistaken for text
356
+ * because it happens to look like one. A tool without it is shown as structured JSON, and so
357
+ * is a failure from any tool.
358
+ */
359
+ function renderToolOutput(toText, output) {
360
+ if (toText && !isToolFailure(output)) {
361
+ return { type: 'text', value: toText(output) };
362
+ }
363
+ return { type: 'json', value: toJSONValue(output) };
364
+ }
365
+ /**
366
+ * A value as the JSON it serializes to — what every transport sends and the model receives.
367
+ * A `Date` becomes its ISO string and an `undefined` field drops out, here rather than out of
368
+ * sight in the transport. Results are small plain-data manifests, so the round trip is cheap.
369
+ */
370
+ function toJSONValue(value) {
371
+ const text = JSON.stringify(value);
372
+ return text === undefined ? null : JSON.parse(text);
373
+ }
374
+ /** A model output as the text a text-only transport (an MCP `content` block) carries. */
375
+ function modelOutputText(output) {
376
+ return output.type === 'text' ? output.value : JSON.stringify(output.value, null, 2);
377
+ }
378
+
379
+ /** Identity helper that infers the generics, so each handler sees its own input type. */
380
+ function defineTool(definition) {
381
+ return definition;
382
+ }
383
+ /**
384
+ * Turn a definition, a connection and a consumer's options into the tool a framework
385
+ * registers.
386
+ *
387
+ * Cheap and side-effect free: nothing is resolved or fetched here. The connection is resolved
388
+ * inside `execute`, on every call — so a host started without credentials still boots and
389
+ * lists its tools, and each call reports the `CONFIGURATION` problem instead of the host
390
+ * crashing at startup.
391
+ *
392
+ * The one thing checked here is the consumer's own static `allow` list (`executeTool`), which
393
+ * becomes the tool's policy now: an entry that is not a path throws a `ValidationError`, so a
394
+ * malformed security boundary fails at startup instead of opening the API (see
395
+ * `toRequestPolicy`).
396
+ */
397
+ function createTool(definition, connection, options) {
398
+ // The tool owns its endpoints; only `execute`'s are the model's to choose and the
399
+ // consumer's to bound.
400
+ const policy = definition.endpoints === 'model-chosen'
401
+ ? modelChosenPolicy(options.allow)
402
+ : toolPolicy(definition.name, definition.endpoints, definition.endpointHint);
403
+ // One rendering, two views of it: `toModelOutput` for frameworks that take structured model
404
+ // output (the AI SDK), `toText` for text-only transports (MCP). They cannot disagree.
405
+ const render = (output) => renderToolOutput(definition.toText, output);
406
+ return {
407
+ name: definition.name,
408
+ title: definition.title,
409
+ description: definition.description,
410
+ inputSchema: definition.inputSchema,
411
+ annotations: definition.annotations,
412
+ toModelOutput: ({ output }) => render(output),
413
+ toText: (output) => modelOutputText(render(output)),
414
+ async execute(input) {
415
+ // The trust boundary, same as `defineAdapter`'s `input`: AI SDK and MCP validate
416
+ // before calling, but a hand-rolled agent loop may not, and a handler must never
417
+ // see input its schema did not accept. Re-parsing already-valid input is a no-op.
418
+ const parsed = definition.inputSchema.safeParse(input);
419
+ if (!parsed.success) {
420
+ return toToolFailure(definition.name, new errors.ValidationError(`Invalid arguments for "${definition.name}":\n${zod.z.prettifyError(parsed.error)}`, parsed.error.issues));
421
+ }
422
+ // A throw that escapes here reaches an MCP client as a PROTOCOL error, or an agent
423
+ // loop as an exception, rather than as a result the model can read and act on.
424
+ try {
425
+ const { url, token, config } = await resolveConnection(connection);
426
+ const ctx = {
427
+ options,
428
+ runtime: (runtimeOptions) => createToolRuntime({
429
+ url,
430
+ token,
431
+ allow: policy,
432
+ onCall: config.onCall,
433
+ onContextError: config.onContextError,
434
+ includeStacks: options.includeStacks
435
+ }, { ...runtimeOptions, requestTimeout: options.requestTimeout })
436
+ };
437
+ return await definition.handler(parsed.data, ctx);
438
+ }
439
+ catch (error) {
440
+ return toToolFailure(definition.name, error);
441
+ }
442
+ }
443
+ };
444
+ }
445
+
446
+ /** Sandbox wall-clock timeout for `execute` when the factory is given none. */
447
+ const DEFAULT_EXECUTE_TIMEOUT_MS = 45_000;
448
+ const input$1 = zod.z.object({
449
+ code: zod.z
450
+ .string()
451
+ .max(100_000)
452
+ .describe('JavaScript async function body to execute against the dotCMS API. Use `api.request({ method, path, query, body, formData })` to make authenticated API calls. Use `formData` instead of `body` for multipart/form-data uploads. Return the result.')
453
+ });
454
+ /**
455
+ * The `execute` tool: the model writes JavaScript, and it runs in the sandbox against the
456
+ * dotCMS API. Resolves to `{ result }`, the capped text of the code's result (or its error).
457
+ */
458
+ function executeTool(connection, options = {}) {
459
+ return createTool(definition$6, connection, options);
460
+ }
461
+ const definition$6 = defineTool({
462
+ name: 'execute',
463
+ title: 'Execute dotCMS API Call',
464
+ inputSchema: input$1,
465
+ description: `Execute code against the dotCMS REST API. Write JavaScript that runs in an isolated sandbox with the \`api\` adapter for making authenticated HTTP requests.
466
+
467
+ Use api.request(options) where options is:
468
+ - method: HTTP method (GET, POST, PUT, DELETE) — default: GET
469
+ - path: API path (e.g., "/api/v1/content")
470
+ - query: Query parameters object
471
+ - body: Request body (auto-serialized to JSON)
472
+ - formData: Multipart form data object for file uploads (mutually exclusive with body).
473
+ String values become text fields. Object values { name, type, data|url } become file fields.
474
+ - headers: Additional headers
475
+
476
+ Auth is handled automatically — tokens are never exposed to your code.
477
+
478
+ This is a **JavaScript sandbox, NOT Velocity/VTL**:
479
+ - Velocity variables like \`$dotcontent\`, \`$dotcontent.pull(...)\`, \`$date\`, \`#foreach\` do NOT exist here — referencing \`$dotcontent\` throws \`ReferenceError\`. To query content, call \`api.request({ method: 'POST', path: '/api/content/_search', body: { query, ... } })\`. Run VTL only via \`POST /api/vtl/dynamic\`.
480
+ - **\`await\` every \`api.request\`** and return only JSON-serializable values (objects, arrays, strings, numbers). Returning an un-awaited Promise (or a function/class instance) throws \`DataCloneError\` — the result is structured-cloned out of the worker.
481
+ - Watch string literals: a raw apostrophe inside a single-quoted JS string (e.g. \`'grandchild's'\`) is a \`SyntaxError\`. Use double quotes or escape it.
482
+
483
+ Pre-loaded instance context (available as globals — no API calls needed to read these):
484
+ - contentTypes: Array<{ id, name, variable, baseType, host?, folder? }>
485
+ - sites: Array<{ identifier, hostname, isDefault, archived, live }> — all accessible non-system
486
+ sites, including stopped and archived states
487
+ - languages: Array<{ id, languageCode, countryCode, language, country, isoCode }>
488
+ - currentUser: { userId, email, givenName?, surname?, admin, roles? } | null
489
+ Examples:
490
+ const blog = contentTypes.find(c => c.variable === 'Blog');
491
+ const defaultSite = sites.find(s => s.isDefault);
492
+ const en = languages.find(l => l.languageCode === 'en');
493
+
494
+ The pre-loaded globals are a snapshot taken at the start of one tool invocation and do not mutate
495
+ halfway through the current script. Each tool invocation constructs fresh runtime context, so a
496
+ subsequent call sees successful changes to sites, content types, or languages without maintaining
497
+ resource-specific invalidation rules.
498
+
499
+ Do not use \`PUT /api/v1/site/switch/{id}\` as a targeting mechanism. Tool API requests are
500
+ independent and session-scoped site selection is not guaranteed to carry to the next request.
501
+ Pass explicit site/host identifiers (for example \`host_id\` or \`contentHost\`) instead.
502
+
503
+ Always use the \`search\` tool first to discover the correct endpoint path and request/response schema before calling \`execute\`.
504
+
505
+ Transferring file assets? Do NOT use this tool. Use the dedicated \`upload_assets\` /
506
+ \`download_assets\` tools instead — they stream file bytes between disk and dotCMS on the
507
+ server side, so the content never enters your context (and you never need a token or \`.env\`).
508
+ Reach for \`execute\` only for JSON/API work; inlining base64 file bytes here just bloats your
509
+ context. The \`formData\`/base64 path below exists only for small, programmatic payloads — not
510
+ for transferring real files, themes, or directories.
511
+
512
+ Tips:
513
+ - Output is hard-capped (~25k chars). Use \`pick(arr, fields)\` / \`first(arr, n)\` to return only the fields you need — responses can be very large and are truncated past the cap.
514
+ - For a small programmatic upload (NOT real files — use \`upload_assets\` for those) use \`formData\` with \`{ name, type, data }\` (base64) or \`{ name, type, url }\` (remote URL)
515
+
516
+ Binary responses (small/programmatic reads only — for real files use \`download_assets\`):
517
+ - Endpoints that return non-text bodies (e.g. GET \`/api/v2/assets/{identifier}\` and \`/dA/{id}\`, content-type \`application/octet-stream\` or \`image/*\`) come back as an envelope: \`{ __dotcmsBinary: true, contentType, base64, byteLength }\`.
518
+ - The \`base64\` field IS the raw file bytes — base64-decode it to recover the exact file. Do NOT treat it as text; the bytes are intact (not UTF-8-mangled).
519
+ - JSON and textual responses (\`text/*\`, xml, js, \`+json\`/\`+xml\`) are returned as parsed objects / strings as before — only binary bodies use the envelope.
520
+
521
+ Content field variables (the \`contentlet\` fire body):
522
+ - The fire body's \`contentlet\` is keyed by each field's exact **field variable** (from the content type's \`fields[].variable\`) — casing is significant and a wrong-case key is silently ignored (its value is dropped, and a required field then 400s as "required").
523
+ - Page (htmlpageasset) system fields are lowercase/camel exactly: \`contentHost\` (the SITE — NOT \`host\`), \`hostFolder\` (the folder id), \`cachettl\` (all lowercase — NOT \`cacheTTL\`/\`cacheTtl\`), \`template\`, \`url\`, \`title\`, \`friendlyName\`, \`pageTitle\`. \`contentHost\` must be a site **identifier UUID**, not a hostname. Prefer the \`page_create\` tool, which sets all of these correctly.
524
+ - For any content type, read the real field variables first: \`GET /api/v1/contenttype/id/{idOrVar}\` → \`entity.fields[].variable\`. Don't guess.
525
+
526
+ Block Editor (Story Block) fields:
527
+ - A Story Block field stores a string. When creating or updating content via a fire endpoint, send the field value as an **HTML or Markdown string** — do NOT hand-author the ProseMirror/JSON document. The server converts it to the Block Editor structure **on save**, so the field immediately reads back as structured content.
528
+ - Example: \`{ "contentType": "Blog", "title": "My Post", "body": "<h2>Intro</h2><p>Hello <strong>world</strong>.</p>" }\` — where \`body\` is the Story Block field.
529
+ - Rich blocks are expressed in Markdown as fenced code blocks: the info string is a \`dotcms-*\` label, the body is one JSON object. All keys are lowercase; **bold** keys are required (a payload missing them, or with a wrong-typed required key, silently degrades to a plain code block):
530
+ - \`dotcms-content\` — embedded contentlet: \`{"identifier": "<contentlet-id>", "languageId": 1}\`. Required: **\`identifier\`**; optional \`languageId\` (default 1). The server rehydrates the full embed data from the identifier on read.
531
+ - \`dotcms-image\` — dotCMS-bound/decorated image. Required: one of **\`identifier\`** or **\`src\`**; optional \`alt\`, \`title\`, \`href\`, \`target\`, \`textWrap\`, \`textAlign\`, \`languageId\`. Plain external images need no fence — \`![alt](url "title")\` works.
532
+ - \`dotcms-video\` — Required: one of **\`identifier\`** or **\`src\`**; optional \`mimeType\`, \`width\`, \`height\`, \`languageId\`.
533
+ - \`dotcms-youtube\` — Required: **\`src\`**; optional \`start\` (seconds), \`width\`, \`height\`.
534
+ - \`dotcms-grid\` — verbatim \`gridBlock\` node JSON: \`{"type":"gridBlock","attrs":{"columns":[n,n]},"content":[<exactly two gridColumn nodes>]}\`.
535
+ - \`dotcms-node\` — any other node type verbatim: \`{"type": "<nodeType>", ...}\` (fallback for custom blocks).
536
+ - In HTML, the same labels are custom elements. Scalar payloads ride as attributes with **hyphenated names** (HTML lowercases attribute names, so \`languageId\` is spelled \`language-id\`, \`mimeType\` is \`mime-type\`, etc.): \`<dotcms-content identifier="<contentlet-id>" language-id="1"></dotcms-content>\`. \`dotcms-grid\` and \`dotcms-node\` take the same JSON object as the element's **text body** instead: \`<dotcms-grid>{"type":"gridBlock",...}</dotcms-grid>\` (HTML-escape \`<\` and \`&\` inside JSON string values). Always write an explicit closing tag — HTML parsing ignores the \`/\` in \`<dotcms-video ... />\` and would swallow the content after it.
537
+ - Block styling (e.g. alignment) is an HTML comment on its own line immediately before the block, in Markdown and HTML alike: \`<!-- dotcms:attrs {"textAlign":"center"} -->\`.
538
+ - A Markdown/HTML write **fully replaces** the stored document. If the stored document has rich blocks your value does not carry, the save still succeeds (200) and the response \`messages\` field lists the replaced blocks — read \`messages\` after every fire and carry rich blocks over as \`dotcms-*\` fences (Markdown) or \`<dotcms-*>\` elements (HTML) to preserve them. Invalid payloads degrade to plain code blocks (or are dropped when there is no body to keep), never errors.
539
+ - Identify Story Block fields from the content type (\`fields[].clazz\` is \`...ImmutableStoryBlockField\`).
540
+
541
+ Workflow fires and Elasticsearch (indexPolicy):
542
+ - All \`/fire\` and \`/firemultipart\` endpoints (e.g. \`/api/v1/workflow/actions/default/fire/PUBLISH\`)
543
+ accept an \`indexPolicy\` query parameter controlling when Elasticsearch reflects the change:
544
+ - \`DEFER\` — default; returns immediately, index may lag by seconds
545
+ - \`WAIT_FOR\` — waits until the document is indexed before responding
546
+ - \`FORCE\` — forces an immediate index flush; expensive, avoid in production
547
+
548
+ - Use \`WAIT_FOR\` on **every** fire call when:
549
+ - Chaining multiple workflow actions on the same contentlet, or
550
+ - Reading state immediately after firing (via \`api.request\`, \`/api/content/_search\`, or GET by inode)
551
+ Without it, follow-up reads may return stale data.
552
+
553
+ - Use \`DEFER\` for isolated, one-off fires where nothing depends on immediate index visibility.
554
+ - Reserve \`FORCE\` for debugging and testing only — it is heavy on the cluster.
555
+
556
+ Velocity \`$dotcontent.pull\` sorting:
557
+ - Pass content field variables in canonical unsuffixed form, e.g. \`Book.title asc\`. The search
558
+ layer selects the keyword mapping by appending \`_dotraw\` internally.
559
+ - Already-suffixed input such as \`Book.title_dotraw asc\` is accepted for compatibility and is
560
+ normalized without producing \`_dotraw_dotraw\`.
561
+
562
+ Workflow action discovery (when you need a workflow action ID):
563
+ - The 'fire' endpoints that take \`{actionId}\` in the path (e.g. PUT /api/v1/workflow/actions/{actionId}/fire and bulk fire) require a workflow action **UUID**, not the system action enum (NEW, EDIT, PUBLISH, …).
564
+ - To find a UUID, call GET /api/v1/workflow/contentlet/{inode}/actions — returns actions firable on that contentlet right now.
565
+ - Without an inode, GET /api/v1/workflow/contenttypes/{contentTypeVarOrId}/system/actions returns the system→action mapping for a content type.
566
+ - To verify an action wires a Move actionlet (required before \`pathToMove\` does anything): GET /api/v1/workflow/actions/{actionId}/actionlets.
567
+ - The /actions/{actionId}/fire endpoint bypasses scheme checks; use it for System Workflow actions (like Move) on content from custom schemes. The /contentlet/actions/bulk/fire endpoint enforces scheme association — input contentlets whose scheme does not own the supplied action are skipped (\`skippedCount\` populated, \`skipReason\` explains).
568
+
569
+ Helper utilities available: pick(arr, fields), table(arr), count(arr, field), sum(arr, field), first(arr, n)`,
570
+ annotations: {
571
+ readOnlyHint: false,
572
+ destructiveHint: true,
573
+ idempotentHint: false,
574
+ openWorldHint: true
575
+ },
576
+ // The model picks the endpoints here, so the consumer's `allow` bounds them.
577
+ endpoints: 'model-chosen',
578
+ // The result IS text (the capped output of the model's code): shown to the model as-is.
579
+ toText: ({ result }) => result,
580
+ async handler({ code }, ctx) {
581
+ // The front door absorbs the executor + adapter + context-cache wiring and injects
582
+ // dotCMS instance context automatically. Auth tokens never enter the sandbox.
583
+ const dotcms = ctx.runtime({ timeout: ctx.options.timeout ?? DEFAULT_EXECUTE_TIMEOUT_MS });
584
+ const result = await dotcms.run(code); // code === the model's output
585
+ return {
586
+ result: formatResult.formatSandboxResult(result, {
587
+ truncationHint: 'Return only the fields you need — use pick(arr, fields) and first(arr, n).'
588
+ })
589
+ };
590
+ }
591
+ });
592
+
593
+ /**
594
+ * Search only reads the bundled spec plus the instance context, so it gets a short leash —
595
+ * a query that runs longer than this is walking far more of the spec than it should.
596
+ */
597
+ const SEARCH_TIMEOUT_MS = 10_000;
598
+ const input = zod.z.object({
599
+ code: zod.z
600
+ .string()
601
+ .max(100_000)
602
+ .describe('JavaScript async function body. The `spec` global contains the filtered dotCMS OpenAPI spec (`$ref`-based: `spec.paths` + `spec.components.schemas`). Return the data you need.')
603
+ });
604
+ /**
605
+ * The `search` tool: the model writes JavaScript that explores the bundled OpenAPI spec.
606
+ * Read-only. Resolves to `{ result }`, the capped text of the code's result (or its error).
607
+ */
608
+ function searchTool(connection) {
609
+ return createTool(definition$5, connection, {});
610
+ }
611
+ const definition$5 = defineTool({
612
+ name: 'search',
613
+ title: 'Search dotCMS API Spec',
614
+ inputSchema: input,
615
+ description: `Explore the dotCMS REST API specification. Write JavaScript that runs in a sandbox with the \`spec\` global.
616
+
617
+ Spec structure:
618
+ - \`spec.paths\` — object keyed by path string (e.g. "/api/v1/contenttype")
619
+ - Method keys are lowercase: get, post, put, delete
620
+ - Each operation has: summary, parameters, requestBody, responses
621
+ - \`requestBody.content\` / \`responses[status].content\` are keyed by MIME type (e.g. "application/json"), then \`.schema\` for the body shape — \`.schema\` is usually a \`$ref\` like \`{ $ref: '#/components/schemas/PageView' }\`, NOT an inline object
622
+ - \`parameters\` is an array of { name, in, required, schema } — "in" is "query", "path", or "header"
623
+ - \`responses\` keys are HTTP status codes
624
+ - \`spec.components.schemas\` — every schema referenced by the kept endpoints, keyed by name
625
+
626
+ Resolving \`$ref\`s: call \`resolveRef(schemaOrName, depth = 2)\` — it resolves a \`$ref\` (or a schema name) against \`spec.components.schemas\`, expanding nested refs \`depth\` levels and leaving deeper ones as \`$ref\` strings for a follow-up query. Use it instead of hand-walking refs.
627
+
628
+ Helpers available here: \`resolveRef(schemaOrName, depth)\`, \`pick(arr, fields)\`, \`table(arr)\`, \`count(arr, field)\`, \`sum(arr, field)\`, \`first(arr, n)\`.
629
+
630
+ Output is hard-capped (~25k chars). Return only what you need — resolve one schema at a bounded depth, not the whole spec.
631
+
632
+ This spec is a CURATED allow-list of the supported authoring endpoints — not every dotCMS endpoint is present, and that is deliberate. Treat it as the set of endpoints you should use. So:
633
+ - **Guard path access:** \`spec.paths['/x']\` may be \`undefined\`, and \`spec.paths['/x'].get\` on a missing path throws \`TypeError: Cannot read properties of undefined\`. Always use optional chaining: \`spec.paths['/x']?.get\`, or check \`if (!spec.paths['/x']) return 'not in spec'\` first.
634
+ - **Discover before assuming:** to find a path, filter the keys — \`Object.keys(spec.paths).filter(p => p.includes('template'))\` — rather than guessing an exact string.
635
+ - **Absent usually means "not the intended path."** If an endpoint isn't here, first look for a supported one that does the job (search the keys; a different path or verb often covers it). Reaching for an off-list endpoint should be a last resort for a genuine gap, not a reflex — the curated set is what these tools are designed and tested around.
636
+
637
+ Pre-loaded instance context (also available as globals here):
638
+ - contentTypes, sites, languages, currentUser
639
+ Use these to cross-reference spec endpoints with what the connected instance actually has.
640
+
641
+ Examples:
642
+ // endpoint summary + param names
643
+ const op = spec.paths['/api/v1/contenttype'].get
644
+ return { summary: op.summary, params: op.parameters?.map(p => p.name) }
645
+
646
+ // the request-body schema of an endpoint, one level deep
647
+ return resolveRef(spec.paths['/api/v1/contenttype'].post.requestBody.content['application/json'].schema, 1)
648
+
649
+ // a named schema, two levels deep
650
+ return resolveRef('ContentType', 2)
651
+
652
+ When inspecting workflow \`fire\` operations, always check the \`indexPolicy\` query parameter and its allowed values. When the \`execute\` tool needs to chain multiple fire calls, or fire and then immediately read content, use \`indexPolicy=WAIT_FOR\` to ensure the index is updated before the next operation runs.
653
+
654
+ Common recipes:
655
+ - **Find a workflow action ID for a contentlet:** call GET /api/v1/workflow/contentlet/{inode}/actions — direct lookup of actions firable on this contentlet right now. The \`workflowActionId\` returned here is the UUID required by PUT /api/v1/workflow/actions/{actionId}/fire and PUT /api/v1/workflow/contentlet/actions/bulk/fire. **Do not** pass system-action enum values (NEW, EDIT, PUBLISH, …) as the action ID; those are only valid for the /default/fire/{systemAction} endpoints.
656
+ - **Find actions for a content type when you don't have an inode:** GET /api/v1/workflow/contenttypes/{contentTypeVarOrId}/system/actions.
657
+ - **Verify what an action actually does (e.g. whether it has a Move actionlet wired):** GET /api/v1/workflow/actions/{actionId}/actionlets.
658
+ - **Move content:** the system action enum has no MOVE; \`pathToMove\` only works on actions with the Move actionlet. Discover the right action via the recipes above, then PUT /api/v1/workflow/actions/{actionId}/fire with \`pathToMove\` in the body, or use bulk fire with \`additionalParams.additionalParamsMap._path_to_move\`.`,
659
+ annotations: {
660
+ readOnlyHint: true,
661
+ destructiveHint: false,
662
+ idempotentHint: true,
663
+ openWorldHint: false
664
+ },
665
+ // The sandbox hands the model's code the same `api` adapter `execute` gets, so without
666
+ // this a "read-only" search could write. It may read the context it is given, nothing more.
667
+ endpoints: CONTEXT_ENDPOINTS,
668
+ // The result IS text (the capped output of the model's code): shown to the model as-is.
669
+ toText: ({ result }) => result,
670
+ endpointHint: 'search is read-only: its code explores the bundled `spec` and the pre-loaded context. ' +
671
+ 'Use the `execute` tool to call the dotCMS API.',
672
+ async handler({ code }, ctx) {
673
+ // Dynamic, like the runtime's own `includeSpec` load: the ~400KB spec is only pulled
674
+ // in by a host that actually calls search, not by every importer of the tools.
675
+ const { getSpec } = await Promise.resolve().then(function () { return require('./spec.cjs2.js'); });
676
+ const spec = getSpec();
677
+ if (!spec || typeof spec !== 'object' || Object.keys(spec).length === 0) {
678
+ return {
679
+ result: 'Error: OpenAPI spec is not available. The server may not have been built with a generated spec (run the generate-spec step), so the search tool cannot run.'
680
+ };
681
+ }
682
+ // The front door injects the instance context AND the `spec` global (includeSpec).
683
+ const dotcms = ctx.runtime({ timeout: SEARCH_TIMEOUT_MS, includeSpec: true });
684
+ const result = await dotcms.run(code);
685
+ return {
686
+ result: formatResult.formatSandboxResult(result, {
687
+ truncationHint: 'Use resolveRef(schemaOrName, depth) to expand one schema at a bounded depth.'
688
+ })
689
+ };
690
+ }
691
+ });
692
+
693
+ /**
694
+ * Whether a contentlet is published.
695
+ *
696
+ * THROWS on a failed read, and that is the contract callers depend on rather than an
697
+ * oversight. The two previous copies disagreed here: one swallowed every error into `false`,
698
+ * which conflates "this is not live" with "we could not find out" — and for the transfer
699
+ * manifest those are opposite conclusions, since a read failure reported as not-live sends
700
+ * the caller off to re-publish assets that were already fine. `upload-assets` needs the
701
+ * distinction, so the shared primitive is the honest one and the caller that wants a
702
+ * best-effort answer catches for itself.
703
+ */
704
+ async function isContentLive(dotcms, identifier) {
705
+ const response = (await dotcms.request({
706
+ path: `/api/v1/content/${encodeURIComponent(identifier)}`,
707
+ query: { depth: 0 }
708
+ }));
709
+ const entity = response.entity;
710
+ // A fire response wraps the contentlet under `contentlets[0]`; a plain read does not.
711
+ const contentlet = entity?.contentlets?.[0] || entity;
712
+ return contentlet?.live === true;
713
+ }
714
+
715
+ /**
716
+ * Normalization for the page paths callers hand to the page tools.
717
+ *
718
+ * Shared because all three tools build a request URL by interpolating a caller-supplied path
719
+ * into an endpoint template — `/api/v1/page/render${uri}`, `/api/v1/page/json${uri}` — and
720
+ * only `page_create` was normalizing first. The two that were not had a live correctness bug
721
+ * and a latent security one:
722
+ *
723
+ * - Live today: `page_verify({ path: '/a/../b' })` renders `/b` while the manifest reports
724
+ * `/a/../b`, and a `#` silently truncates the path. The tool's whole job is to report on
725
+ * the page it actually checked, so reporting a different path than it read is a
726
+ * correctness failure, not a cosmetic one.
727
+ * - Latent: `requestCore` policy-checks the RAW request string and normalizes afterwards,
728
+ * so a path like `/../../../../api/v1/users/current` would pass an `/api/v1/page/` prefix
729
+ * allowlist and then resolve somewhere else entirely. No `allow` policy is configured
730
+ * today, which is the only reason this is not already exploitable — and adding one is the
731
+ * natural next hardening step for this tool surface.
732
+ */
733
+ /** The percent-decoded segments of a page path, with encoded separators refused. */
734
+ function decodeSegments(pathname, original, label) {
735
+ const segments = pathname
736
+ .split('/')
737
+ .filter(Boolean)
738
+ .map((segment) => decodeSegment(segment, original, label));
739
+ // Splitting first does not make an encoded slash inert: every caller rebuilds a path by
740
+ // joining these segments, which turns it straight back into a path boundary. dotCMS
741
+ // folder names cannot contain a separator anyway, so nothing legitimate is refused.
742
+ const smuggled = segments.find((segment) => segment.includes('/'));
743
+ if (smuggled !== undefined) {
744
+ throw new errors.ValidationError(`${label} segment "${smuggled}" contains an encoded path separator (%2F), which ` +
745
+ `would silently resolve to a DIFFERENT path than the one named: "${original}" ` +
746
+ `would behave as if the slash had been written literally. dotCMS folder names ` +
747
+ `cannot contain "/", so write the path out plainly instead.`);
748
+ }
749
+ return segments;
750
+ }
751
+ /**
752
+ * Percent-decode one segment. A malformed escape (`%zz`, a lone `%`) is the caller's input
753
+ * being wrong, so it is reported as that rather than as the bare `URIError` it throws.
754
+ */
755
+ function decodeSegment(segment, original, label) {
756
+ try {
757
+ return decodeURIComponent(segment);
758
+ }
759
+ catch {
760
+ throw new errors.ValidationError(`${label} has a malformed percent-encoding in segment "${segment}": "${original}"`);
761
+ }
762
+ }
763
+ /** Run a path through the URL API, collapsing `.`/`..` and dropping any query or fragment. */
764
+ function toPathname(trimmed, original, label) {
765
+ // Collapse a leading `//` FIRST. To the URL API a `//` prefix is scheme-relative, so it
766
+ // reads the next segment as a HOST: `new URL('//books/index', 'http://_').pathname` is
767
+ // `/index`, silently discarding `books` — a page path resolving to a different page with
768
+ // nothing to indicate it. (`///` does not even parse.) Callers reach this legitimately,
769
+ // since `//host/path` is how dotCMS writes a host-qualified path elsewhere.
770
+ const withoutSchemeRelative = trimmed.replace(/^\/+/, '/');
771
+ try {
772
+ // The base is a throwaway — only `pathname` is read back out, so the host never leaks
773
+ // into the result.
774
+ return new URL(withoutSchemeRelative, 'http://_').pathname;
775
+ }
776
+ catch {
777
+ throw new errors.ValidationError(`${label} is not a valid path: "${original}"`);
778
+ }
779
+ }
780
+ /**
781
+ * The canonical form of a page path: leading slash, `.`/`..` collapsed, query and fragment
782
+ * dropped, percent-encoding decoded, encoded separators refused.
783
+ *
784
+ * Returned so the CALLER can report what it actually requested. A tool that normalizes for
785
+ * the request but echoes the raw input in its manifest is telling the model about a page it
786
+ * did not look at.
787
+ */
788
+ function normalizePagePath(path, label = 'path') {
789
+ const trimmed = path.trim();
790
+ if (!trimmed) {
791
+ throw new errors.ValidationError(`${label} must not be empty.`);
792
+ }
793
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
794
+ const segments = decodeSegments(toPathname(withSlash, path, label), path, label);
795
+ if (segments.length === 0) {
796
+ return '/';
797
+ }
798
+ // Re-encode each segment so the result is a valid URL path again: decoding happened only
799
+ // to inspect the segments, and a space or `#` left raw here would break the request URL
800
+ // this feeds. `encodeURIComponent` escapes `/` too, which is exactly right — a slash
801
+ // inside a single segment is the case refused above.
802
+ return `/${segments.map((segment) => encodeURIComponent(segment)).join('/')}`;
803
+ }
804
+ /**
805
+ * Split a page-relative URL into the parent folder and the leaf url stored on the page.
806
+ *
807
+ * "/books/index" → { folder: "/books", url: "index", fullPath: "/books/index" }
808
+ * "/books" → { folder: "/books", url: "index", fullPath: "/books/index" }
809
+ * "/about-us/" → { folder: "/about-us", url: "index", fullPath: "/about-us/index" }
810
+ * "/" → { folder: "/", url: "index", fullPath: "/index" }
811
+ *
812
+ * Shares its normalization with {@link normalizePagePath} but NOT its output: the folder-vs-leaf
813
+ * decision is page-create's alone. The URL API preserves a trailing slash but does not know that
814
+ * "/about-us/" means a folder index while "/about-us" means a leaf url, and dotCMS pages always
815
+ * have a leaf url (commonly "index"), so a path with no explicit leaf gets one — matching how the
816
+ * admin UI and the rest of the platform address a folder's default page.
817
+ *
818
+ * Segments are returned DECODED here, because they become folder and page names rather than
819
+ * parts of a URL.
820
+ */
821
+ function splitUrlPath(urlPath) {
822
+ const trimmed = urlPath.trim();
823
+ if (!trimmed.startsWith('/')) {
824
+ throw new errors.ValidationError(`urlPath must start with "/": "${urlPath}"`);
825
+ }
826
+ const pathname = toPathname(trimmed, urlPath, 'urlPath');
827
+ const segments = decodeSegments(pathname, urlPath, 'urlPath');
828
+ // No segments → the site root; the page is the root index.
829
+ if (segments.length === 0) {
830
+ return { folder: '/', url: 'index', fullPath: '/index' };
831
+ }
832
+ // A trailing slash means the whole path IS the folder and the page is its index. Otherwise the
833
+ // last segment is the leaf url and everything before it is the folder. (segments is non-empty
834
+ // here — the length===0 case returned above.)
835
+ if (!pathname.endsWith('/')) {
836
+ const url = segments[segments.length - 1];
837
+ const folderSegments = segments.slice(0, -1);
838
+ const folder = folderSegments.length ? `/${folderSegments.join('/')}` : '/';
839
+ const fullPath = `${folder === '/' ? '' : folder}/${url}`;
840
+ return { folder, url, fullPath };
841
+ }
842
+ const folder = `/${segments.join('/')}`;
843
+ return { folder, url: 'index', fullPath: `${folder}/index` };
844
+ }
845
+
846
+ /**
847
+ * Every endpoint this module calls. Each operation that resolves a site or language includes
848
+ * these in its own list. `resolve.spec.ts` checks every request against them.
849
+ */
850
+ const RESOLVE_ENDPOINTS = [
851
+ 'GET /api/v1/site',
852
+ 'GET /api/v1/site/{identifier}',
853
+ 'GET /api/v2/languages'
854
+ ];
855
+ /**
856
+ * Resolution of the instance references a caller can name: sites and languages.
857
+ *
858
+ * ONE RULE runs through this file: **the session context cache is a fast path, never a
859
+ * gate.** `loadContext()` loads sites/languages/content-types once per session, and every
860
+ * loader inside it catches its own failure and returns an empty array (see
861
+ * `sdk/ai/src/adapter/context.ts`). So a transient 500, an expired token, or a permission
862
+ * quirk during that one load leaves the cache permanently empty for the session — and the
863
+ * tools that gated on it then rejected every call with "Available sites: (none found)",
864
+ * including for the default site and for a caller passing a correct site IDENTIFIER.
865
+ *
866
+ * That failure mode is worse than the problem the cache was solving. A cached list is a
867
+ * useful accelerator and a good source of candidate names for an error message; it is not
868
+ * evidence that something does not exist. Every resolver here therefore tries the cache
869
+ * first, falls back to asking dotCMS directly, and only fails when the instance itself says
870
+ * no — at which point the message distinguishes "this does not exist" from "the session
871
+ * context never loaded".
872
+ */
873
+ /** A dotCMS identifier is a 36-char UUID; anything else a caller passes is a name. */
874
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
875
+ function looksLikeIdentifier(value) {
876
+ return UUID_PATTERN.test(value.trim());
877
+ }
878
+ function asRecord$1(value) {
879
+ return value && typeof value === 'object' ? value : undefined;
880
+ }
881
+ function entityOf(response) {
882
+ const record = asRecord$1(response);
883
+ return record && 'entity' in record ? record['entity'] : response;
884
+ }
885
+ function readString(record, key) {
886
+ const value = record?.[key];
887
+ return typeof value === 'string' && value ? value : undefined;
888
+ }
889
+ /** Project a raw site payload onto {@link ResolvedSite}, or undefined if it has no identifier. */
890
+ function toResolvedSite(raw) {
891
+ const record = asRecord$1(raw);
892
+ const identifier = readString(record, 'identifier');
893
+ if (!identifier) {
894
+ return undefined;
895
+ }
896
+ return {
897
+ identifier,
898
+ hostname: readString(record, 'hostname') ??
899
+ readString(record, 'hostName') ??
900
+ readString(record, 'siteName') ??
901
+ identifier
902
+ };
903
+ }
904
+ /**
905
+ * Resolve a site (hostname OR identifier) to its identifier + hostname.
906
+ *
907
+ * Order is deliberate — cheapest and least fallible first:
908
+ * 1. the session cache, when it actually has sites;
909
+ * 2. a direct `GET /api/v1/site/{id}` when the caller gave something UUID-shaped;
910
+ * 3. a filtered `GET /api/v1/site` lookup by hostname.
911
+ *
912
+ * Step 2 matters most for the reported failure: a caller who already knows the identifier
913
+ * should never be blocked by a cache, because the identifier is precisely what the
914
+ * downstream call needs and no lookup is required to use it.
915
+ */
916
+ async function resolveSite(dotcms, site) {
917
+ const wanted = site.trim();
918
+ if (!wanted) {
919
+ throw new errors.ValidationError('Site must be a non-empty hostname or identifier.');
920
+ }
921
+ const cached = await siteFromCache(dotcms, wanted);
922
+ if (cached) {
923
+ return cached;
924
+ }
925
+ const live = await siteFromApi(dotcms, wanted);
926
+ if (live) {
927
+ return live;
928
+ }
929
+ throw new errors.ValidationError(await siteNotFoundMessage(dotcms, wanted));
930
+ }
931
+ /**
932
+ * The instance's default site — what a path with no `//host` prefix means.
933
+ *
934
+ * The session cache first; then `GET /api/v1/site/defaultSite`, so a failed context load does
935
+ * not leave the caller unscoped. A failure of that read propagates as the instance error it is:
936
+ * falling back to "no site" would silently widen whatever the caller was about to scope.
937
+ */
938
+ async function resolveDefaultSite(dotcms) {
939
+ const { sites } = await safeContext(dotcms);
940
+ const cached = sites.find((entry) => entry.isDefault);
941
+ if (cached) {
942
+ return { identifier: cached.identifier, hostname: cached.hostname };
943
+ }
944
+ const live = toResolvedSite(entityOf(await dotcms.request({ path: '/api/v1/site/defaultSite' })));
945
+ if (!live) {
946
+ throw new Error('The instance did not return a default site (GET /api/v1/site/defaultSite).');
947
+ }
948
+ return live;
949
+ }
950
+ async function siteFromCache(dotcms, wanted) {
951
+ const { sites } = await safeContext(dotcms);
952
+ const match = sites.find((entry) => entry.identifier === wanted || entry.hostname?.toLowerCase() === wanted.toLowerCase());
953
+ return match ? { identifier: match.identifier, hostname: match.hostname } : undefined;
954
+ }
955
+ async function siteFromApi(dotcms, wanted) {
956
+ if (looksLikeIdentifier(wanted)) {
957
+ try {
958
+ const resolved = toResolvedSite(entityOf(await dotcms.request({ path: `/api/v1/site/${encodeURIComponent(wanted)}` })));
959
+ if (resolved) {
960
+ return resolved;
961
+ }
962
+ }
963
+ catch (error) {
964
+ // A 404 means this identifier genuinely does not exist — fall through to the
965
+ // hostname search, which will also miss, and report not-found properly. Any other
966
+ // failure is an instance problem and must not be reported as "site not found".
967
+ if (!(error instanceof errors.HttpError) || error.status !== 404) {
968
+ throw error;
969
+ }
970
+ }
971
+ }
972
+ try {
973
+ const raw = entityOf(await dotcms.request({
974
+ path: '/api/v1/site',
975
+ query: { filter: wanted, per_page: 50, page: 1 }
976
+ }));
977
+ const candidates = Array.isArray(raw) ? raw : [];
978
+ // The endpoint filters by substring, so `demo` also returns `demo-backup`. Only an
979
+ // exact hostname (or identifier) match may be accepted — silently picking a
980
+ // near-miss would write content to the wrong site.
981
+ for (const candidate of candidates) {
982
+ const resolved = toResolvedSite(candidate);
983
+ if (resolved &&
984
+ (resolved.identifier === wanted ||
985
+ resolved.hostname.toLowerCase() === wanted.toLowerCase())) {
986
+ return resolved;
987
+ }
988
+ }
989
+ }
990
+ catch (error) {
991
+ // Only a 404 is a miss, and falls through to the not-found message. Anything else — a
992
+ // 503, a rejected token, no response at all — says nothing about whether the site
993
+ // exists, and reported as "not found" it would be VALIDATION: the model told its input
994
+ // is wrong and not to retry, when retrying (or fixing credentials) is the answer.
995
+ if (!(error instanceof errors.HttpError) || error.status !== 404) {
996
+ throw error;
997
+ }
998
+ }
999
+ return undefined;
1000
+ }
1001
+ /** Explain a miss WITHOUT implying the instance has no sites when the cache simply failed. */
1002
+ async function siteNotFoundMessage(dotcms, wanted) {
1003
+ const { sites } = await safeContext(dotcms);
1004
+ if (sites.length === 0) {
1005
+ return (`Site "${wanted}" could not be resolved, and this session's site list is empty — ` +
1006
+ `which usually means the one-time context load failed (a transient error or a ` +
1007
+ `permissions problem), NOT that the instance has no sites. A direct lookup was ` +
1008
+ `also tried and did not find it. Verify the site exists and that the configured ` +
1009
+ `token can read it; if the site is correct, retrying the call reloads the ` +
1010
+ `context — every tool call starts from a fresh runtime.`);
1011
+ }
1012
+ const available = sites.map((entry) => entry.hostname).join(', ');
1013
+ return (`Site "${wanted}" was not found (neither a known hostname nor a site identifier), ` +
1014
+ `and a direct lookup did not find it either. Available sites: ${available}.`);
1015
+ }
1016
+ /**
1017
+ * Resolve a language id against the instance.
1018
+ *
1019
+ * dotCMS silently falls back to its default language for an unknown id rather than
1020
+ * rejecting it, so an unrecognised id does not fail — it quietly writes to a DIFFERENT
1021
+ * language than the caller named while the manifest echoes the id they asked for. That is
1022
+ * worth catching, but ONLY when the language list actually loaded: with an empty list the
1023
+ * caller's explicit id is better evidence than our missing cache, so it is passed through.
1024
+ */
1025
+ async function resolveLanguageId(dotcms, languageId) {
1026
+ const languages = await loadLanguages(dotcms);
1027
+ if (languageId === undefined) {
1028
+ // The instance's own default, not a hardcoded 1 — on some instances it is not 1.
1029
+ return languages[0]?.id ?? 1;
1030
+ }
1031
+ if (languages.length === 0 || languages.some((language) => language.id === languageId)) {
1032
+ return languageId;
1033
+ }
1034
+ const available = languages
1035
+ .map((language) => `${language.id} (${language.isoCode})`)
1036
+ .join(', ');
1037
+ throw new errors.ValidationError(`languageId ${languageId} does not exist on this instance. dotCMS would silently fall ` +
1038
+ `back to the default language and write to the WRONG language rather than reject ` +
1039
+ `it, so this is refused up front. Available languages: ${available}.`);
1040
+ }
1041
+ /** Languages from the cache, falling back to a direct read when the cache is empty. */
1042
+ async function loadLanguages(dotcms) {
1043
+ const { languages } = await safeContext(dotcms);
1044
+ if (languages.length > 0) {
1045
+ return languages.map((language) => ({ id: language.id, isoCode: language.isoCode }));
1046
+ }
1047
+ try {
1048
+ const raw = entityOf(await dotcms.request({ path: '/api/v2/languages' }));
1049
+ return (Array.isArray(raw) ? raw : [])
1050
+ .map((item) => {
1051
+ const record = asRecord$1(item);
1052
+ const id = record?.['id'];
1053
+ return {
1054
+ id: typeof id === 'number' ? id : Number(id) || 0,
1055
+ isoCode: readString(record, 'isoCode') ?? ''
1056
+ };
1057
+ })
1058
+ .filter((language) => language.id > 0);
1059
+ }
1060
+ catch {
1061
+ // Unknown rather than empty — the caller's id is then taken at face value.
1062
+ return [];
1063
+ }
1064
+ }
1065
+ /**
1066
+ * `loadContext()` with its own failure absorbed.
1067
+ *
1068
+ * A context load failing must never be the reason a tool call dies: the context is an
1069
+ * accelerator, and every resolver here has a direct-lookup path that does not need it.
1070
+ */
1071
+ async function safeContext(dotcms) {
1072
+ try {
1073
+ return await dotcms.loadContext();
1074
+ }
1075
+ catch (error) {
1076
+ console.error(`[context] load failed during resolution: ${errorMessage(error)}`);
1077
+ return { contentTypes: [], sites: [], languages: [], currentUser: null };
1078
+ }
1079
+ }
1080
+
1081
+ /**
1082
+ * Every endpoint `createPage` calls — the `page_create` tool enforces exactly this list, and
1083
+ * `page-create.spec.ts` checks every request against it. Add a request, add it here.
1084
+ */
1085
+ const PAGE_CREATE_ENDPOINTS = [
1086
+ ...CONTEXT_ENDPOINTS,
1087
+ ...RESOLVE_ENDPOINTS,
1088
+ 'GET /api/v1/contenttype/id/{idOrVar}',
1089
+ 'GET /api/v1/templates/{identifier}/working',
1090
+ 'POST /api/v1/folder/createfolders/{site}',
1091
+ 'PUT /api/v1/workflow/actions/default/fire/PUBLISH',
1092
+ 'GET /api/v1/content/{identifier}'
1093
+ ];
1094
+ /** The default page content type when the caller does not name one. */
1095
+ const DEFAULT_PAGE_CONTENT_TYPE = 'htmlpageasset';
1096
+ /** Cap on an interpolated response entity inside an error message (see describeEntity). */
1097
+ const MAX_ENTITY_CHARS = 1_000;
1098
+ /** Base type string the REST API reports for any page content type. */
1099
+ const PAGE_BASE_TYPE = 'HTMLPAGE';
1100
+ /**
1101
+ * System field variables every page content type carries — mirrored by hand from
1102
+ * dotCMS/.../contenttype/model/type/PageContentType.java (requiredFields() + the field-var
1103
+ * constants). These are either set explicitly from typed args below or filled by the platform, so
1104
+ * they are NOT treated as "unsatisfied user-required fields" during validation. Re-sync this set
1105
+ * if those field variables change backend-side. (The fetched field's `fixed` flag also excludes
1106
+ * platform fields at line ~`assertRequiredFieldsSatisfied`; this list is the belt-and-suspenders
1107
+ * fallback for instances/versions where `fixed` isn't reliably serialized.)
1108
+ */
1109
+ const SYSTEM_PAGE_FIELD_VARS = new Set([
1110
+ 'title',
1111
+ 'url',
1112
+ 'hostFolder',
1113
+ 'template',
1114
+ 'showOnMenu',
1115
+ 'sortOrder',
1116
+ 'cachettl',
1117
+ 'friendlyName',
1118
+ 'pageTitle',
1119
+ 'redirecturl',
1120
+ 'httpsreq',
1121
+ 'seodescription',
1122
+ 'seokeywords',
1123
+ 'pagemetadata'
1124
+ ]);
1125
+ /**
1126
+ * Create and publish a dotCMS page in one safe call.
1127
+ *
1128
+ * A "page" is a contentlet whose content type's base type is HTMLPAGE, fired through the generic
1129
+ * workflow endpoint — there is no dedicated create-page endpoint. The content type defaults to
1130
+ * `htmlpageasset` but can be any page type; custom page types may add their own (possibly required)
1131
+ * fields, so we resolve the type and validate against its actual field set before firing.
1132
+ *
1133
+ * This wrapper absorbs the URL-collapse trap: dotCMS silently collapses a page `url` whose parent
1134
+ * folder does not exist down to `/index`, which then 400s against the home page. We split `urlPath`
1135
+ * into folder + leaf, create the folder first, then fire the page with `url: "<leaf>"` and
1136
+ * `hostFolder: <created folder>` so the URL lands where the caller meant.
1137
+ *
1138
+ * This is the THIN tier: it does NOT place content. The page comes up live but blank — content
1139
+ * placement and the re-publish that follows are a separate, explicit step for the caller. The
1140
+ * manifest flags this so a successful create is never mistaken for a fully-populated page.
1141
+ */
1142
+ async function createPage(options) {
1143
+ const { folder, url, fullPath } = splitUrlPath(options.urlPath);
1144
+ const warnings = [];
1145
+ const extraFields = options.extraFields ?? {};
1146
+ // Resolve the site to its identifier UUID up front. `contentHost` on the fire body MUST be a
1147
+ // site UUID — a bare hostname makes the fire NPE ("Host.getIdentifier() because host is null"),
1148
+ // which is exactly the root-page (`/`) trap where there is no folder to anchor the page on.
1149
+ const siteId = await resolveSiteId(options.dotcms, options.site);
1150
+ // Resolve the page content type and validate it BEFORE creating anything. A wrong type (not a
1151
+ // page) or a missing user-required field would otherwise 400 the fire opaquely — and only after
1152
+ // we'd already created the folder. Fail early with a precise message instead.
1153
+ const contentType = await resolvePageContentType(options.dotcms, options.contentType);
1154
+ assertRequiredFieldsSatisfied(contentType, extraFields);
1155
+ // Validate the remaining caller inputs BEFORE anything is written. `site` and
1156
+ // `contentType` are already resolved above against cached context; `template` and
1157
+ // `languageId` were the two that were not, which made them the only inputs whose
1158
+ // rejection arrived AFTER the folder had been created (see ensureFolder below).
1159
+ const languageId = await resolveLanguageId(options.dotcms, options.languageId);
1160
+ await assertTemplateExists(options.dotcms, options.template);
1161
+ // Trap #1: the parent folder must exist before the page is fired, or dotCMS collapses the
1162
+ // page url to /index. createfolders is idempotent — re-creating an existing folder is a no-op.
1163
+ const folderId = await ensureFolder(options.dotcms, siteId, folder);
1164
+ // Trap #3 (root/leaf page on a NON-default site): the page's HOST_OR_FOLDER field must carry a
1165
+ // concrete location id, or the fire cannot resolve the host and 500s with
1166
+ // "Host.getIdentifier() ... host is null". A nested page passes the folder id (which carries its
1167
+ // host); a root page has no folder, so `folderId` is undefined and, left alone, `hostFolder`
1168
+ // would be dropped from the JSON body — leaving only `contentHost`, which is not enough to
1169
+ // anchor a root page. Fall the location back to the SITE id: HOST_OR_FOLDER accepts a host id
1170
+ // and resolves it to that host's system folder. (This mirrors the working manual recovery:
1171
+ // fire with hostFolder = site id.)
1172
+ const hostFolder = folderId ?? siteId;
1173
+ const title = options.title;
1174
+ // Guarded: `ensureFolder` above has ALREADY COMMITTED a folder by this point, so a bare
1175
+ // rethrow here leaves a folder nothing mentions. `page_create({urlPath:"/books/index"})`
1176
+ // with a bad input would create `/books`, fail, and report only the HTTP error — and a
1177
+ // corrected retry then operates on folder state the caller does not know exists.
1178
+ const fired = await fireCreate(options.dotcms, folder, folderId, {
1179
+ method: 'PUT',
1180
+ path: '/api/v1/workflow/actions/default/fire/PUBLISH',
1181
+ query: { indexPolicy: 'WAIT_FOR' },
1182
+ body: {
1183
+ contentlet: {
1184
+ // User-added fields first, so the typed page fields below always win on the keys
1185
+ // they own (a caller can't accidentally override `url`/`template` via extraFields).
1186
+ ...extraFields,
1187
+ contentType: contentType.variable,
1188
+ contentHost: siteId,
1189
+ hostFolder,
1190
+ languageId,
1191
+ title,
1192
+ url,
1193
+ template: options.template,
1194
+ cachettl: options.cacheTtl ?? '0',
1195
+ sortOrder: options.sortOrder ?? 0,
1196
+ friendlyName: options.friendlyName ?? title,
1197
+ pageTitle: options.pageTitle ?? title
1198
+ }
1199
+ }
1200
+ });
1201
+ const entity = extractPageEntity(fired);
1202
+ const identifier = entity?.identifier;
1203
+ const inode = entity?.inode;
1204
+ const live = await isLive(options.dotcms, identifier);
1205
+ if (!live) {
1206
+ warnings.push(`Page created but not confirmed live. It may render blank until content is placed and the page is re-published.`);
1207
+ }
1208
+ return {
1209
+ identifier,
1210
+ inode,
1211
+ contentType: contentType.variable,
1212
+ folder,
1213
+ url,
1214
+ fullPath,
1215
+ site: options.site,
1216
+ live,
1217
+ warnings
1218
+ };
1219
+ }
1220
+ /**
1221
+ * Resolve a site (given as a hostname OR an identifier UUID) to its identifier UUID.
1222
+ *
1223
+ * The fire body's `contentHost` must be a site UUID. Passing a bare hostname works for pages under
1224
+ * a folder (the folder anchors the host) but NPEs for a root page, where there is no folder — dotCMS
1225
+ * then can't resolve the host and throws "Host.getIdentifier() because host is null". Resolving to
1226
+ * the UUID here makes every page (root included) fire cleanly. Uses the runtime's cached site
1227
+ * context (already loaded), mirroring how resolvePageContentType uses cached content types.
1228
+ */
1229
+ async function resolveSiteId(dotcms, site) {
1230
+ return (await resolveSite(dotcms, site)).identifier;
1231
+ }
1232
+ /**
1233
+ * Resolve the page content type by variable or id and confirm it is actually a page type.
1234
+ *
1235
+ * Defaults to `htmlpageasset`. We first match against the runtime's cached content-type summaries
1236
+ * (cheap, already loaded) to give a precise "not found / not a page type" error, then fetch the
1237
+ * full definition (with fields) so required-field validation can run. Firing a non-page type as a
1238
+ * page produces a broken contentlet, so a wrong base type is a hard error, not a warning.
1239
+ */
1240
+ async function resolvePageContentType(dotcms, requested) {
1241
+ const wanted = (requested ?? DEFAULT_PAGE_CONTENT_TYPE).trim();
1242
+ // The cache is consulted for the id and for a candidate list, but it does NOT decide
1243
+ // whether the type exists: the definition fetch below is a live call that answers the
1244
+ // same question authoritatively. Gating on the cache meant that when the one-time
1245
+ // context load failed — leaving `contentTypes` empty for the whole session — even the
1246
+ // default `htmlpageasset` was rejected as "not found".
1247
+ const context = await safeLoadContext(dotcms);
1248
+ const summary = context.contentTypes.find((ct) => ct.variable === wanted || ct.id === wanted);
1249
+ // Computed lazily — the happy path never needs the list of page types for an error message.
1250
+ const availablePageTypes = () => context.contentTypes
1251
+ .filter((ct) => ct.baseType === PAGE_BASE_TYPE)
1252
+ .map((ct) => ct.variable)
1253
+ .join(', ') || '(unknown — the session content-type list did not load)';
1254
+ let definition;
1255
+ try {
1256
+ definition = await fetchContentTypeDefinition(dotcms, summary?.id || wanted);
1257
+ }
1258
+ catch (error) {
1259
+ if (!(error instanceof errors.HttpError) || error.status !== 404) {
1260
+ throw error;
1261
+ }
1262
+ // 404 → not found, handled below alongside the empty-entity case.
1263
+ }
1264
+ if (!definition) {
1265
+ throw new errors.ValidationError(`Content type "${wanted}" was not found on this instance. ` +
1266
+ `Available page content types: ${availablePageTypes()}.`);
1267
+ }
1268
+ // Checked against the FETCHED definition, not the cached summary — the fetch is the
1269
+ // authority and is present on every path.
1270
+ if (definition.baseType !== PAGE_BASE_TYPE) {
1271
+ throw new errors.ValidationError(`Content type "${definition.variable}" is base type ${definition.baseType}, not a page ` +
1272
+ `(HTMLPAGE). page_create only creates pages. Available page content types: ` +
1273
+ `${availablePageTypes()}.`);
1274
+ }
1275
+ return definition;
1276
+ }
1277
+ /**
1278
+ * `loadContext()` with its failure absorbed — the context is an accelerator here, and every
1279
+ * caller below has a live path that does not need it. See lib/resolve.ts for the full
1280
+ * rationale on why an empty cache must never be read as "this does not exist".
1281
+ */
1282
+ async function safeLoadContext(dotcms) {
1283
+ try {
1284
+ return await dotcms.loadContext();
1285
+ }
1286
+ catch (error) {
1287
+ console.error(`[context] load failed during content-type resolution: ${errorMessage(error)}`);
1288
+ return { contentTypes: [], sites: [], languages: [], currentUser: null };
1289
+ }
1290
+ }
1291
+ /** Fetch the full content-type definition (including fields) by id or variable. */
1292
+ async function fetchContentTypeDefinition(dotcms, idOrVar) {
1293
+ const response = await dotcms.request({
1294
+ path: `/api/v1/contenttype/id/${encodeURIComponent(idOrVar)}`
1295
+ });
1296
+ const entity = asRecord(responseEntity(response));
1297
+ const rawFields = entity?.['fields'];
1298
+ const fields = Array.isArray(rawFields)
1299
+ ? rawFields
1300
+ .map(asRecord)
1301
+ .filter((f) => f !== undefined)
1302
+ .map((f) => ({
1303
+ variable: optionalString(f, 'variable'),
1304
+ required: f['required'] === true,
1305
+ fixed: f['fixed'] === true,
1306
+ defaultValue: f['defaultValue']
1307
+ }))
1308
+ : [];
1309
+ const variable = optionalString(entity, 'variable');
1310
+ const baseType = optionalString(entity, 'baseType');
1311
+ // An empty/unrecognised entity means the type was not resolved. Returning a shape that
1312
+ // defaults `baseType` to HTMLPAGE would assert the very thing the caller is being
1313
+ // checked for, so the base-type guard would pass on a type that does not exist.
1314
+ if (!variable || !baseType) {
1315
+ return undefined;
1316
+ }
1317
+ return {
1318
+ id: optionalString(entity, 'id') ?? idOrVar,
1319
+ variable,
1320
+ baseType,
1321
+ fields
1322
+ };
1323
+ }
1324
+ /**
1325
+ * Fail before firing if the type has a user-added required field we have no value for. dotCMS
1326
+ * would reject the fire with a 400 anyway — but only after we've created the folder, and with a
1327
+ * less actionable message. We skip system page fields (filled from typed args / the platform),
1328
+ * fixed fields, and fields that carry a default value.
1329
+ */
1330
+ function assertRequiredFieldsSatisfied(contentType, extraFields) {
1331
+ // flatMap (not filter + map) so `variable` stays narrowed to string without a cast.
1332
+ const missing = contentType.fields.flatMap((field) => {
1333
+ const variable = field.variable;
1334
+ if (!variable || !field.required || field.fixed)
1335
+ return [];
1336
+ if (SYSTEM_PAGE_FIELD_VARS.has(variable))
1337
+ return [];
1338
+ if (hasDefault(field.defaultValue))
1339
+ return [];
1340
+ return hasValue(extraFields[variable]) ? [] : [variable];
1341
+ });
1342
+ if (missing.length > 0) {
1343
+ throw new errors.ValidationError(`Content type "${contentType.variable}" has required field(s) with no value: ` +
1344
+ `${missing.join(', ')}. Pass them via extraFields, e.g. ` +
1345
+ `{ "${missing[0]}": <value> }.`);
1346
+ }
1347
+ }
1348
+ /** A server-side default counts if non-null and (for strings) non-empty. */
1349
+ function hasDefault(defaultValue) {
1350
+ return typeof defaultValue === 'string' ? defaultValue.length > 0 : defaultValue != null;
1351
+ }
1352
+ /** A caller-supplied value counts if non-null and (for strings) non-blank after trimming. */
1353
+ function hasValue(value) {
1354
+ if (value == null)
1355
+ return false;
1356
+ if (typeof value === 'string')
1357
+ return value.trim().length > 0;
1358
+ return true;
1359
+ }
1360
+ /**
1361
+ * Ensure the folder path exists on the site and return the id of the deepest (target) folder.
1362
+ * createfolders creates the full path and is idempotent on existing folders.
1363
+ */
1364
+ async function ensureFolder(dotcms, site, folder) {
1365
+ if (folder === '/' || folder === '') {
1366
+ // Root page: no folder to create. The caller falls hostFolder back to the site id (a root
1367
+ // page fired with an undefined hostFolder 500s on a non-default site — see Trap #3).
1368
+ return undefined;
1369
+ }
1370
+ const response = await dotcms.request({
1371
+ method: 'POST',
1372
+ path: `/api/v1/folder/createfolders/${encodeURIComponent(site)}`,
1373
+ body: [folder]
1374
+ });
1375
+ const folderId = extractFolderId(response, folder);
1376
+ // A nested page MUST be anchored on its real folder id. Falling back to the site id here
1377
+ // (the root-page path below) would silently anchor the page at the site root and
1378
+ // reintroduce the very /index URL-collapse trap this function exists to prevent, while the
1379
+ // manifest still reported the intended folder/fullPath. Fail loudly instead.
1380
+ if (!folderId) {
1381
+ throw new Error(`Folder "${folder}" was requested on site "${site}" but createfolders returned no ` +
1382
+ `resolvable folder id, so the page cannot be anchored to it. Refusing to fall ` +
1383
+ `back to the site root (that would collapse the page url to /index). ` +
1384
+ `Response entity: ${describeEntity(response)}`);
1385
+ }
1386
+ return folderId;
1387
+ }
1388
+ /**
1389
+ * `dotcms.request()` is typed `unknown` — it speaks to a live instance whose payload we cannot
1390
+ * prove at compile time. Rather than assert a shape with `as` (which type-checks a lie and then
1391
+ * lets `undefined` surface deep in the caller), narrow the ONE thing every dotCMS REST envelope
1392
+ * guarantees: an object with an optional `entity`. Callers keep their own per-endpoint guards for
1393
+ * what lives inside it, so a shape change becomes a handled `undefined` rather than a crash.
1394
+ */
1395
+ function responseEntity(response) {
1396
+ if (typeof response !== 'object' || response === null) {
1397
+ return undefined;
1398
+ }
1399
+ return response.entity;
1400
+ }
1401
+ /** Narrow a value to an indexable record, or undefined when it isn't one. */
1402
+ function asRecord(value) {
1403
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1404
+ ? value
1405
+ : undefined;
1406
+ }
1407
+ /** Read an optional string property, ignoring non-string values. */
1408
+ function optionalString(record, key) {
1409
+ const value = record?.[key];
1410
+ return typeof value === 'string' ? value : undefined;
1411
+ }
1412
+ /**
1413
+ * createfolders returns the created/found folders. We want the id of the folder matching our
1414
+ * target path. Shapes vary across versions (array vs object, identifier vs inode), so probe
1415
+ * defensively and fall back to the deepest entry.
1416
+ */
1417
+ function extractFolderId(response, folder) {
1418
+ const entity = responseEntity(response);
1419
+ const raw = Array.isArray(entity) ? entity : entity ? [entity] : [];
1420
+ const list = raw.map(asRecord).filter((f) => f !== undefined);
1421
+ if (list.length === 0) {
1422
+ return undefined;
1423
+ }
1424
+ const normalized = folder.replace(/\/+$/, '');
1425
+ const match = list.find((f) => optionalString(f, 'path')?.replace(/\/+$/, '') === normalized);
1426
+ const chosen = match ?? list[list.length - 1];
1427
+ return optionalString(chosen, 'identifier') ?? optionalString(chosen, 'inode');
1428
+ }
1429
+ /**
1430
+ * Read the page fields out of a fire/read response.
1431
+ *
1432
+ * Every field is read through a checking accessor rather than asserted with `as`. The two
1433
+ * casts that used to live here claimed `identifier`, `inode` and `live` existed with the
1434
+ * right types off a `Record<string, unknown>` that nothing had checked — precisely the
1435
+ * "type-check a lie and let `undefined` surface deep in the caller" failure that
1436
+ * `responseEntity`/`asRecord`/`optionalString` were introduced to close.
1437
+ */
1438
+ function extractPageEntity(response) {
1439
+ const entity = asRecord(responseEntity(response));
1440
+ if (!entity) {
1441
+ return undefined;
1442
+ }
1443
+ // Fire responses sometimes wrap the contentlet under `contentlets[0]`.
1444
+ const contentlets = entity['contentlets'];
1445
+ if (Array.isArray(contentlets) && contentlets.length) {
1446
+ const first = asRecord(contentlets[0]);
1447
+ return first && toPageEntity(first);
1448
+ }
1449
+ return toPageEntity(entity);
1450
+ }
1451
+ /** Project a checked record onto {@link PageEntity} — unknown/mistyped fields stay undefined. */
1452
+ function toPageEntity(record) {
1453
+ return {
1454
+ identifier: optionalString(record, 'identifier'),
1455
+ inode: optionalString(record, 'inode'),
1456
+ live: optionalBoolean(record, 'live')
1457
+ };
1458
+ }
1459
+ /** Read an optional boolean property, ignoring non-boolean values. */
1460
+ function optionalBoolean(record, key) {
1461
+ const value = record?.[key];
1462
+ return typeof value === 'boolean' ? value : undefined;
1463
+ }
1464
+ async function isLive(dotcms, identifier) {
1465
+ if (!identifier) {
1466
+ return false;
1467
+ }
1468
+ try {
1469
+ return await isContentLive(dotcms, identifier);
1470
+ }
1471
+ catch {
1472
+ // A failed liveness check is not a failed create — the page exists either way, so
1473
+ // report it as not-verified-live rather than failing the whole operation. The
1474
+ // shared probe deliberately throws; swallowing is this caller's choice, not the
1475
+ // primitive's (see page-common.ts).
1476
+ return false;
1477
+ }
1478
+ }
1479
+ /**
1480
+ * Confirm the template exists before anything is written.
1481
+ *
1482
+ * `template` is the one input a caller most often gets wrong — the schema says "the template
1483
+ * UUID, not its name", which is exactly the mistake worth catching — and it was the only one
1484
+ * whose rejection arrived from the fire, i.e. after the folder had already been created.
1485
+ *
1486
+ * A non-404 failure here is deliberately NOT fatal: the check is a courtesy, and refusing to
1487
+ * create a page because the template lookup was briefly unavailable would be worse than
1488
+ * letting the fire decide.
1489
+ */
1490
+ async function assertTemplateExists(dotcms, template) {
1491
+ try {
1492
+ await dotcms.request({
1493
+ path: `/api/v1/templates/${encodeURIComponent(template)}/working`
1494
+ });
1495
+ }
1496
+ catch (error) {
1497
+ if (error instanceof errors.HttpError && error.status === 404) {
1498
+ throw new errors.ValidationError(`Template "${template}" was not found. This must be the template's IDENTIFIER ` +
1499
+ `(a UUID), not its name — passing a human-readable title here is the most ` +
1500
+ `common cause. Nothing has been created; fix the template and re-run.`);
1501
+ }
1502
+ // Anything else (403, 5xx, timeout): fall through and let the fire be the judge.
1503
+ }
1504
+ }
1505
+ /**
1506
+ * Fire the create, and on failure say what has ALREADY been committed.
1507
+ *
1508
+ * The folder write happens before this point and cannot be rolled back, so the recoverable
1509
+ * outcome depends on the caller knowing three things: which inputs were used, that folder
1510
+ * `<x>` now exists, and that re-running is safe because `createfolders` is idempotent. That
1511
+ * last sentence is what turns an orphaned folder into a retryable operation.
1512
+ */
1513
+ async function fireCreate(dotcms, folder, folderId, request) {
1514
+ try {
1515
+ return await dotcms.request(request);
1516
+ }
1517
+ catch (error) {
1518
+ const created = folderId
1519
+ ? `Folder "${folder}" (${folderId}) WAS created before this failure and still exists. `
1520
+ : '';
1521
+ const note = `${created}Re-running this call after fixing the input is SAFE: folder creation ` +
1522
+ `is idempotent, so no duplicate folder is made.`;
1523
+ // Add the note to the fire's OWN error rather than wrapping it: its class is what says
1524
+ // whether to retry — a 503 or a timeout is worth another try, a 400 is not.
1525
+ throw withMessage(error, `${errorMessage(error)}\n\n${note}`);
1526
+ }
1527
+ }
1528
+ /**
1529
+ * Render a response's entity for an error message.
1530
+ *
1531
+ * `JSON.stringify(undefined)` returns `undefined` (the value, not a string), so interpolating
1532
+ * it printed the literal text "Response entity: undefined" — and an ABSENT entity is exactly
1533
+ * the condition that triggers the branch using this, so that was the common case rather than
1534
+ * the edge one. When an entity IS present it is capped: the raw blob can be arbitrarily large
1535
+ * and this is going straight into an error the model has to read.
1536
+ */
1537
+ function describeEntity(response) {
1538
+ const entity = responseEntity(response);
1539
+ if (entity === undefined || entity === null) {
1540
+ return '(no entity in the response — the endpoint returned a body this tool could not read)';
1541
+ }
1542
+ const json = JSON.stringify(entity);
1543
+ return json.length <= MAX_ENTITY_CHARS
1544
+ ? json
1545
+ : `${json.slice(0, MAX_ENTITY_CHARS)}… [truncated]`;
1546
+ }
1547
+
1548
+ const definition$4 = defineTool({
1549
+ name: 'page_create',
1550
+ title: 'Create dotCMS Page',
1551
+ inputSchema: zod.z.object({
1552
+ site: zod.z
1553
+ .string()
1554
+ .min(1)
1555
+ .describe('Site the page lives on — identifier (UUID) or hostname, e.g. "demo.dotcms.com"'),
1556
+ urlPath: zod.z
1557
+ .string()
1558
+ .min(1)
1559
+ .describe('Page URL path on the site, e.g. "/books/index" or "/books". The parent folder is created automatically; the leaf segment becomes the page url (a bare folder or trailing slash defaults the leaf to "index").'),
1560
+ title: zod.z.string().min(1).describe('Page title'),
1561
+ template: zod.z
1562
+ .string()
1563
+ .min(1)
1564
+ .describe('Template identifier the page renders with (the template UUID, not its name)'),
1565
+ contentType: zod.z
1566
+ .string()
1567
+ .optional()
1568
+ .describe('Page content type — variable or id. Defaults to "htmlpageasset". Must be a content type whose base type is HTMLPAGE; a custom page type may add its own fields (pass values via extraFields).'),
1569
+ extraFields: zod.z
1570
+ .record(zod.z.string(), zod.z.unknown())
1571
+ .optional()
1572
+ .describe('Values for content-type fields beyond the common page fields, keyed by field variable. Required for any user-added required field on a custom page type that has no default value (the tool validates this before firing and tells you which fields are missing).'),
1573
+ friendlyName: zod.z.string().optional().describe('Friendly name; defaults to the title'),
1574
+ pageTitle: zod.z.string().optional().describe('Browser <title>; defaults to the title'),
1575
+ languageId: zod.z.number().int().default(1).describe('Language id. Default 1'),
1576
+ cacheTtl: zod.z
1577
+ .string()
1578
+ .default('0')
1579
+ .describe('Cache TTL in seconds, as a string. Default "0"'),
1580
+ sortOrder: zod.z.number().int().default(0).describe('Sort order within the folder. Default 0')
1581
+ }),
1582
+ description: `Create and publish a dotCMS page in one safe call.
1583
+
1584
+ A dotCMS "page" is a contentlet whose content type's base type is HTMLPAGE, fired through the
1585
+ generic workflow endpoint — there is NO dedicated create-page endpoint. The content type defaults
1586
+ to \`htmlpageasset\`, but pass \`contentType\` to use a custom page type. The tool resolves the type,
1587
+ confirms it really is a page type, and validates its required fields BEFORE creating anything — so
1588
+ a custom page type's user-added required fields surface as a clear "pass extraFields: { … }" error
1589
+ instead of an opaque 400 after the folder was already created. Hand-rolling that fire with the
1590
+ \`execute\` tool is the path that hits two sharp edges; this tool exists to absorb the first one:
1591
+
1592
+ The URL-collapse trap. If you fire a page with \`url: "/books/index"\` but the \`/books\` folder
1593
+ does not exist yet, dotCMS silently collapses the url down to \`/index\` — which then 400s with
1594
+ "Page URL [/index] already exists" because the home page already owns it. This tool splits
1595
+ \`urlPath\` into folder + leaf, creates the parent folder first (idempotent), then fires the
1596
+ page with the leaf url under that folder — so the url lands exactly where you meant.
1597
+
1598
+ WHAT THIS TOOL DOES NOT DO — read this. It creates the page; it does NOT place any content on it.
1599
+ The page comes up live but BLANK. Placing content is a separate, explicit step you take afterward
1600
+ with the \`page_place_content\` tool: it puts existing contentlets into the page's slots without
1601
+ emptying the slots you don't address. Then check the result with \`page_verify\`, which catches a
1602
+ blank slot or an edit that is not live yet. The returned manifest flags a \`live: false\` / warning
1603
+ case so a successful create is never mistaken for a fully-populated page.
1604
+
1605
+ Returns a JSON manifest: { identifier, inode, folder, url, fullPath, site, live, warnings }.
1606
+
1607
+ Custom page fields go in \`extraFields\` on this tool. Use the \`execute\` tool (not this one) only
1608
+ when you need a page variant or need to fire a non-PUBLISH workflow action.`,
1609
+ annotations: {
1610
+ readOnlyHint: false,
1611
+ destructiveHint: false,
1612
+ idempotentHint: false,
1613
+ openWorldHint: true
1614
+ },
1615
+ endpoints: PAGE_CREATE_ENDPOINTS,
1616
+ async handler(args, ctx) {
1617
+ return createPage({
1618
+ dotcms: ctx.runtime(),
1619
+ site: args.site,
1620
+ urlPath: args.urlPath,
1621
+ title: args.title,
1622
+ template: args.template,
1623
+ contentType: args.contentType,
1624
+ extraFields: args.extraFields,
1625
+ friendlyName: args.friendlyName,
1626
+ pageTitle: args.pageTitle,
1627
+ languageId: args.languageId,
1628
+ cacheTtl: args.cacheTtl,
1629
+ sortOrder: args.sortOrder
1630
+ });
1631
+ }
1632
+ });
1633
+ /**
1634
+ * The `page_create` tool: creates and publishes a page, creating its parent folder first so
1635
+ * the URL lands where it was asked for. Resolves to a {@link CreatePageManifest}.
1636
+ */
1637
+ function pageCreateTool(connection, options = {}) {
1638
+ return createTool(definition$4, connection, options);
1639
+ }
1640
+
1641
+ /**
1642
+ * Every endpoint `placeContent` calls — the `page_place_content` tool enforces exactly this
1643
+ * list, and `page-place-content.spec.ts` checks every request against it. Add a request, add it
1644
+ * here.
1645
+ */
1646
+ const PAGE_PLACE_CONTENT_ENDPOINTS = [
1647
+ ...CONTEXT_ENDPOINTS,
1648
+ ...RESOLVE_ENDPOINTS,
1649
+ 'GET /api/v1/page/json/**',
1650
+ 'POST /api/v1/page/{pageId}/content'
1651
+ ];
1652
+ /** The default variant when the caller does not name one. */
1653
+ const DEFAULT_VARIANT = 'DEFAULT';
1654
+ /** The default language id when the caller does not name one. */
1655
+ const DEFAULT_LANGUAGE_ID$1 = 1;
1656
+ /**
1657
+ * Place content into a page's container slots without wiping the rest of the page.
1658
+ *
1659
+ * `POST /api/v1/page/{pageId}/content` is a FULL replacement of the page's container-to-contentlet
1660
+ * map: any slot omitted from the body is emptied. A caller that means "add one contentlet to one
1661
+ * slot" but sends only that slot silently clears every other slot. This wrapper absorbs that trap:
1662
+ * it reads the page's current content first, applies the requested op(s) to the addressed slot(s)
1663
+ * only, then POSTs the COMPLETE map back so untouched slots survive.
1664
+ *
1665
+ * It also removes the discovery friction: the caller addresses a slot by index or container name
1666
+ * and we resolve it to the exact `identifier`+`uuid` the endpoint needs — sourced from the page's
1667
+ * real layout, so a typo fails clearly instead of silently doing nothing. The returned manifest
1668
+ * gives a before/after diff per slot and flags any slot that lost content.
1669
+ */
1670
+ async function placeContent(options) {
1671
+ const mode = options.mode ?? 'merge';
1672
+ const variantName = options.variantName ?? DEFAULT_VARIANT;
1673
+ const languageId = options.languageId ?? DEFAULT_LANGUAGE_ID$1;
1674
+ const assignments = validateAssignments(options.slots);
1675
+ const target = await resolvePageTarget(options.dotcms, options.path, options.site);
1676
+ // Read the page's current content. This is the whole point of merge mode, but replace mode needs
1677
+ // it too — to validate the addressed slots exist and to build the before/after diff.
1678
+ const { pageId, url, slots } = await loadPageSlots(options.dotcms, target.path, target.siteId, languageId, variantName);
1679
+ // Index the real slots two ways so a caller can address either by 1-based layout order or by
1680
+ // container (+optional instance uuid). Resolution validates existence and disambiguates.
1681
+ const resolved = assignments.map((assignment) => ({
1682
+ assignment,
1683
+ target: resolveSlot(assignment.slot, slots)
1684
+ }));
1685
+ // Start from the page's current map. In merge mode we mutate the addressed slots in place; in
1686
+ // replace mode we start empty (every slot cleared) and set only what the caller specifies.
1687
+ const desired = new Map();
1688
+ for (const slot of slots) {
1689
+ desired.set(slotKey(slot.identifier, slot.uuid), mode === 'replace' ? [] : [...slot.contentlets]);
1690
+ }
1691
+ for (const { assignment, target } of resolved) {
1692
+ const key = slotKey(target.identifier, target.uuid);
1693
+ const current = desired.get(key) ?? [];
1694
+ desired.set(key, applyOp(current, assignment.contentlets, assignment.op ?? 'append'));
1695
+ }
1696
+ // The POST body is the FULL array — every slot on the page, with its desired contents.
1697
+ const body = slots.map((slot) => ({
1698
+ identifier: slot.identifier,
1699
+ uuid: slot.uuid,
1700
+ contentletsId: desired.get(slotKey(slot.identifier, slot.uuid)) ?? []
1701
+ }));
1702
+ await postContent(options.dotcms, pageId, body, variantName, languageId);
1703
+ // Build the diff and warnings from before (page's current) vs after (what we sent).
1704
+ const warnings = [];
1705
+ const slotResults = slots.map((slot) => {
1706
+ const after = desired.get(slotKey(slot.identifier, slot.uuid)) ?? [];
1707
+ const before = slot.contentlets;
1708
+ const lost = before.filter((id) => !after.includes(id));
1709
+ if (lost.length > 0) {
1710
+ warnings.push(`Slot ${slot.identifier} [uuid ${slot.uuid}] lost ${lost.length} contentlet(s): ` +
1711
+ `${lost.join(', ')}.`);
1712
+ }
1713
+ return {
1714
+ identifier: slot.identifier,
1715
+ uuid: slot.uuid,
1716
+ before,
1717
+ after,
1718
+ changed: !sameOrder(before, after)
1719
+ };
1720
+ });
1721
+ return {
1722
+ pageId,
1723
+ site: target.hostname,
1724
+ url,
1725
+ variantName,
1726
+ languageId,
1727
+ mode,
1728
+ slots: slotResults,
1729
+ warnings
1730
+ };
1731
+ }
1732
+ /** Resolve explicit or host-qualified page targeting without ever falling back to a default site. */
1733
+ async function resolvePageTarget(dotcms, rawPath, explicitSite) {
1734
+ const trimmed = rawPath.trim();
1735
+ const qualified = /^\/\/([^/]+)(\/.*)?$/.exec(trimmed);
1736
+ const qualifiedHost = qualified?.[1];
1737
+ const pagePath = qualified ? qualified[2] || '/' : trimmed;
1738
+ const requestedSite = explicitSite?.trim();
1739
+ if (!requestedSite && !qualifiedHost) {
1740
+ throw new errors.ValidationError('`site` is required for page placement when `path` is not host-qualified. ' +
1741
+ 'Pass a site hostname/identifier or use `//hostname/path`; refusing to choose the ' +
1742
+ 'default site prevents writes to the wrong page on multi-site instances.');
1743
+ }
1744
+ // Resolved through the shared resolver, which falls back to a direct lookup rather than
1745
+ // treating an empty session cache as proof a site does not exist. Each resolver throws
1746
+ // its own explanatory error, so an unknown site still fails here — just for the right
1747
+ // reason, and before any request is made.
1748
+ const explicit = requestedSite ? await resolveSite(dotcms, requestedSite) : undefined;
1749
+ const embedded = qualifiedHost ? await resolveSite(dotcms, qualifiedHost) : undefined;
1750
+ if (explicit && embedded && explicit.identifier !== embedded.identifier) {
1751
+ throw new errors.ValidationError(`Conflicting sites: path targets "${qualifiedHost}" but site targets ` +
1752
+ `"${requestedSite}". Pass one site consistently; no request was made.`);
1753
+ }
1754
+ const site = explicit ?? embedded;
1755
+ // The branches above make this unreachable, but keep the guard for type safety and future edits.
1756
+ if (!site) {
1757
+ throw new Error('Page placement site could not be resolved.');
1758
+ }
1759
+ // The manifest must echo the path actually operated on, not the raw input.
1760
+ const normalizedPath = normalizePagePath(pagePath);
1761
+ return { path: normalizedPath, siteId: site.identifier, hostname: site.hostname };
1762
+ }
1763
+ /**
1764
+ * `slots` is required and must be non-empty. The tool schema also guards this, but the lib is
1765
+ * called directly from tests, so it validates too.
1766
+ */
1767
+ function validateAssignments(slots) {
1768
+ if (!slots || slots.length === 0) {
1769
+ throw new errors.ValidationError('`slots` is required and must contain at least one slot assignment.');
1770
+ }
1771
+ return slots;
1772
+ }
1773
+ /** Apply an op to a slot's current contentlet list, preserving order and de-duplicating. */
1774
+ function applyOp(current, incoming, op) {
1775
+ switch (op) {
1776
+ case 'set':
1777
+ return dedupe(incoming);
1778
+ case 'remove': {
1779
+ const remove = new Set(incoming);
1780
+ return current.filter((id) => !remove.has(id));
1781
+ }
1782
+ case 'append':
1783
+ default:
1784
+ return dedupe([...current, ...incoming]);
1785
+ }
1786
+ }
1787
+ function dedupe(ids) {
1788
+ const seen = new Set();
1789
+ const out = [];
1790
+ for (const id of ids) {
1791
+ if (id && !seen.has(id)) {
1792
+ seen.add(id);
1793
+ out.push(id);
1794
+ }
1795
+ }
1796
+ return out;
1797
+ }
1798
+ function sameOrder(a, b) {
1799
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1800
+ }
1801
+ /** A stable key for a slot: a container can appear multiple times, so the uuid is part of it. */
1802
+ function slotKey(identifier, uuid) {
1803
+ return `${identifier} ${uuid}`;
1804
+ }
1805
+ /**
1806
+ * Resolve a caller-supplied slot address to a concrete slot on the page. Fails with the list of
1807
+ * valid slots when the address does not match, and (for a container address with no instance) with
1808
+ * the list of instances when the container occupies more than one slot.
1809
+ */
1810
+ function resolveSlot(address, slots) {
1811
+ if (typeof address === 'number') {
1812
+ const index = address - 1; // 1-based, in layout order.
1813
+ if (!Number.isInteger(address) || index < 0 || index >= slots.length) {
1814
+ throw new errors.ValidationError(`Slot index ${address} is out of range. The page has ${slots.length} slot(s): ` +
1815
+ `${describeSlots(slots)}.`);
1816
+ }
1817
+ return slots[index];
1818
+ }
1819
+ const wanted = address.container.trim();
1820
+ // Rank-filtered, like `findContainer`: keep only the slots matching at the BEST rank, so
1821
+ // an exact/path-boundary hit on `.../default/` is never diluted by a loose substring hit on
1822
+ // `.../default-banner/`. Without this, naming one container could report "appears in 2
1823
+ // slots, pass slot.instance" about two DIFFERENT containers.
1824
+ const bestRank = slots.reduce((rank, slot) => Math.max(rank, containerMatchRank(slot.identifier, wanted)), MATCH_NONE);
1825
+ const matches = bestRank === MATCH_NONE
1826
+ ? []
1827
+ : slots.filter((slot) => containerMatchRank(slot.identifier, wanted) === bestRank);
1828
+ if (matches.length === 0) {
1829
+ throw new errors.ValidationError(`No slot on the page uses container "${address.container}". Available slots: ` +
1830
+ `${describeSlots(slots)}.`);
1831
+ }
1832
+ if (address.instance !== undefined) {
1833
+ const exact = matches.find((slot) => slot.uuid === String(address.instance));
1834
+ if (!exact) {
1835
+ const instances = matches.map((slot) => `'${slot.uuid}'`).join(', ');
1836
+ throw new errors.ValidationError(`Container "${address.container}" has no slot with instance '${address.instance}'. ` +
1837
+ `Available instances: ${instances}.`);
1838
+ }
1839
+ return exact;
1840
+ }
1841
+ if (matches.length > 1) {
1842
+ const instances = matches.map((slot) => `'${slot.uuid}'`).join(', ');
1843
+ throw new errors.ValidationError(`Container "${address.container}" appears in ${matches.length} slots ` +
1844
+ `(instances: ${instances}). Pass slot.instance to disambiguate.`);
1845
+ }
1846
+ return matches[0];
1847
+ }
1848
+ /**
1849
+ * How well a layout container key matches what the caller asked for. Higher is better;
1850
+ * `NONE` means no match at all.
1851
+ *
1852
+ * Ranked rather than boolean, because the previous "either string contains the other" test
1853
+ * made resolution ORDER-DEPENDENT and therefore non-deterministic from the caller's side: a
1854
+ * layout holding both `.../containers/default/` and `.../containers/default-banner/` would
1855
+ * resolve a request for `.../default/` to whichever appeared FIRST in the object. Object keys
1856
+ * iterate in insertion order, so which container won depended on layout authoring order — and
1857
+ * in `merge` mode the tool would read the banner's contentlet list and write the merged result
1858
+ * back under it, putting content in the wrong container and potentially replacing the banner's
1859
+ * own contents.
1860
+ */
1861
+ const MATCH_NONE = 0;
1862
+ /** One string contains the other anywhere — the loosest, most ambiguous signal. */
1863
+ const MATCH_SUBSTRING = 1;
1864
+ /** The key ends with the wanted value on a `/` boundary, e.g. `.../containers/default/`. */
1865
+ const MATCH_PATH_SUFFIX = 2;
1866
+ /** Byte-identical (case-insensitively). */
1867
+ const MATCH_EXACT = 3;
1868
+ function containerMatchRank(identifier, wanted) {
1869
+ const key = identifier.toLowerCase();
1870
+ const want = wanted.toLowerCase();
1871
+ if (key === want) {
1872
+ return MATCH_EXACT;
1873
+ }
1874
+ // Compare on `/`-delimited boundaries so `default` cannot match `default-banner`. Both
1875
+ // sides are normalised for a trailing slash first, since container paths carry one and
1876
+ // callers routinely omit it.
1877
+ const keyTrimmed = key.replace(/\/+$/, '');
1878
+ const wantTrimmed = want.replace(/\/+$/, '');
1879
+ if (keyTrimmed === wantTrimmed || keyTrimmed.endsWith(`/${wantTrimmed}`)) {
1880
+ return MATCH_PATH_SUFFIX;
1881
+ }
1882
+ if (key.includes(want) || want.includes(key)) {
1883
+ return MATCH_SUBSTRING;
1884
+ }
1885
+ return MATCH_NONE;
1886
+ }
1887
+ /**
1888
+ * The single best-matching key, or a hard error when the choice is genuinely ambiguous.
1889
+ *
1890
+ * Failing loudly with the candidates beats silently picking one: writing to the wrong
1891
+ * container is not recoverable by the caller, whereas an error naming both candidates tells
1892
+ * them exactly what to disambiguate with.
1893
+ */
1894
+ function bestContainerKey(keys, wanted) {
1895
+ let bestRank = MATCH_NONE;
1896
+ let best = [];
1897
+ for (const key of keys) {
1898
+ const rank = containerMatchRank(key, wanted);
1899
+ if (rank === MATCH_NONE || rank < bestRank) {
1900
+ continue;
1901
+ }
1902
+ if (rank > bestRank) {
1903
+ bestRank = rank;
1904
+ best = [key];
1905
+ }
1906
+ else {
1907
+ best.push(key);
1908
+ }
1909
+ }
1910
+ if (best.length === 0) {
1911
+ return undefined;
1912
+ }
1913
+ if (best.length > 1) {
1914
+ throw new errors.ValidationError(`Container "${wanted}" is ambiguous — it matches ${best.length} containers on this ` +
1915
+ `page equally well: ${best.join(', ')}. Pass the full container path or id to ` +
1916
+ `disambiguate; guessing here could write content into the wrong container.`);
1917
+ }
1918
+ return best[0];
1919
+ }
1920
+ function describeSlots(slots) {
1921
+ if (slots.length === 0)
1922
+ return '(none)';
1923
+ return slots.map((slot, i) => `#${i + 1} ${slot.identifier} [uuid ${slot.uuid}]`).join('; ');
1924
+ }
1925
+ /**
1926
+ * Fetch the page's current content and flatten it into the ordered list of slots we operate on.
1927
+ *
1928
+ * The slot order and the authoritative identifier+uuid come from `layout.body.rows[].columns[]
1929
+ * .containers[]` — this is exactly what the endpoint expects back. The current contentlet ids come
1930
+ * from `containers[identifier].contentlets[uuid]`, matched tolerantly because the layout key and the
1931
+ * containers-map key can differ (shorty vs full id, host-relative vs host-qualified path) and the
1932
+ * contentlets map is historically keyed as either "1" or "uuid-1".
1933
+ */
1934
+ async function loadPageSlots(dotcms, path, siteId, languageId, variantName) {
1935
+ // Normalized before interpolation — see lib/page-path.ts for why the raw form is unsafe.
1936
+ const uri = normalizePagePath(path);
1937
+ // Read the SAME variant the write targets. Omitting `variantName` here reads DEFAULT,
1938
+ // so in `merge` mode on a non-DEFAULT variant the "before" slot map and the
1939
+ // untouched-slot preservation would be computed from DEFAULT and then written into the
1940
+ // target variant — clobbering its real contents and reporting a bogus before/after diff.
1941
+ const response = (await dotcms.request({
1942
+ path: `/api/v1/page/json${uri}`,
1943
+ query: { variantName, language_id: languageId, host_id: siteId }
1944
+ }));
1945
+ const entity = response.entity;
1946
+ if (!entity || !entity.page) {
1947
+ throw new errors.ValidationError(`Page "${path}" was not found (no page at this url for language ${languageId}).`);
1948
+ }
1949
+ const pageId = entity.page.identifier;
1950
+ if (!pageId) {
1951
+ throw new Error(`Page "${path}" resolved but has no identifier.`);
1952
+ }
1953
+ const url = entity.page.pageURI ?? entity.page.pageUrl ?? entity.page.url ?? uri;
1954
+ const containers = entity.containers ?? {};
1955
+ const rows = entity.layout?.body?.rows ?? [];
1956
+ const slots = [];
1957
+ for (const row of rows) {
1958
+ for (const column of row.columns ?? []) {
1959
+ for (const container of column.containers ?? []) {
1960
+ const identifier = container.identifier;
1961
+ const uuid = container.uuid;
1962
+ if (!identifier || !uuid) {
1963
+ continue;
1964
+ }
1965
+ slots.push({
1966
+ identifier,
1967
+ uuid,
1968
+ contentlets: currentContentlets(containers, identifier, uuid)
1969
+ });
1970
+ }
1971
+ }
1972
+ }
1973
+ return { pageId, url, slots };
1974
+ }
1975
+ /**
1976
+ * Read the contentlet ids currently in a slot from the containers map. The layout `identifier` may
1977
+ * not be byte-identical to the containers-map key, and the per-slot key may be "uuid" or "uuid-N",
1978
+ * so both lookups are tolerant.
1979
+ */
1980
+ function currentContentlets(containers, identifier, uuid) {
1981
+ const raw = findContainer$1(containers, identifier);
1982
+ if (!raw?.contentlets) {
1983
+ return [];
1984
+ }
1985
+ const map = raw.contentlets;
1986
+ const list = map[uuid] ??
1987
+ map[`uuid-${uuid}`] ??
1988
+ map[stripUuidPrefix$1(uuid)] ??
1989
+ // Last resort: a key that ends with the uuid (covers other prefixings).
1990
+ map[Object.keys(map).find((k) => stripUuidPrefix$1(k) === stripUuidPrefix$1(uuid)) ?? ''];
1991
+ if (!Array.isArray(list)) {
1992
+ return [];
1993
+ }
1994
+ return list.map((c) => c.identifier).filter((id) => Boolean(id));
1995
+ }
1996
+ function stripUuidPrefix$1(uuid) {
1997
+ return uuid.startsWith('uuid-') ? uuid.slice('uuid-'.length) : uuid;
1998
+ }
1999
+ function findContainer$1(containers, identifier) {
2000
+ if (containers[identifier]) {
2001
+ return containers[identifier];
2002
+ }
2003
+ const key = bestContainerKey(Object.keys(containers), identifier);
2004
+ return key ? containers[key] : undefined;
2005
+ }
2006
+ /**
2007
+ * POST the full container map, translating the two documented non-200 outcomes into actionable
2008
+ * messages: 409 is the net-loss conflict (someone else changed the page, or the write would
2009
+ * remove too much), and a 400 usually means a contentlet's type is not allowed in its container.
2010
+ *
2011
+ * Both branches are keyed on `HttpError.status`, not on the text of the message. The 409 used to
2012
+ * be sniffed with `/\b409\b|net content loss|conflict/i`, which both over-matched (any response
2013
+ * body happening to contain "409" or "conflict") and under-matched (a real 409 whose body says
2014
+ * neither). `cause` is threaded through so the original typed error — and with it `code` and
2015
+ * `status` — still reaches the tool boundary instead of being flattened away here.
2016
+ */
2017
+ async function postContent(dotcms, pageId, body, variantName, languageId) {
2018
+ try {
2019
+ await dotcms.request({
2020
+ method: 'POST',
2021
+ path: `/api/v1/page/${encodeURIComponent(pageId)}/content`,
2022
+ query: { variantName, language_id: languageId },
2023
+ body
2024
+ });
2025
+ }
2026
+ catch (error) {
2027
+ const message = errorMessage(error);
2028
+ // Each explanation is added to the save's own error (`withMessage`), never a new one:
2029
+ // its class and status are what tell the model whether a retry can help.
2030
+ if (error instanceof errors.HttpError && error.status === 409) {
2031
+ throw withMessage(error, 'Page content save was rejected as a net-loss conflict (the change would remove more ' +
2032
+ 'content than allowed, or the page changed underneath this write). Re-read the page ' +
2033
+ `and retry. Original error: ${message}`);
2034
+ }
2035
+ // The 400 branch the docblock has always promised but never had. This is the single
2036
+ // most common failure of this tool in a placement loop, and without naming the cause
2037
+ // the model cannot tell that the fix is a different container or a different content
2038
+ // type — so it retries the identical call and fails identically.
2039
+ if (error instanceof errors.HttpError && error.status === 400) {
2040
+ throw withMessage(error, 'Page content save was rejected (HTTP 400). The usual cause is a contentlet whose ' +
2041
+ 'CONTENT TYPE is not permitted in the container it was placed in — check the ' +
2042
+ "container's allowed content types and either place a permitted type or choose " +
2043
+ 'a different container. Retrying this same call unchanged will fail the same ' +
2044
+ `way. Original error: ${message}`);
2045
+ }
2046
+ throw withMessage(error, `Failed to save page content: ${message}`);
2047
+ }
2048
+ }
2049
+
2050
+ // A slot address: a 1-based index into the page's real slots, OR a container reference. `instance`
2051
+ // (the slot uuid) is optional only when the container occupies exactly one slot on the page.
2052
+ const slotAddress = zod.z.union([
2053
+ zod.z
2054
+ .number()
2055
+ .int()
2056
+ .positive()
2057
+ .describe('1-based index into the page’s slots, in layout order (the first slot is 1).'),
2058
+ zod.z
2059
+ .object({
2060
+ container: zod.z
2061
+ .string()
2062
+ .min(1)
2063
+ .describe('Container key as it appears on the page: a container id/shorty, a container ' +
2064
+ 'file path (e.g. "//demo.dotcms.com/application/containers/default/"), or a ' +
2065
+ 'recognizable fragment of either. "SYSTEM_CONTAINER" for the system container.'),
2066
+ instance: zod.z
2067
+ .string()
2068
+ .optional()
2069
+ .describe('Slot instance uuid (e.g. "1", "10"). Required only when the container appears ' +
2070
+ 'in more than one slot; the tool errors and lists the instances if omitted.')
2071
+ })
2072
+ .strict()
2073
+ ]);
2074
+ const definition$3 = defineTool({
2075
+ name: 'page_place_content',
2076
+ title: 'Place Content on a dotCMS Page',
2077
+ inputSchema: zod.z.object({
2078
+ path: zod.z
2079
+ .string()
2080
+ .min(1)
2081
+ .describe('Page URL path, e.g. "/about-us", host-qualified path such as "//demo.dotcms.com/about-us", or a page identifier. Bare paths/identifiers require site.'),
2082
+ // Conditionally required: a host-qualified path already supplies the site. Marking this field
2083
+ // required in JSON Schema would incorrectly reject the supported `//hostname/path` form; the
2084
+ // resolver enforces that exactly one source of site identity is present before any request.
2085
+ site: zod.z
2086
+ .string()
2087
+ .min(1)
2088
+ .optional()
2089
+ .describe('Site hostname or identifier (UUID). Required for a bare path or page identifier. Omit only when path is host-qualified as "//hostname/path".'),
2090
+ slots: zod.z
2091
+ .array(zod.z
2092
+ .object({
2093
+ slot: slotAddress,
2094
+ contentlets: zod.z
2095
+ .array(zod.z.string().min(1))
2096
+ .describe('Contentlet identifiers to place, in order. For op "remove" these are ' +
2097
+ 'the ids to remove; to clear a slot use op "set" with [].'),
2098
+ op: zod.z
2099
+ .enum(['append', 'set', 'remove'])
2100
+ .optional()
2101
+ .describe('How to combine with the slot’s current content. "append" (default) ' +
2102
+ 'adds after existing (de-duped), "set" replaces, "remove" removes.')
2103
+ })
2104
+ .strict())
2105
+ .min(1)
2106
+ .describe('One or more slot assignments applied in a single atomic write. Placing content in one ' +
2107
+ 'slot is just an array of one. Each entry: { slot, contentlets, op? }.'),
2108
+ // ── scope ──
2109
+ variantName: zod.z.string().optional().describe('Variant to write to. Default "DEFAULT".'),
2110
+ languageId: zod.z.number().int().positive().optional().describe('Language id. Default 1.'),
2111
+ mode: zod.z
2112
+ .enum(['merge', 'replace'])
2113
+ .optional()
2114
+ .describe('"merge" (default) preserves every slot you don’t touch. "replace" treats the slots ' +
2115
+ 'you pass as the complete page — every other slot is cleared.')
2116
+ }),
2117
+ description: `Place content into a dotCMS page's container slots — safely, without wiping the rest of the page.
2118
+
2119
+ The underlying endpoint (POST /api/v1/page/{pageId}/content) is a FULL replacement: it rewrites the
2120
+ page's entire container-to-contentlet map, and any slot omitted from the body is emptied. Adding one
2121
+ contentlet "the raw way" therefore silently clears every other slot. This tool removes that footgun:
2122
+ it reads the page's current content, applies your change to the addressed slot(s) only, and writes
2123
+ the COMPLETE map back — so untouched slots survive.
2124
+
2125
+ You do NOT need to call GET /api/v1/page/json first. Give the page \`path\` and address a slot by:
2126
+ - a 1-based \`slot\` index (in layout order), or
2127
+ - \`slot: { container, instance? }\` — a container id/path/fragment, plus the slot uuid when that
2128
+ container appears in more than one slot (the tool lists the instances if you omit it).
2129
+ A slot that doesn't resolve fails with the list of valid slots, so a typo can't silently no-op.
2130
+
2131
+ Ops (per entry in \`slots[]\`):
2132
+ - append (default) — add the ids after what's already in the slot (de-duplicated)
2133
+ - set — replace the slot's content with exactly these ids ([] clears the slot)
2134
+ - remove — remove these ids from the slot
2135
+
2136
+ Targeting: pass \`site\` with every bare path/identifier, or use a host-qualified path such as
2137
+ \`//awazon.dotcms.site/index\`. The tool resolves the hostname to \`host_id\` before reading the page
2138
+ and never silently falls back to the default site. If explicit and embedded sites conflict, it
2139
+ fails before making a request.
2140
+
2141
+ Shape: { path, site?, slots: [{ slot, contentlets, op? }, ...] }. One atomic write across all listed slots
2142
+ — placing content in a single slot is just an array of one: slots: [{ slot, contentlets }].
2143
+
2144
+ Modes: "merge" (default) keeps every slot you don't address. "replace" treats the slots you pass as
2145
+ the whole page and clears all others — use it only for deliberate whole-page authoring.
2146
+
2147
+ Scope: \`variantName\` (default DEFAULT) and \`languageId\` (default 1) target a specific A/B variant
2148
+ and language.
2149
+
2150
+ Returns a manifest: { pageId, site, url, variantName, languageId, mode, slots: [{ identifier, uuid,
2151
+ before[], after[], changed }], warnings[] }. \`warnings\` flags any slot that lost content and
2152
+ explains a net-loss 409 (refresh and retry). Contentlets whose type isn't allowed in a container are
2153
+ rejected by the backend; archived/missing ids are skipped and show up as a slot that didn't gain them.
2154
+
2155
+ Typical flow: create the (blank) page with \`page_create\`, then populate it with this tool. The
2156
+ contentlets themselves are created separately (e.g. via the \`execute\` tool) — this tool only places
2157
+ existing contentlets into slots.`,
2158
+ annotations: {
2159
+ readOnlyHint: false,
2160
+ destructiveHint: true,
2161
+ idempotentHint: false,
2162
+ openWorldHint: true
2163
+ },
2164
+ endpoints: PAGE_PLACE_CONTENT_ENDPOINTS,
2165
+ async handler(args, ctx) {
2166
+ const options = {
2167
+ dotcms: ctx.runtime(),
2168
+ path: args.path,
2169
+ site: args.site,
2170
+ slots: args.slots,
2171
+ variantName: args.variantName,
2172
+ languageId: args.languageId,
2173
+ mode: args.mode
2174
+ };
2175
+ return placeContent(options);
2176
+ }
2177
+ });
2178
+ /**
2179
+ * The `page_place_content` tool: places contentlets into a page's slots while preserving
2180
+ * every slot it was not asked to touch. Resolves to a {@link PagePlaceContentManifest}.
2181
+ */
2182
+ function pagePlaceContentTool(connection, options = {}) {
2183
+ return createTool(definition$3, connection, options);
2184
+ }
2185
+
2186
+ /**
2187
+ * Every endpoint `verifyPage` calls — all reads. The `page_verify` tool enforces exactly this
2188
+ * list, and `page-verify.spec.ts` checks every request against it. Add a request, add it here.
2189
+ */
2190
+ const PAGE_VERIFY_ENDPOINTS = [
2191
+ ...CONTEXT_ENDPOINTS,
2192
+ ...RESOLVE_ENDPOINTS,
2193
+ 'GET /api/v1/page/render/**'
2194
+ ];
2195
+ /** The default render mode when the caller does not name one. */
2196
+ const DEFAULT_MODE = 'LIVE';
2197
+ /**
2198
+ * The `mode` the render endpoint is sent for each {@link VerifyMode}.
2199
+ *
2200
+ * dotCMS defines a `PageMode.WORKING`, but its velocity renderer (`VelocityModeHandler`)
2201
+ * registers no handler for it, so `mode=WORKING` fails with a 500 — a NullPointerException —
2202
+ * on every page. `PREVIEW_MODE` has the same flags (working versions, anonymous permissions
2203
+ * respected) and renders. The caller still says `WORKING`, and the manifest reports it.
2204
+ */
2205
+ const RENDER_MODE = { LIVE: 'LIVE', WORKING: 'PREVIEW_MODE' };
2206
+ /** The default language id when the caller does not name one. */
2207
+ const DEFAULT_LANGUAGE_ID = 1;
2208
+ const MAX_INCLUDED_HTML_CHARS = 20_000;
2209
+ /**
2210
+ * Verify that a dotCMS page actually renders — the layer that catches a blank slot, a swallowed
2211
+ * #dotParse error, a cache-stale page, or an unpublished edit, none of which a VTL-only check
2212
+ * (/api/vtl/dynamic) can see because that runs in a request context, not a render context.
2213
+ *
2214
+ * Wraps GET /api/v1/page/render/{uri} and absorbs its sharp edges:
2215
+ * - Host: the caller passes a hostname (or nothing); the tool resolves it to a host_id. host_id is
2216
+ * required only for a NON-default site — omit `site` and the default site is used.
2217
+ * - 200 != rendered: #dotParse swallows a VTL error into an empty HTTP 200, so a 200 with an empty
2218
+ * body is a failure, surfaced as pageRendered=false, not success.
2219
+ * - Two rendered layers that can disagree: each slot's rendered HTML (containers[].rendered[uuid])
2220
+ * vs. the assembled page.rendered. The disagreement IS the diagnosis (cache-stale) as opposed to
2221
+ * an empty slot (VTL error or no content).
2222
+ *
2223
+ * Returns a structured verdict — per-slot classification plus a one-line diagnosis with the next
2224
+ * action — instead of two JSON blobs the caller has to compare by hand.
2225
+ */
2226
+ async function verifyPage(options) {
2227
+ const mode = options.mode ?? DEFAULT_MODE;
2228
+ const languageId = options.languageId ?? DEFAULT_LANGUAGE_ID;
2229
+ // Resolve the site to a host_id ONLY when one was given. Absent → the backend uses the default
2230
+ // site, and we send no host_id (the render endpoint's documented default behavior).
2231
+ const resolvedSite = options.site ? await resolveSite(options.dotcms, options.site) : undefined;
2232
+ // Normalized BEFORE it reaches the request URL: `/a/../b` used to render `/b` while the
2233
+ // manifest reported `/a/../b`, and a `#` silently truncated the path.
2234
+ const uri = normalizePagePath(options.path);
2235
+ const query = {
2236
+ language_id: languageId,
2237
+ mode: RENDER_MODE[mode]
2238
+ };
2239
+ if (resolvedSite) {
2240
+ query['host_id'] = resolvedSite.identifier;
2241
+ }
2242
+ const { status, body } = await renderPage(options.dotcms, uri, query);
2243
+ return buildManifest({
2244
+ path: options.path,
2245
+ uri,
2246
+ siteLabel: resolvedSite?.hostname ?? '(default)',
2247
+ mode,
2248
+ languageId,
2249
+ status,
2250
+ body,
2251
+ includeHtml: options.includeHtml ?? false
2252
+ });
2253
+ }
2254
+ /** Assemble the verdict manifest from a render response. Pure — no I/O, so it is unit-testable. */
2255
+ function buildManifest(input) {
2256
+ const entity = input.body.entity ?? {};
2257
+ const page = entity.page ?? {};
2258
+ const containers = entity.containers ?? {};
2259
+ const rows = entity.layout?.body?.rows ?? [];
2260
+ const pageHtml = page.rendered ?? '';
2261
+ const pageBytes = byteLength(pageHtml);
2262
+ const pageRendered = !isBlank(pageHtml);
2263
+ const url = page.pageURI ?? page.pageUrl ?? page.url ?? input.uri;
2264
+ const slots = [];
2265
+ for (const row of rows) {
2266
+ for (const column of row.columns ?? []) {
2267
+ for (const layoutContainer of column.containers ?? []) {
2268
+ const container = layoutContainer.identifier;
2269
+ const uuid = layoutContainer.uuid;
2270
+ if (!container || !uuid) {
2271
+ continue;
2272
+ }
2273
+ slots.push(classifySlot(containers, container, uuid, pageHtml));
2274
+ }
2275
+ }
2276
+ }
2277
+ const urlMap = resolveUrlMap(entity.urlContentMap);
2278
+ const warnings = collectWarnings(slots, pageRendered, input.status, urlMap, input.mode);
2279
+ const diagnosis = diagnose(slots, pageRendered, input.status, urlMap, input.mode);
2280
+ const manifest = {
2281
+ path: input.path,
2282
+ url,
2283
+ site: input.siteLabel,
2284
+ mode: input.mode,
2285
+ languageId: input.languageId,
2286
+ httpStatus: input.status,
2287
+ pageRendered,
2288
+ pageBytes,
2289
+ slots,
2290
+ urlMap,
2291
+ warnings,
2292
+ diagnosis
2293
+ };
2294
+ if (input.includeHtml) {
2295
+ manifest.html = {
2296
+ content: pageHtml.slice(0, MAX_INCLUDED_HTML_CHARS),
2297
+ totalChars: pageHtml.length,
2298
+ totalBytes: pageBytes,
2299
+ truncated: pageHtml.length > MAX_INCLUDED_HTML_CHARS,
2300
+ limit: MAX_INCLUDED_HTML_CHARS
2301
+ };
2302
+ }
2303
+ return manifest;
2304
+ }
2305
+ /** Classify one slot from the render response. */
2306
+ function classifySlot(containers, container, uuid, pageHtml) {
2307
+ const raw = findContainer(containers, container);
2308
+ const html = lookupByUuid(raw?.rendered, uuid) ?? '';
2309
+ const contentCount = (lookupByUuid(raw?.contentlets, uuid) ?? []).length;
2310
+ const rendered = !isBlank(html);
2311
+ const pageRendered = !isBlank(pageHtml);
2312
+ const bytes = byteLength(html);
2313
+ let verdict;
2314
+ if (rendered) {
2315
+ // The slot produced HTML. If the assembled page did NOT, that is a page-level cache problem.
2316
+ if (!pageRendered) {
2317
+ verdict = 'cache-stale';
2318
+ }
2319
+ else if (!slotEvidenceAppearsInPage(html, pageHtml)) {
2320
+ verdict = 'not-assembled';
2321
+ }
2322
+ else {
2323
+ verdict = 'ok';
2324
+ }
2325
+ }
2326
+ else if (contentCount > 0) {
2327
+ // Content is placed but rendered to nothing → the container/widget VTL failed.
2328
+ verdict = 'empty-vtl-error';
2329
+ }
2330
+ else {
2331
+ // Nothing placed → a placement gap, not a code bug.
2332
+ verdict = 'empty-no-content';
2333
+ }
2334
+ return { container, uuid, rendered, bytes, contentCount, verdict };
2335
+ }
2336
+ function resolveUrlMap(urlContentMap) {
2337
+ // The field is present (as null/absent) on every page; a non-null value with an identifier means
2338
+ // the path resolved to a concrete detail contentlet via $URLMapContent.
2339
+ if (urlContentMap === undefined) {
2340
+ return null;
2341
+ }
2342
+ if (urlContentMap === null) {
2343
+ // Present-but-null happens on URL-map-capable responses that did not resolve. We can't tell
2344
+ // that apart from a plain page here, so treat absence of a match as "no url map".
2345
+ return null;
2346
+ }
2347
+ const contentletId = urlContentMap.identifier;
2348
+ return contentletId ? { resolved: true, contentletId } : { resolved: false };
2349
+ }
2350
+ function collectWarnings(slots, pageRendered, status, urlMap, mode) {
2351
+ const warnings = [];
2352
+ if (status === 200 && !pageRendered) {
2353
+ warnings.push('HTTP 200 but page.rendered is empty — a #dotParse VTL error was likely swallowed into ' +
2354
+ 'an empty body. 200 does NOT mean the page rendered.');
2355
+ }
2356
+ for (const slot of slots) {
2357
+ if (slot.verdict === 'empty-vtl-error') {
2358
+ warnings.push(`Slot ${slot.container} [uuid ${slot.uuid}] has ${slot.contentCount} contentlet(s) ` +
2359
+ 'placed but rendered empty — the container/widget VTL failed. Re-run that VTL ' +
2360
+ 'through POST /api/vtl/dynamic to get the parse/eval error with line/column.');
2361
+ }
2362
+ else if (slot.verdict === 'empty-no-content') {
2363
+ warnings.push(`Slot ${slot.container} [uuid ${slot.uuid}] is empty — no content placed. Use ` +
2364
+ 'page_place_content to add contentlets.');
2365
+ }
2366
+ else if (slot.verdict === 'cache-stale') {
2367
+ warnings.push(`Slot ${slot.container} [uuid ${slot.uuid}] rendered content but the page did not — ` +
2368
+ 'page-level cache is stale. Set cachettl "0" and re-publish the page.');
2369
+ }
2370
+ else if (slot.verdict === 'not-assembled') {
2371
+ warnings.push(`Slot ${slot.container} [uuid ${slot.uuid}] rendered content, but its HTML is ` +
2372
+ 'absent from the assembled page — the template/theme layout loop is not ' +
2373
+ "emitting this container. Inspect the theme's row/column loop and use " +
2374
+ '$render.eval($column.draw()).');
2375
+ }
2376
+ }
2377
+ if (urlMap && !urlMap.resolved) {
2378
+ warnings.push('This looks like a URL-mapped page but the path did not resolve to a detail contentlet ' +
2379
+ '($URLMapContent hit the no-match branch). Check the detail-page slug.');
2380
+ }
2381
+ if (mode === 'WORKING' && pageRendered) {
2382
+ warnings.push('Rendered in WORKING mode — this reflects unpublished edits. Re-verify with mode "LIVE" ' +
2383
+ 'to confirm what the public actually sees.');
2384
+ }
2385
+ return warnings;
2386
+ }
2387
+ /** Derive the one-line diagnosis + next action from the worst thing found. */
2388
+ function diagnose(slots, pageRendered, status, urlMap, mode) {
2389
+ if (status !== 200) {
2390
+ return `Render returned HTTP ${status}. The page did not render — check the path, site, and that the page exists in ${mode} mode.`;
2391
+ }
2392
+ if (urlMap && !urlMap.resolved) {
2393
+ return 'URL-mapped page did not resolve to a detail contentlet — the slug hit the no-match branch. Verify the detail-page slug exists and is published.';
2394
+ }
2395
+ const vtlError = slots.find((s) => s.verdict === 'empty-vtl-error');
2396
+ if (vtlError) {
2397
+ return `Slot ${vtlError.container} [uuid ${vtlError.uuid}] has content but rendered empty (VTL error). Next: re-run that container's VTL through POST /api/vtl/dynamic to get the parse/eval error.`;
2398
+ }
2399
+ const stale = slots.find((s) => s.verdict === 'cache-stale');
2400
+ if (stale || (!pageRendered && slots.some((s) => s.rendered))) {
2401
+ return 'Slots rendered but the assembled page.rendered is empty — page-level cache is stale. Next: set cachettl "0" and re-publish the page.';
2402
+ }
2403
+ const notAssembled = slots.find((s) => s.verdict === 'not-assembled');
2404
+ if (notAssembled) {
2405
+ return `Slot ${notAssembled.container} [uuid ${notAssembled.uuid}] rendered in isolation but is absent from the assembled page. Next: inspect the theme's row/column loop and ensure it emits $render.eval($column.draw()).`;
2406
+ }
2407
+ if (!pageRendered) {
2408
+ return `HTTP 200 but the page rendered empty (a swallowed #dotParse error, or nothing is placed). Next: check per-slot verdicts${mode === 'LIVE' ? ' and confirm the page is published' : ''}.`;
2409
+ }
2410
+ const noContent = slots.filter((s) => s.verdict === 'empty-no-content');
2411
+ if (noContent.length > 0) {
2412
+ return `Page rendered, but ${noContent.length} slot(s) are empty (no content placed). Next: use page_place_content to fill them if intended.`;
2413
+ }
2414
+ // Zero slots is NOT a clean bill of health. `collectWarnings` iterates `slots`, so an
2415
+ // empty list yields no warnings, and the sentence below would interpolate to "all 0
2416
+ // slot(s) produced content" — the tool built to catch blank slots declaring success over
2417
+ // a page whose layout it could not read. Legacy/advanced templates, and any response with
2418
+ // `entity.containers` populated but no `entity.layout.body.rows`, land exactly here.
2419
+ if (slots.length === 0) {
2420
+ return (`The page rendered in ${mode} mode, but NO slots could be parsed from its layout, ` +
2421
+ `so per-slot verification did not run and this is not a clean result. The template ` +
2422
+ `may be legacy/advanced (no layout.body.rows), or the response shape may be ` +
2423
+ `unexpected. Next: inspect the page's template directly — do not read this as "the ` +
2424
+ `page is fine".`);
2425
+ }
2426
+ return `Page rendered successfully in ${mode} mode — all ${slots.length} slot(s) produced content.`;
2427
+ }
2428
+ /** GET the render endpoint, capturing the HTTP status even on a non-2xx so verdicts can use it. */
2429
+ async function renderPage(dotcms, uri, query) {
2430
+ try {
2431
+ const body = (await dotcms.request({
2432
+ path: `/api/v1/page/render${uri}`,
2433
+ query
2434
+ }));
2435
+ return { status: 200, body };
2436
+ }
2437
+ catch (error) {
2438
+ // A 404/403/etc. is a legitimate verify outcome, not a tool failure — surface it in the
2439
+ // manifest with an empty body so the diagnosis explains the HTTP result.
2440
+ //
2441
+ // ONLY a real HttpError qualifies. The previous version fell back to scraping the
2442
+ // first three-digit run out of the message, which turned any error containing a
2443
+ // number into a fabricated verdict: `connect ETIMEDOUT 10.0.0.5:443` became status
2444
+ // 443, and `buildManifest` then stated flatly that the page did not render and to
2445
+ // check the path, site and existence — about a page that is very likely fine. The
2446
+ // model's next move is to "fix" something that was never broken. A transport failure
2447
+ // must surface AS a transport failure.
2448
+ if (error instanceof errors.HttpError) {
2449
+ return { status: error.status, body: {} };
2450
+ }
2451
+ throw error;
2452
+ }
2453
+ }
2454
+ /**
2455
+ * The layout `identifier` may not be byte-identical to the containers-map key (shorty vs full id,
2456
+ * host-relative vs host-qualified path). Match tolerantly, same as page_place_content.
2457
+ */
2458
+ function findContainer(containers, identifier) {
2459
+ if (containers[identifier]) {
2460
+ return containers[identifier];
2461
+ }
2462
+ const key = Object.keys(containers).find((k) => containerMatches(k, identifier));
2463
+ return key ? containers[key] : undefined;
2464
+ }
2465
+ function containerMatches(a, b) {
2466
+ if (a === b)
2467
+ return true;
2468
+ const la = a.toLowerCase();
2469
+ const lb = b.toLowerCase();
2470
+ return la === lb || la.includes(lb) || lb.includes(la);
2471
+ }
2472
+ /** The per-slot maps are historically keyed as "1" or "uuid-1"; look up tolerantly. */
2473
+ function lookupByUuid(map, uuid) {
2474
+ if (!map) {
2475
+ return undefined;
2476
+ }
2477
+ const direct = map[uuid] ?? map[`uuid-${uuid}`] ?? map[stripUuidPrefix(uuid)];
2478
+ if (direct !== undefined) {
2479
+ return direct;
2480
+ }
2481
+ const key = Object.keys(map).find((k) => stripUuidPrefix(k) === stripUuidPrefix(uuid));
2482
+ return key ? map[key] : undefined;
2483
+ }
2484
+ function stripUuidPrefix(uuid) {
2485
+ return uuid.startsWith('uuid-') ? uuid.slice('uuid-'.length) : uuid;
2486
+ }
2487
+ /**
2488
+ * Confirm assembly using several short, normalized pieces of evidence rather than an exact HTML
2489
+ * substring. Theme assembly may rewrite whitespace and entity encoding, so exact matching would
2490
+ * label healthy pages as broken. If no reliable evidence can be extracted, return true (unknown)
2491
+ * instead of issuing a false failure.
2492
+ */
2493
+ function slotEvidenceAppearsInPage(slotHtml, pageHtml) {
2494
+ const candidates = assemblyEvidence(slotHtml);
2495
+ if (candidates.length === 0) {
2496
+ return true;
2497
+ }
2498
+ const normalizedPage = normalizeEvidence(pageHtml);
2499
+ return candidates.some((candidate) => normalizedPage.includes(candidate));
2500
+ }
2501
+ function assemblyEvidence(html) {
2502
+ const candidates = [];
2503
+ const attributePattern = /\b(?:id|data-[\w:-]+)\s*=\s*["']([^"']+)["']/gi;
2504
+ for (const match of html.matchAll(attributePattern)) {
2505
+ addEvidence(candidates, match[1], 6);
2506
+ }
2507
+ const classPattern = /\bclass\s*=\s*["']([^"']+)["']/gi;
2508
+ for (const match of html.matchAll(classPattern)) {
2509
+ for (const className of match[1].split(/\s+/)) {
2510
+ addEvidence(candidates, className, 8);
2511
+ }
2512
+ }
2513
+ const visibleText = decodeEntities(html
2514
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
2515
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
2516
+ .replace(/<[^>]+>/g, ' '));
2517
+ for (const textRun of visibleText.split(/\s{2,}|[.!?]\s+/)) {
2518
+ addEvidence(candidates, textRun, 12);
2519
+ }
2520
+ return [...new Set(candidates)].slice(0, 12);
2521
+ }
2522
+ function addEvidence(target, raw, minimumLength) {
2523
+ const normalized = normalizeEvidence(raw);
2524
+ if (normalized.length >= minimumLength) {
2525
+ target.push(normalized);
2526
+ }
2527
+ }
2528
+ function normalizeEvidence(value) {
2529
+ return decodeEntities(value).replace(/\s+/g, ' ').trim().toLowerCase();
2530
+ }
2531
+ function decodeEntities(value) {
2532
+ const named = {
2533
+ amp: '&',
2534
+ apos: "'",
2535
+ gt: '>',
2536
+ lt: '<',
2537
+ nbsp: ' ',
2538
+ quot: '"'
2539
+ };
2540
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, token) => {
2541
+ if (token[0] !== '#') {
2542
+ return named[token.toLowerCase()] ?? entity;
2543
+ }
2544
+ const hex = token[1]?.toLowerCase() === 'x';
2545
+ const codePoint = Number.parseInt(token.slice(hex ? 2 : 1), hex ? 16 : 10);
2546
+ return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
2547
+ });
2548
+ }
2549
+ /** Empty or whitespace-only (comments/blank lines count as "not rendered" for verdict purposes). */
2550
+ function isBlank(html) {
2551
+ return html.trim().length === 0;
2552
+ }
2553
+ function byteLength(html) {
2554
+ // Byte length, not char length — multibyte content should report its real size.
2555
+ return typeof TextEncoder !== 'undefined'
2556
+ ? new TextEncoder().encode(html).length
2557
+ : Buffer.byteLength(html, 'utf8');
2558
+ }
2559
+
2560
+ const definition$2 = defineTool({
2561
+ name: 'page_verify',
2562
+ title: 'Verify a dotCMS Page Renders',
2563
+ inputSchema: zod.z.object({
2564
+ path: zod.z
2565
+ .string()
2566
+ .min(1)
2567
+ .describe('Page URL path, e.g. "/about-us". For a URL-mapped detail page, pass a concrete slug ' +
2568
+ '(e.g. "/blog/my-post") — the tool reports whether $URLMapContent resolved.'),
2569
+ site: zod.z
2570
+ .string()
2571
+ .optional()
2572
+ .describe('Site to render against — hostname or identifier (UUID). Omit for the default site. ' +
2573
+ 'Only a NON-default site needs this; the tool resolves it to the host_id the ' +
2574
+ 'endpoint requires.'),
2575
+ languageId: zod.z.number().int().positive().optional().describe('Language id. Default 1.'),
2576
+ mode: zod.z
2577
+ .enum(['LIVE', 'WORKING'])
2578
+ .optional()
2579
+ .describe('Render mode. "LIVE" (default) = what the public sees (published). "WORKING" = latest ' +
2580
+ 'saved, for a pre-publish check. An unpublished edit renders stale in LIVE.'),
2581
+ includeHtml: zod.z
2582
+ .boolean()
2583
+ .optional()
2584
+ .describe('Include a bounded prefix of the assembled page HTML for diagnosis. Default false; returns at most 20,000 characters with truncation metadata.')
2585
+ }),
2586
+ description: `Verify that a dotCMS page actually RENDERS — catch a blank slot, a swallowed VTL error, a cache-stale page, or an unpublished edit.
2587
+
2588
+ This is the render-verification layer, distinct from VTL validation (POST /api/vtl/dynamic). VTL
2589
+ validation runs in a REQUEST context and is structurally blind to $URLMapContent, $CONTENTLETS,
2590
+ $dotContentMap, $dotTheme, and per-container vars. Only a real render exercises the full
2591
+ page-assembly pipeline — so this is the only layer that catches a slot that came out empty, a
2592
+ #dotParse error that got swallowed, or a page serving stale from cache.
2593
+
2594
+ It wraps GET /api/v1/page/render/{uri} and absorbs its sharp edges:
2595
+ - Host: pass a hostname in \`site\` (or nothing). The tool resolves it to the host_id the endpoint
2596
+ needs. host_id is required only for a NON-default site; omit \`site\` for the default site.
2597
+ - 200 != rendered: #dotParse swallows a VTL error into an empty HTTP 200. A 200 with an empty body
2598
+ is a FAILURE here (pageRendered=false), not success.
2599
+ - Two rendered layers that can disagree: each slot's HTML (containers[].rendered[uuid]) vs. the
2600
+ assembled page.rendered. Their disagreement IS the diagnosis.
2601
+
2602
+ Per-slot \`verdict\`:
2603
+ - ok — the slot produced rendered HTML.
2604
+ - empty-vtl-error — content is placed but the slot rendered empty → the container/widget VTL
2605
+ failed. The fix lives in the OTHER layer: re-run that container's VTL through
2606
+ POST /api/vtl/dynamic to get the parse/eval error with line/column.
2607
+ - empty-no-content — the slot resolved but has no content placed (a placement gap, not a code bug).
2608
+ Use page_place_content to fill it.
2609
+ - cache-stale — the slot rendered HTML but page.rendered is empty → page-level cache. Set
2610
+ cachettl "0" and re-publish.
2611
+ - not-assembled — the slot rendered in isolation but distinctive HTML evidence is absent from
2612
+ page.rendered → inspect the theme row/column loop and emit
2613
+ $render.eval($column.draw()).
2614
+
2615
+ LIVE vs WORKING: an unpublished edit renders stale in LIVE. Use mode "WORKING" for a pre-publish
2616
+ check; the result flags that it reflects unpublished edits.
2617
+
2618
+ URL-mapped pages: for a detail slug, \`urlMap\` reports whether $URLMapContent resolved to a concrete
2619
+ contentlet (vs. the no-match/404 branch).
2620
+
2621
+ Returns a manifest: { path, url, site, mode, languageId, httpStatus, pageRendered, pageBytes,
2622
+ slots: [{ container, uuid, rendered, bytes, contentCount, verdict }], urlMap, warnings, diagnosis }.
2623
+ The \`diagnosis\` is a one-line summary plus the next action to take — read it first.
2624
+
2625
+ Set \`includeHtml: true\` to add \`html: { content, totalChars, totalBytes, truncated, limit }\`.
2626
+ The content is capped at 20,000 characters so a diagnostic call cannot flood model context.
2627
+
2628
+ Out of scope: visual/screenshot checks, accessibility, performance, multi-page crawl (one page per
2629
+ call). This verifies DEFAULT-variant rendering — the render endpoint does not take a variant.`,
2630
+ annotations: {
2631
+ readOnlyHint: true,
2632
+ destructiveHint: false,
2633
+ idempotentHint: true,
2634
+ openWorldHint: true
2635
+ },
2636
+ endpoints: PAGE_VERIFY_ENDPOINTS,
2637
+ async handler(args, ctx) {
2638
+ const options = {
2639
+ dotcms: ctx.runtime(),
2640
+ path: args.path,
2641
+ site: args.site,
2642
+ languageId: args.languageId,
2643
+ mode: args.mode,
2644
+ includeHtml: args.includeHtml
2645
+ };
2646
+ return verifyPage(options);
2647
+ }
2648
+ });
2649
+ /**
2650
+ * The `page_verify` tool: renders a page and diagnoses each slot — empty, swallowed VTL
2651
+ * error, stale cache, not assembled. Read-only. Resolves to a {@link VerifyPageManifest}.
2652
+ */
2653
+ function pageVerifyTool(connection, options = {}) {
2654
+ return createTool(definition$2, connection, options);
2655
+ }
2656
+
2657
+ /**
2658
+ * What `uploadAssets` and `downloadAssets` share: the entries their manifests are made of, the
2659
+ * dotCMS path form both accept, and how a manifest is finished.
2660
+ */
2661
+ function normalizeDotCMSPath(input) {
2662
+ const value = input.trim().replace(/\/+$/, '');
2663
+ if (value.startsWith('//')) {
2664
+ const firstSlash = value.slice(2).indexOf('/');
2665
+ if (firstSlash < 0) {
2666
+ throw new errors.ValidationError(`Site-qualified path "${input}" must include a path`);
2667
+ }
2668
+ return { siteQualified: value, path: value.slice(firstSlash + 2) };
2669
+ }
2670
+ if (!value.startsWith('/')) {
2671
+ throw new errors.ValidationError(`dotCMS path "${input}" must start with "/" or "//host/"`);
2672
+ }
2673
+ return { path: value };
2674
+ }
2675
+ function sumBytes(files) {
2676
+ return files.reduce((sum, file) => sum + file.bytes, 0);
2677
+ }
2678
+ function sortManifest(manifest) {
2679
+ const byPath = (a, b) => a.path.localeCompare(b.path);
2680
+ manifest.files.sort(byPath);
2681
+ manifest.failures.sort(byPath);
2682
+ manifest.skipped?.sort(byPath);
2683
+ manifest.notLive?.sort(byPath);
2684
+ return manifest;
2685
+ }
2686
+
2687
+ /**
2688
+ * The `include` glob filter the asset operations share: `upload_assets` matches it against
2689
+ * local paths relative to `src`, `download_assets` against dotCMS paths relative to the folder.
2690
+ */
2691
+ /**
2692
+ * Split an `include` string into its comma-separated patterns — but NOT on commas inside a brace
2693
+ * group, so `*.{png,webp,jpg}` stays one pattern while `*.vtl,*.scss` is two. Exported for tests.
2694
+ */
2695
+ function splitIncludePatterns(include) {
2696
+ if (!include) {
2697
+ return [];
2698
+ }
2699
+ const patterns = [];
2700
+ let current = '';
2701
+ let braceDepth = 0;
2702
+ for (const ch of include) {
2703
+ if (ch === '{') {
2704
+ braceDepth++;
2705
+ current += ch;
2706
+ }
2707
+ else if (ch === '}') {
2708
+ braceDepth = Math.max(0, braceDepth - 1);
2709
+ current += ch;
2710
+ }
2711
+ else if (ch === ',' && braceDepth === 0) {
2712
+ patterns.push(current);
2713
+ current = '';
2714
+ }
2715
+ else {
2716
+ current += ch;
2717
+ }
2718
+ }
2719
+ patterns.push(current);
2720
+ return patterns.map((p) => p.trim()).filter(Boolean);
2721
+ }
2722
+ /**
2723
+ * Build a matcher over relative POSIX paths from a comma-separated `include` string. A file matches
2724
+ * if it matches ANY pattern. No `include` → matches everything. Exported for tests.
2725
+ *
2726
+ * Supports the glob features callers reasonably assume from a standard glob:
2727
+ * - `*` matches any run of chars WITHIN a path segment (does not cross `/`)
2728
+ * - `**` matches across segments, including zero (so a leading globstar also matches a top-level file)
2729
+ * - `?` matches a single non-`/` char
2730
+ * - `{png,webp,jpg}` brace expansion (alternation)
2731
+ * A pattern with no `/` matches the file's basename anywhere in the tree; a pattern with a `/` is
2732
+ * anchored at the root of `src`.
2733
+ */
2734
+ function includeMatcher(include) {
2735
+ const patterns = splitIncludePatterns(include);
2736
+ if (!patterns.length) {
2737
+ return () => true;
2738
+ }
2739
+ // Compile each pattern ONCE here, not per-file — this matcher runs on every asset/file.
2740
+ const regexes = patterns.map(globToRegExp);
2741
+ return (rel) => regexes.some((re) => re.test(rel));
2742
+ }
2743
+ /**
2744
+ * Compile a single glob pattern to a RegExp with a single left-to-right character scan.
2745
+ *
2746
+ * A scanner (rather than chained `.replace()` passes) is used deliberately: it has no ordering
2747
+ * hazard between `**` and `*`, needs no placeholder sentinels, and each glob token emits its regex
2748
+ * exactly once. The old chained-replace version turned every `*` into `[^/]*`, so a `**` + `/*.png`
2749
+ * pattern compiled to "exactly one subdirectory" and silently matched nothing for top-level files.
2750
+ *
2751
+ * Tokens:
2752
+ * - `**` (with an optional adjacent `/`) crosses directory boundaries, matching zero or more
2753
+ * segments, so a leading `**` also matches a top-level file.
2754
+ * - `*` matches any run of chars within one segment (never crosses `/`).
2755
+ * - `?` matches a single non-`/` char.
2756
+ * - `{png,webp,jpg}` expands to alternation `(?:png|webp|jpg)` (nested wildcards are honored).
2757
+ * A pattern containing `/` is anchored at the root of `src`; otherwise it matches a basename
2758
+ * anywhere in the tree.
2759
+ */
2760
+ function globToRegExp(pattern) {
2761
+ const normalized = pattern.split(node_path.sep).join(node_path.posix.sep);
2762
+ const anchored = normalized.includes('/');
2763
+ const source = compileGlob(normalized, 0, normalized.length);
2764
+ return new RegExp(`${anchored ? '^' : '(^|/)'}${source}$`, 'i');
2765
+ }
2766
+ /** Regex-escape a single literal character. */
2767
+ function escapeRegexChar(ch) {
2768
+ return /[|\\{}()[\]^$+.*?]/.test(ch) ? `\\${ch}` : ch;
2769
+ }
2770
+ /**
2771
+ * Translate the glob in `input[start..end)` to a regex source string. Recurses into brace groups so
2772
+ * `{a*,b}` honors the wildcard inside each alternative.
2773
+ */
2774
+ function compileGlob(input, start, end) {
2775
+ let out = '';
2776
+ let i = start;
2777
+ while (i < end) {
2778
+ const ch = input[i];
2779
+ if (ch === '*') {
2780
+ if (input[i + 1] === '*') {
2781
+ // `**` crosses directory boundaries. Consume it plus one adjacent `/` (leading or
2782
+ // trailing) and emit an optional "any number of full segments" fragment.
2783
+ i += 2;
2784
+ const trailing = i >= end;
2785
+ if (input[i] === '/') {
2786
+ i++;
2787
+ }
2788
+ else if (!trailing && out.endsWith('/')) {
2789
+ out = out.slice(0, -1);
2790
+ }
2791
+ if (trailing) {
2792
+ // A globstar with nothing after it — `themes/**` — means "everything
2793
+ // below here", so it has to be able to match a final FILENAME segment.
2794
+ // The general fragment below cannot: it only ever ends at a `/`, so
2795
+ // `themes/**` compiled to `^themes(?:.*/)?$` and matched nothing but the
2796
+ // bare string `themes`. Since `dir/**` is the common idiom, users writing
2797
+ // it hit the "matched 0 of N files, check the glob syntax" warning while
2798
+ // their syntax was perfectly reasonable.
2799
+ out += '.*';
2800
+ }
2801
+ else {
2802
+ out += '(?:.*/)?';
2803
+ }
2804
+ }
2805
+ else {
2806
+ out += '[^/]*';
2807
+ i++;
2808
+ }
2809
+ }
2810
+ else if (ch === '?') {
2811
+ out += '[^/]';
2812
+ i++;
2813
+ }
2814
+ else if (ch === '{') {
2815
+ const close = input.indexOf('}', i);
2816
+ if (close === -1 || close >= end) {
2817
+ // Unbalanced brace: treat the `{` literally rather than throwing.
2818
+ out += '\\{';
2819
+ i++;
2820
+ }
2821
+ else {
2822
+ const alternatives = splitTopLevelCommas(input.slice(i + 1, close)).map((alt) => compileGlob(alt, 0, alt.length));
2823
+ out += `(?:${alternatives.join('|')})`;
2824
+ i = close + 1;
2825
+ }
2826
+ }
2827
+ else {
2828
+ out += escapeRegexChar(ch);
2829
+ i++;
2830
+ }
2831
+ }
2832
+ return out;
2833
+ }
2834
+ /** Split on commas that are NOT inside a nested brace group (for brace-group alternatives). */
2835
+ function splitTopLevelCommas(group) {
2836
+ const parts = [];
2837
+ let current = '';
2838
+ let depth = 0;
2839
+ for (const ch of group) {
2840
+ if (ch === '{') {
2841
+ depth++;
2842
+ current += ch;
2843
+ }
2844
+ else if (ch === '}') {
2845
+ depth = Math.max(0, depth - 1);
2846
+ current += ch;
2847
+ }
2848
+ else if (ch === ',' && depth === 0) {
2849
+ parts.push(current);
2850
+ current = '';
2851
+ }
2852
+ else {
2853
+ current += ch;
2854
+ }
2855
+ }
2856
+ parts.push(current);
2857
+ return parts;
2858
+ }
2859
+
2860
+ /**
2861
+ * The local-filesystem boundary for the asset operations.
2862
+ *
2863
+ * `src` and `dest` come from the model. In the local stdio server that is the user's own agent
2864
+ * on the user's own machine, but in a hosted MCP server or a multi-user agent it is anyone who
2865
+ * can reach the tool — and without a boundary `upload_assets` reads any directory the process
2866
+ * can read (then readable back through `execute`) and `download_assets` writes into any
2867
+ * directory it can write. Every check here compares REAL paths, after symlinks resolve: a
2868
+ * symlink inside the root pointing out of it is outside, and `/tmp` is `/private/tmp` on macOS.
2869
+ */
2870
+ function isInside(root, target) {
2871
+ const back = node_path.relative(root, target);
2872
+ return !(back === '..' || back.startsWith(`..${node_path.sep}`) || node_path.isAbsolute(back));
2873
+ }
2874
+ /**
2875
+ * The real path of `path`, or of its nearest existing ancestor when it does not exist yet. The
2876
+ * tail that does not exist cannot contain a symlink, so the ancestor decides containment —
2877
+ * which is what lets a check run BEFORE `mkdir` creates anything.
2878
+ */
2879
+ async function realpathOfNearest(path) {
2880
+ let current = node_path.resolve(path);
2881
+ for (;;) {
2882
+ try {
2883
+ return await promises.realpath(current);
2884
+ }
2885
+ catch (error) {
2886
+ const parent = node_path.dirname(current);
2887
+ if (error.code !== 'ENOENT' || parent === current) {
2888
+ throw error;
2889
+ }
2890
+ current = parent;
2891
+ }
2892
+ }
2893
+ }
2894
+ /**
2895
+ * Refuse `path` when it resolves outside `root`. No `root` means no boundary — for trusted,
2896
+ * direct callers of the operations; the tools always set one.
2897
+ */
2898
+ async function assertInsideRoot(root, label, path) {
2899
+ if (root === undefined) {
2900
+ return;
2901
+ }
2902
+ const rootReal = await promises.realpath(node_path.resolve(root));
2903
+ if (!isInside(rootReal, await realpathOfNearest(path))) {
2904
+ throw new errors.ValidationError(`\`${label}\` must be inside ${rootReal}, the directory this tool is allowed to use, ` +
2905
+ `and ${path} is not (symlinks are resolved before checking).`);
2906
+ }
2907
+ }
2908
+ /**
2909
+ * Refuse to write `file` when it would land outside `root`: its directory resolves elsewhere,
2910
+ * or the file itself is a symlink — `writeFile` follows one, so a link planted inside the
2911
+ * destination would otherwise redirect the write anywhere.
2912
+ */
2913
+ async function assertWritableInsideRoot(root, file) {
2914
+ if (root === undefined) {
2915
+ return;
2916
+ }
2917
+ try {
2918
+ if ((await promises.lstat(file)).isSymbolicLink()) {
2919
+ throw new errors.ValidationError(`Refusing to write through a symlink: ${file}`);
2920
+ }
2921
+ }
2922
+ catch (error) {
2923
+ if (error.code !== 'ENOENT') {
2924
+ throw error;
2925
+ }
2926
+ }
2927
+ await assertInsideRoot(root, 'dest', node_path.dirname(file));
2928
+ }
2929
+
2930
+ /**
2931
+ * Every endpoint `uploadAssets` calls — the `upload_assets` tool enforces exactly this list,
2932
+ * and `upload-assets.spec.ts` checks every request against it. Add a request, add it here.
2933
+ */
2934
+ const UPLOAD_ASSETS_ENDPOINTS = [
2935
+ // The upload itself: `publish` picks which one.
2936
+ 'PUT /api/v2/assets/publish',
2937
+ 'PUT /api/v2/assets/save',
2938
+ // `verify`: read each uploaded asset's live status, and re-fire PUBLISH for one that is
2939
+ // not live yet (see verifyLive).
2940
+ 'GET /api/v1/content/{identifier}',
2941
+ 'PUT /api/v1/workflow/actions/default/fire/PUBLISH'
2942
+ ];
2943
+ const MIME_BY_EXT = {
2944
+ '.css': 'text/css',
2945
+ '.eot': 'application/vnd.ms-fontobject',
2946
+ '.gif': 'image/gif',
2947
+ '.html': 'text/html',
2948
+ '.ico': 'image/x-icon',
2949
+ '.jpeg': 'image/jpeg',
2950
+ '.jpg': 'image/jpeg',
2951
+ '.js': 'application/javascript',
2952
+ '.json': 'application/json',
2953
+ '.png': 'image/png',
2954
+ '.scss': 'text/x-scss',
2955
+ '.svg': 'image/svg+xml',
2956
+ '.ttf': 'font/ttf',
2957
+ '.vtl': 'text/x-velocity',
2958
+ '.woff': 'font/woff',
2959
+ '.woff2': 'font/woff2'
2960
+ };
2961
+ /**
2962
+ * Upload every file under a local directory as dotCMS file assets, preserving relative
2963
+ * paths. Files are saved or published one at a time; a failed file is recorded in the
2964
+ * manifest rather than aborting the batch.
2965
+ */
2966
+ async function uploadAssets(options) {
2967
+ const src = await prepareReadableDir(options.src, options.root);
2968
+ const dest = normalizeDotCMSPath(options.dest);
2969
+ if (!dest.siteQualified) {
2970
+ throw new errors.ValidationError('Upload destination must be host-qualified, e.g. //demo.dotcms.com/application/themes/travel');
2971
+ }
2972
+ const { files: localFiles, totalSeen } = await collectLocalFiles(src, options.include, {
2973
+ maxFiles: options.maxFiles,
2974
+ maxScannedEntries: options.maxScannedEntries
2975
+ });
2976
+ const files = [];
2977
+ const failures = [];
2978
+ const skipped = [];
2979
+ const warnings = [];
2980
+ if (localFiles.length === 0) {
2981
+ if (options.include && totalSeen > 0) {
2982
+ // The source dir is NOT empty — the include pattern is the problem. Say so distinctly so
2983
+ // this never reads as "nothing to upload" in an unattended run. The matcher supports
2984
+ // *, ? , ** globstar, and {a,b,c} brace expansion, all relative to `src`.
2985
+ warnings.push(`Include pattern "${options.include}" matched 0 of ${totalSeen} file(s) under ` +
2986
+ `"${src}" — check the glob syntax. Patterns are relative to the source dir and ` +
2987
+ `support *, ?, ** (globstar), and {png,webp,jpg} brace expansion (e.g. ` +
2988
+ `"*.{png,webp,jpg}" or "**/*.png"). Nothing was uploaded.`);
2989
+ }
2990
+ else {
2991
+ warnings.push(`No files found under "${src}".`);
2992
+ }
2993
+ }
2994
+ for (const file of localFiles) {
2995
+ if (options.maxFileBytes !== undefined && file.bytes > options.maxFileBytes) {
2996
+ // Before the file is opened, so an oversized one costs nothing but this entry.
2997
+ failures.push({
2998
+ path: file.rel,
2999
+ error: `File is ${file.bytes} bytes, over the ${options.maxFileBytes}-byte upload ` +
3000
+ `limit (${formatMB(options.maxFileBytes)}), so it was not read or sent. ` +
3001
+ `Retrying will not help: upload it another way, or ask whoever runs this ` +
3002
+ `server or agent to raise maxFileBytes.`
3003
+ });
3004
+ continue;
3005
+ }
3006
+ try {
3007
+ // Every file in src lands in dotCMS as-is, 0-byte content included. We do not
3008
+ // skip on empty content: an empty file that exists locally must exist remotely,
3009
+ // otherwise the container can't assemble CONTENT bodies (the empty-skip was the
3010
+ // root cause of a missing postloop.vtl). `skipped[]` is reserved for real skips
3011
+ // (e.g. a glob matching nothing), never for empty content.
3012
+ const uploaded = await uploadOneAsset(options.dotcms, file, `${dest.siteQualified}/${file.rel}`, options.publish);
3013
+ files.push(uploaded.file);
3014
+ if (uploaded.warning) {
3015
+ warnings.push(uploaded.warning);
3016
+ }
3017
+ }
3018
+ catch (error) {
3019
+ failures.push({ path: file.rel, error: errorMessage(error) });
3020
+ }
3021
+ }
3022
+ // Belt AND braces: `verifyLive` guards every await internally, but this call is the last
3023
+ // thing standing between a completed set of writes and the manifest that reports them.
3024
+ // If verification ever fails in a way it did not anticipate, the uploads still happened
3025
+ // and the model still needs to be told exactly what landed — so the worst case here is a
3026
+ // manifest with a warning, never a thrown error that erases the whole report.
3027
+ let notLive = [];
3028
+ if (options.publish && options.verify) {
3029
+ try {
3030
+ const verified = await verifyLive(options.dotcms, files);
3031
+ notLive = verified.notLive;
3032
+ warnings.push(...verified.warnings);
3033
+ }
3034
+ catch (error) {
3035
+ warnings.push(`Upload succeeded but live-verification could not complete: ` +
3036
+ `${errorMessage(error)}. The ${files.length} file(s) listed below WERE ` +
3037
+ `uploaded — do not re-upload them; check their published state directly.`);
3038
+ }
3039
+ }
3040
+ return sortManifest({
3041
+ src,
3042
+ dest: dest.siteQualified,
3043
+ count: files.length,
3044
+ bytes: sumBytes(files),
3045
+ files,
3046
+ failures,
3047
+ skipped,
3048
+ notLive,
3049
+ warnings
3050
+ });
3051
+ }
3052
+ async function uploadOneAsset(dotcms, file, destPath, publish) {
3053
+ const type = mimeFor(file.rel);
3054
+ // The file travels as a disk-backed Blob, so nothing here reads, copies or base64-encodes
3055
+ // it. `fetch` still gathers the request body before sending, so each upload holds one
3056
+ // copy of its file in memory — the documented upload size limitation. (It held about
3057
+ // four: the read, its base64 string, the decode, and the Blob built from it.)
3058
+ const put = (blob) => dotcms.request({
3059
+ method: 'PUT',
3060
+ path: publish ? '/api/v2/assets/publish' : '/api/v2/assets/save',
3061
+ formData: {
3062
+ path: destPath,
3063
+ file: { name: node_path.basename(file.rel), type, blob }
3064
+ }
3065
+ });
3066
+ let response;
3067
+ let warning;
3068
+ try {
3069
+ // Upload the real content, 0-byte included.
3070
+ response = await put(await fileBlob(file.abs, type));
3071
+ }
3072
+ catch (error) {
3073
+ // Fallback: if (and only if) dotCMS rejects a 0-byte body, retry with a single
3074
+ // newline so the file still lands instead of being dropped. The demo postloop.vtl
3075
+ // indicates 0-byte is accepted, so this path is expected to be unused.
3076
+ if (file.bytes === 0) {
3077
+ response = await put(new Blob(['\n'], { type }));
3078
+ // The remote asset now DIFFERS from the source: 1 byte where the source has 0.
3079
+ // Reporting a clean success would leave the caller unable to see that, and for
3080
+ // an empty VTL or CSS partial the difference is invisible until something
3081
+ // downstream behaves oddly. Say it plainly and report the bytes actually sent.
3082
+ warning =
3083
+ `"${file.rel}" is 0 bytes and dotCMS rejected an empty body, so it was ` +
3084
+ `uploaded as a single newline (1 byte) instead. The remote file does NOT ` +
3085
+ `match the source exactly.`;
3086
+ }
3087
+ else {
3088
+ throw error;
3089
+ }
3090
+ }
3091
+ return {
3092
+ file: {
3093
+ path: file.rel,
3094
+ bytes: warning ? 1 : file.bytes,
3095
+ identifier: response.entity?.identifier
3096
+ },
3097
+ warning
3098
+ };
3099
+ }
3100
+ /**
3101
+ * Re-check that every uploaded asset is actually live, re-firing PUBLISH for any that
3102
+ * aren't (up to 3 rounds), then confirming the last round's fires.
3103
+ *
3104
+ * Every await in here is individually guarded, for one reason: this is a READ-ONLY
3105
+ * verification of writes that have ALREADY COMMITTED. A throw escaping this function would
3106
+ * propagate out of `uploadAssets` and discard `files[]`, `failures[]` and `warnings[]` — so
3107
+ * a 120-file theme that uploaded and published perfectly, then hit one flaky liveness GET,
3108
+ * would be reported to the model as a failure. Its next move is to re-upload all 120.
3109
+ *
3110
+ * Verification can therefore only ever downgrade the manifest (add to `notLive`/`warnings`),
3111
+ * never replace it with an exception.
3112
+ */
3113
+ async function verifyLive(dotcms, files) {
3114
+ const warnings = [];
3115
+ // A file with no identifier CANNOT be checked, which is not the same as it being fine.
3116
+ // Silently filtering these out meant that if the publish envelope ever stopped matching
3117
+ // the expected shape, every identifier would be undefined, every file would drop out
3118
+ // here, the round loop would never run, and the manifest would report
3119
+ // `count: 120, notLive: [], warnings: []` — indistinguishable from a fully verified
3120
+ // publish when in fact nothing at all was verified.
3121
+ const unverifiable = files.filter((file) => !file.identifier);
3122
+ if (unverifiable.length > 0) {
3123
+ warnings.push(`${unverifiable.length} of ${files.length} uploaded file(s) returned no identifier, ` +
3124
+ `so their live status could NOT be verified: ` +
3125
+ `${unverifiable.map((file) => file.path).join(', ')}. ` +
3126
+ `They may or may not be published — check them directly.`);
3127
+ }
3128
+ let pending = files.filter((file) => file.identifier);
3129
+ for (let round = 0; round < 3 && pending.length > 0; round++) {
3130
+ const notLive = await collectNotLive(dotcms, pending, warnings);
3131
+ if (notLive.length === 0) {
3132
+ return { notLive: [], warnings };
3133
+ }
3134
+ // Sequential, not concurrent: these fire workflow actions against content dotCMS is
3135
+ // concurrently versioning and indexing. The per-item catch is the fix that matters —
3136
+ // previously the first bad fire (locked by another workflow, or a token without
3137
+ // PUBLISH on that folder) threw, so every remaining asset was never even attempted
3138
+ // and nothing recorded which ones those were.
3139
+ for (const file of notLive) {
3140
+ try {
3141
+ await dotcms.request({
3142
+ method: 'PUT',
3143
+ path: '/api/v1/workflow/actions/default/fire/PUBLISH',
3144
+ body: { contentlet: { identifier: file.identifier } }
3145
+ });
3146
+ }
3147
+ catch (error) {
3148
+ warnings.push(`Re-publish failed for "${file.path}" (${file.identifier}): ` +
3149
+ `${errorMessage(error)}. Remaining files were still attempted.`);
3150
+ }
3151
+ }
3152
+ pending = notLive;
3153
+ }
3154
+ // The PUBLISH fired in the final round has not been verified yet — without this pass an
3155
+ // asset that only goes live on its last re-fire would be reported as notLive despite
3156
+ // having published successfully (a false negative in the transfer manifest).
3157
+ return { notLive: await collectNotLive(dotcms, pending, warnings), warnings };
3158
+ }
3159
+ /**
3160
+ * Which of `files` are not live yet.
3161
+ *
3162
+ * Pure GETs on distinct identifiers with no interdependence, so they run concurrently via
3163
+ * `allSettled` — a 120-file theme was previously up to 3 rounds of 120 sequential round
3164
+ * trips plus a final 120, and only the last round's results mattered.
3165
+ *
3166
+ * `allSettled` (not `all`) for the same reason the whole function is guarded: `all` fails
3167
+ * fast and discards its settled siblings, and here those siblings ARE the answer. A single
3168
+ * rejected read must not decide the fate of the other 119. A file whose check failed is
3169
+ * treated as NOT-not-live — it is left out of `notLive` and reported as a warning, so an
3170
+ * unreadable status never masquerades as a confirmed failure.
3171
+ */
3172
+ async function collectNotLive(dotcms, files, warnings) {
3173
+ const results = await Promise.allSettled(files.map((file) => isContentLive(dotcms, file.identifier)));
3174
+ const notLive = [];
3175
+ results.forEach((result, index) => {
3176
+ const file = files[index];
3177
+ if (result.status === 'rejected') {
3178
+ warnings.push(`Could not check whether "${file.path}" (${file.identifier}) is live: ` +
3179
+ `${errorMessage(result.reason)}.`);
3180
+ return;
3181
+ }
3182
+ if (!result.value) {
3183
+ notLive.push(file);
3184
+ }
3185
+ });
3186
+ return notLive;
3187
+ }
3188
+ /**
3189
+ * Walk `src` and return the files matching `include` (all files when no `include`), plus
3190
+ * `totalSeen` — the count of files present regardless of the filter. `totalSeen` lets the caller
3191
+ * distinguish "the source dir is empty" from "your include pattern matched none of N real files",
3192
+ * so a mistyped glob is reported as a syntax problem instead of silent success.
3193
+ */
3194
+ async function collectLocalFiles(src, include, limits) {
3195
+ const matches = includeMatcher(include);
3196
+ const files = [];
3197
+ const { maxFiles, maxScannedEntries } = limits;
3198
+ let totalSeen = 0;
3199
+ let scanned = 0;
3200
+ // An explicit queue, not recursion: each directory is read to the end and closed before
3201
+ // the next one is opened, so the walk holds ONE directory handle however deep `src` goes.
3202
+ // Recursing from inside the loop kept every ancestor open, and a deep tree hit EMFILE long
3203
+ // before the scan limit. The queue itself is bounded by the scan limit, since every
3204
+ // directory in it was counted as an entry.
3205
+ const pending = [src];
3206
+ for (let dir = pending.pop(); dir !== undefined; dir = pending.pop()) {
3207
+ // Streamed with `opendir`, a few entries at a time: `readdir` would load a directory's
3208
+ // whole listing before the scan limit could stop it, so one directory of millions of
3209
+ // entries would cost unbounded time and memory. `for await` closes the directory when
3210
+ // the loop ends, breaks or throws — the limit's throw included.
3211
+ for await (const entry of await promises.opendir(dir)) {
3212
+ const abs = node_path.join(dir, entry.name);
3213
+ // Every entry counts, directory or file, matched or not: the walk's cost is what
3214
+ // it visits, and a narrow `include` does not make a vast tree any cheaper to walk.
3215
+ scanned++;
3216
+ if (maxScannedEntries !== undefined && scanned > maxScannedEntries) {
3217
+ throw new errors.ValidationError(`Stopped after it scanned ${maxScannedEntries} entries under "${src}" ` +
3218
+ `without reaching the end, over the upload's scan limit. Nothing was ` +
3219
+ `uploaded. Narrow \`src\` to a smaller directory — \`include\` filters ` +
3220
+ `what is uploaded, but not what is walked.`);
3221
+ }
3222
+ if (entry.isDirectory()) {
3223
+ pending.push(abs);
3224
+ continue;
3225
+ }
3226
+ if (!entry.isFile()) {
3227
+ continue;
3228
+ }
3229
+ totalSeen++;
3230
+ const rel = node_path.relative(src, abs).split(node_path.sep).join(node_path.posix.sep);
3231
+ if (!matches(rel)) {
3232
+ continue;
3233
+ }
3234
+ const info = await promises.stat(abs);
3235
+ files.push({ abs, rel, bytes: info.size });
3236
+ // Thrown mid-walk, so a `src` far over the limit is not walked to the end first.
3237
+ if (maxFiles !== undefined && files.length > maxFiles) {
3238
+ throw new errors.ValidationError(`"${src}" holds more than ${maxFiles} file(s) to upload${include ? ` matching "${include}"` : ''}, ` +
3239
+ `over the upload's file limit. Nothing was uploaded. Narrow \`src\` to a ` +
3240
+ `smaller directory or \`include\` to fewer files.`);
3241
+ }
3242
+ }
3243
+ }
3244
+ return { files: files.sort((a, b) => a.rel.localeCompare(b.rel)), totalSeen };
3245
+ }
3246
+ async function prepareReadableDir(src, root) {
3247
+ if (!node_path.isAbsolute(src)) {
3248
+ throw new errors.ValidationError(`Source must be an absolute path: ${src}`);
3249
+ }
3250
+ const resolved = node_path.resolve(src);
3251
+ const info = await promises.stat(resolved);
3252
+ if (!info.isDirectory()) {
3253
+ throw new errors.ValidationError(`Source must be a directory: ${src}`);
3254
+ }
3255
+ await assertInsideRoot(root, 'src', resolved);
3256
+ await promises.access(resolved, fs__namespace.constants.R_OK);
3257
+ return resolved;
3258
+ }
3259
+ /** Bytes as a human-readable megabyte figure, for a limit message. */
3260
+ function formatMB(bytes) {
3261
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
3262
+ }
3263
+ function mimeFor(path) {
3264
+ return MIME_BY_EXT[node_path.extname(path).toLowerCase()] || 'application/octet-stream';
3265
+ }
3266
+ /**
3267
+ * A Blob whose bytes stay on disk until something reads them. Node has `fs.openAsBlob`
3268
+ * (from 19.8; the package requires 20). Under Bun, `Bun.file` is the same thing and does
3269
+ * not depend on how complete Bun's `node:fs` is.
3270
+ */
3271
+ async function fileBlob(path, type) {
3272
+ const bun = globalThis.Bun;
3273
+ if (bun) {
3274
+ return bun.file(path, { type });
3275
+ }
3276
+ return fs__namespace.openAsBlob(path, { type });
3277
+ }
3278
+
3279
+ /**
3280
+ * A boolean that also accepts the string forms tool-calling clients (MCP and others) often
3281
+ * send ("true"/"false"/"1"/"0"). Plain `z.coerce.boolean()` is wrong here: it uses JS
3282
+ * truthiness, so the string "false" becomes `true`. This maps the string forms to their
3283
+ * intended value and leaves real booleans untouched.
3284
+ *
3285
+ * The default is declared twice, on purpose. The inner one covers a blank string, which the
3286
+ * preprocess turns into `undefined` AFTER the outer wrapper has already let it through. The
3287
+ * outer one is what marks the field optional in the generated JSON Schema: a preprocess hides
3288
+ * the inner default from `z.toJSONSchema`, so without it the schema a model reads lists the
3289
+ * field as REQUIRED even though omitting it is the normal case.
3290
+ */
3291
+ const lenientBoolean = (defaultValue) => zod.z
3292
+ .preprocess((value) => {
3293
+ if (typeof value === 'string') {
3294
+ const v = value.trim().toLowerCase();
3295
+ // An empty (or whitespace-only) string means "I did not set this", so it has to
3296
+ // become `undefined` — `.default()` only substitutes on `undefined`, so returning
3297
+ // the original `''` skipped the default entirely and failed validation with
3298
+ // "Expected boolean, received string" for an argument the caller never set.
3299
+ if (v === '')
3300
+ return undefined;
3301
+ if (v === 'false' || v === '0' || v === 'no')
3302
+ return false;
3303
+ if (v === 'true' || v === '1' || v === 'yes')
3304
+ return true;
3305
+ }
3306
+ return value;
3307
+ }, zod.z.boolean().default(defaultValue))
3308
+ .default(defaultValue);
3309
+
3310
+ const definition$1 = defineTool({
3311
+ name: 'upload_assets',
3312
+ title: 'Upload dotCMS Assets',
3313
+ inputSchema: zod.z.object({
3314
+ src: zod.z
3315
+ .string()
3316
+ .min(1)
3317
+ .describe('Absolute local directory the server reads files from. Must be inside the directory the server allows; a path outside it fails and names that directory.'),
3318
+ dest: zod.z
3319
+ .string()
3320
+ .min(1)
3321
+ .describe('Host-qualified dotCMS destination folder, e.g. //demo.dotcms.com/application/themes/travel'),
3322
+ include: zod.z
3323
+ .string()
3324
+ .optional()
3325
+ .describe('Optional comma-separated glob filter, matched against each file path RELATIVE to `src`. ' +
3326
+ 'Supports *, ? (single non-slash char), ** (globstar, crosses directories), and ' +
3327
+ '{a,b,c} brace expansion. A pattern with no "/" matches the basename anywhere in the ' +
3328
+ 'tree; a pattern with a "/" is anchored at the top of `src`. Examples: "*.vtl,*.scss", ' +
3329
+ '"*.{png,webp,jpg}", "**/*.png". A comma inside {…} does not split patterns.'),
3330
+ publish: lenientBoolean(true).describe('Use /api/v2/assets/publish when true, otherwise /api/v2/assets/save'),
3331
+ verify: lenientBoolean(true).describe('After publishing, verify live status through /api/v1/content/{identifier}')
3332
+ }),
3333
+ description: `Upload one or more local files as dotCMS file assets (themes, VTL, CSS, JS, images, fonts, …).
3334
+
3335
+ ALWAYS use this tool to put files into dotCMS — do NOT hand-roll uploads with the \`execute\`
3336
+ tool, a direct API call, or a custom script. This is the supported path and it is strictly
3337
+ better for two reasons:
3338
+ 1. File bytes are read from disk by the server and sent straight to dotCMS — they NEVER
3339
+ pass through your context. Use this whenever you'd otherwise inline file content (e.g. large
3340
+ VTL/CSS blobs) into a tool call; it keeps that content out of the conversation entirely.
3341
+ 2. Auth is already configured on the server. You do NOT need a dotCMS token, a \`.env\` file, or
3342
+ any local credentials — never go looking for them.
3343
+
3344
+ Use it whenever local files need to land in dotCMS — whether the user explicitly asks to
3345
+ "upload/push/publish," OR you decided to write files as part of a larger task and now need them
3346
+ in dotCMS. Example: the user says "create a theme"; you write the theme files to a local
3347
+ directory, then call this tool to upload them. Putting files in dotCMS is always a step you
3348
+ take with this tool — not something you wait to be told to do, and not something you improvise.
3349
+
3350
+ Provide an absolute source directory (\`src\`) and a host-qualified destination (\`dest\`, e.g.
3351
+ \`//demo.dotcms.com/application/themes/travel\`). Optional \`include\` globs limit which files go.
3352
+ The tool preserves relative paths and returns only a JSON manifest — never the file bytes.
3353
+
3354
+ Reserved-folder trap: \`assets\` is a RESERVED top-level folder name — a \`dest\` like
3355
+ \`//host/assets/...\` fails with "reserved folder name: assets". Put files under \`/application\`
3356
+ (the conventional home for themes, VTL, containers, e.g. \`//host/application/themes/<name>\`) or
3357
+ another non-reserved path. \`dest\` must be host-qualified (start with \`//<hostname>/\`).
3358
+
3359
+ Tip: to avoid inlining large templates, write them to files on disk and upload them with this
3360
+ tool, then reference them from a container/template via \`#dotParse\`.`,
3361
+ annotations: {
3362
+ readOnlyHint: false,
3363
+ destructiveHint: true,
3364
+ idempotentHint: false,
3365
+ openWorldHint: true
3366
+ },
3367
+ endpoints: UPLOAD_ASSETS_ENDPOINTS,
3368
+ async handler(args, ctx) {
3369
+ return uploadAssets({
3370
+ dotcms: ctx.runtime(),
3371
+ root: ctx.options.root,
3372
+ src: args.src,
3373
+ dest: args.dest,
3374
+ include: args.include,
3375
+ publish: args.publish,
3376
+ verify: args.verify,
3377
+ maxFileBytes: ctx.options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
3378
+ maxFiles: ctx.options.maxFiles ?? DEFAULT_MAX_FILES,
3379
+ maxScannedEntries: ctx.options.maxScannedEntries ?? DEFAULT_MAX_SCANNED_ENTRIES
3380
+ });
3381
+ }
3382
+ });
3383
+ /** The `upload_assets` tool's per-file limit when the host sets none: 100 MB. */
3384
+ const DEFAULT_MAX_FILE_BYTES = 100 * 1024 * 1024;
3385
+ /** The `upload_assets` tool's file-count limit when the host sets none. */
3386
+ const DEFAULT_MAX_FILES = 1_000;
3387
+ /** The `upload_assets` tool's walk limit when the host sets none: 20× the file limit. */
3388
+ const DEFAULT_MAX_SCANNED_ENTRIES = 20_000;
3389
+ /**
3390
+ * The `upload_assets` tool: uploads a local directory into dotCMS as file assets — the bytes
3391
+ * never pass through the model. Needs Node or Bun. Resolves to an {@link UploadAssetsManifest}.
3392
+ * `options.root` bounds which local directories the model may read from.
3393
+ *
3394
+ * The model chooses `src`, so the tool is bounded by default: `options.maxFileBytes` (100 MB),
3395
+ * `options.maxFiles` (1,000) and `options.maxScannedEntries` (20,000 entries walked). Each file
3396
+ * is held in memory once while it is sent (`fetch` buffers request bodies), so the file limit
3397
+ * is also the upload's memory bound. Pass `Infinity` to lift any of them.
3398
+ */
3399
+ function uploadAssetsTool(connection, options) {
3400
+ return createTool(definition$1, connection, options);
3401
+ }
3402
+
3403
+ /**
3404
+ * Every endpoint `downloadAssets` calls — all reads. The `download_assets` tool enforces
3405
+ * exactly this list, and `download-assets.spec.ts` checks every request against it.
3406
+ */
3407
+ const DOWNLOAD_ASSETS_ENDPOINTS = [
3408
+ // A folder search is scoped to one site, so the site is resolved first.
3409
+ ...CONTEXT_ENDPOINTS,
3410
+ ...RESOLVE_ENDPOINTS,
3411
+ 'POST /api/content/_search',
3412
+ 'GET /api/v2/assets',
3413
+ 'GET /api/v2/assets/{identifier}'
3414
+ ];
3415
+ const SEARCH_LIMIT = 500;
3416
+ /**
3417
+ * Bounds on a single enumeration: how many search results one walk reads, whether or not the
3418
+ * filters select them. `/application` on a large site can hold tens of thousands of assets,
3419
+ * and every one selected is then downloaded one at a time with an open file handle — so an
3420
+ * unbounded walk is both an unbounded tool call and unbounded load on the instance. A
3421
+ * realistic theme is 100–500 files, so this is far above any genuine use while still being a
3422
+ * ceiling. Hitting it is reported, never silent.
3423
+ */
3424
+ const MAX_ENUMERATED_ASSETS = 5_000;
3425
+ const MAX_SEARCH_PAGES = Math.ceil(MAX_ENUMERATED_ASSETS / SEARCH_LIMIT);
3426
+ /**
3427
+ * Download a dotCMS asset, or every asset under a folder, to a local directory. Relative
3428
+ * paths are preserved, and one failed file is recorded in the manifest rather than aborting
3429
+ * the batch.
3430
+ */
3431
+ async function downloadAssets(options) {
3432
+ const input = normalizeDotCMSPath(options.path);
3433
+ const dest = await prepareWritableDir(options.dest, options.root);
3434
+ const files = [];
3435
+ const failures = [];
3436
+ const skipped = [];
3437
+ const warnings = [];
3438
+ const kind = options.kind ?? 'auto';
3439
+ const reading = kind === 'auto' ? (looksLikeAssetPath(input.path) ? 'asset' : 'folder') : kind;
3440
+ const record = (result) => {
3441
+ if (result.kind === 'written')
3442
+ files.push(result.file);
3443
+ else
3444
+ skipped.push(result.skip);
3445
+ };
3446
+ const target = (rel) => ({
3447
+ dest,
3448
+ rel,
3449
+ overwrite: options.overwrite,
3450
+ root: options.root
3451
+ });
3452
+ // Each download is wrapped in the same try/catch so one failure records a failure and
3453
+ // doesn't abort the batch — both the single-asset path and the folder loop go through it.
3454
+ const download = async (rel, source, identifier) => {
3455
+ try {
3456
+ record(await saveAsset(options.dotcms, target(rel), source(), identifier));
3457
+ }
3458
+ catch (error) {
3459
+ failures.push({ path: rel, error: errorMessage(error) });
3460
+ }
3461
+ };
3462
+ // `path` read as ONE asset. Resolves false only when there is no asset there (404), which
3463
+ // is what lets `auto` try the folder reading instead; any other failure is recorded.
3464
+ const oneAsset = async () => {
3465
+ const rel = node_path.basename(input.path);
3466
+ try {
3467
+ record(await saveAsset(options.dotcms, target(rel), { path: '/api/v2/assets', query: { path: assetQueryPath(input) } }, undefined,
3468
+ // Only a guess needs dotCMS's word before a collision policy applies;
3469
+ // `kind: 'asset'` is certain.
3470
+ kind === 'auto'));
3471
+ }
3472
+ catch (error) {
3473
+ if (error instanceof errors.HttpError && error.status === 404) {
3474
+ return false;
3475
+ }
3476
+ failures.push({ path: rel, error: errorMessage(error) });
3477
+ }
3478
+ return true;
3479
+ };
3480
+ // `path` read as a FOLDER, searched on its one site. Resolves false when it holds nothing.
3481
+ const folder = async () => {
3482
+ const site = await siteOf(options.dotcms, options.path, input);
3483
+ const { assets, truncated } = await enumerateAssets(options.dotcms, site.identifier, input.path, options.recursive, options.include);
3484
+ if (truncated) {
3485
+ // `include` and `recursive` filter what the search returned, so they cannot get a
3486
+ // walk past the cap; only a narrower folder can.
3487
+ warnings.push(`Enumeration stopped before reaching the end of this folder (it reads at most ` +
3488
+ `${MAX_ENUMERATED_ASSETS} assets), so the download is INCOMPLETE. Narrow it ` +
3489
+ `with a subfolder path and run again.`);
3490
+ }
3491
+ for (const asset of assets) {
3492
+ const assetPath = asset.path ? normalizeDotCMSPath(asset.path).path : '';
3493
+ const rel = relativeAssetPath(input.path, assetPath) || assetPath || '(unknown)';
3494
+ const identifier = asset.identifier;
3495
+ await download(rel, () => {
3496
+ if (!identifier || !relativeAssetPath(input.path, assetPath)) {
3497
+ throw new Error('Asset is missing identifier or path');
3498
+ }
3499
+ return { path: `/api/v2/assets/${encodeURIComponent(identifier)}` };
3500
+ }, identifier);
3501
+ }
3502
+ // After a cap, the folder is not empty: it simply was not read to the end.
3503
+ return assets.length > 0 || truncated;
3504
+ };
3505
+ let found;
3506
+ if (reading === 'asset') {
3507
+ found = (await oneAsset()) || (kind === 'auto' && (await folder()));
3508
+ }
3509
+ else {
3510
+ found = (await folder()) || (kind === 'auto' && (await oneAsset()));
3511
+ }
3512
+ if (!found) {
3513
+ warnings.push(zeroMatchWarning(options.path, input));
3514
+ }
3515
+ return sortManifest({
3516
+ path: input.path,
3517
+ dest,
3518
+ count: files.length,
3519
+ bytes: sumBytes(files),
3520
+ files,
3521
+ failures,
3522
+ skipped,
3523
+ warnings
3524
+ });
3525
+ }
3526
+ /**
3527
+ * Save one asset to disk, streaming its bytes straight from the response into the file.
3528
+ *
3529
+ * Everything that can refuse the write is decided BEFORE the request: the path, the root, and
3530
+ * the overwrite mode — so `skip` never transfers bytes it would throw away.
3531
+ *
3532
+ * `confirmRemote` is for a GUESSED asset (`kind: 'auto'`). A local collision decided on local
3533
+ * state alone — `skip` reporting success, `error` reporting "already exists" — would never
3534
+ * ask dotCMS whether an asset is there, and the folder reading would never be tried. So before
3535
+ * either policy applies, the read is made and its body discarded unread; a 404 from it
3536
+ * propagates for the caller's fallback, and a confirmed asset gets the policy as usual.
3537
+ */
3538
+ async function saveAsset(dotcms, target, source, identifier, confirmRemote = false) {
3539
+ const outputPath = safeJoin(target.dest, target.rel);
3540
+ // `safeJoin` keeps the path inside `dest` as a string; this keeps the WRITE inside the root
3541
+ // — a symlinked directory or file already inside `dest` would otherwise redirect it.
3542
+ await assertWritableInsideRoot(target.root, outputPath);
3543
+ if (target.overwrite !== 'overwrite' && (await exists(outputPath))) {
3544
+ if (confirmRemote) {
3545
+ await dotcms.request({ ...source, onBody: (body) => body.cancel() });
3546
+ }
3547
+ if (target.overwrite === 'skip') {
3548
+ return { kind: 'skipped', skip: { path: target.rel, reason: 'exists' } };
3549
+ }
3550
+ throw new Error('Destination file already exists');
3551
+ }
3552
+ const bytes = (await dotcms.request({
3553
+ ...source,
3554
+ onBody: (body) => writeBodyToFile(outputPath, body)
3555
+ }));
3556
+ return { kind: 'written', file: { path: target.rel, bytes, identifier } };
3557
+ }
3558
+ /**
3559
+ * Write a response body to `outputPath` and return how many bytes it held.
3560
+ *
3561
+ * The bytes go to a temporary file beside the target, renamed over it only once the body is
3562
+ * complete. Written in place, a transfer that broke halfway would leave the target truncated —
3563
+ * and, with `overwrite`, would have already destroyed the good copy it was replacing. The
3564
+ * temporary file is opened exclusively (`wx`), so it is never an existing file or a planted
3565
+ * symlink; the rename replaces the target's directory entry rather than writing through it.
3566
+ */
3567
+ async function writeBodyToFile(outputPath, body) {
3568
+ await promises.mkdir(node_path.dirname(outputPath), { recursive: true });
3569
+ const partial = `${outputPath}.${node_crypto.randomUUID()}.part`;
3570
+ const reader = body.getReader();
3571
+ let bytes = 0;
3572
+ try {
3573
+ const file = await promises.open(partial, 'wx');
3574
+ try {
3575
+ for (;;) {
3576
+ const { done, value } = await reader.read();
3577
+ if (done)
3578
+ break;
3579
+ // Awaiting each write is the backpressure: the next chunk is not read until
3580
+ // this one is on disk, so memory holds one chunk, not the file. A write may
3581
+ // store fewer bytes than it was given (`bytesWritten`), so it loops until the
3582
+ // whole chunk is written — counting the chunk would report a truncated file
3583
+ // as complete.
3584
+ for (let offset = 0; offset < value.byteLength;) {
3585
+ const { bytesWritten } = await file.write(value, offset, value.byteLength - offset);
3586
+ offset += bytesWritten;
3587
+ }
3588
+ bytes += value.byteLength;
3589
+ }
3590
+ }
3591
+ finally {
3592
+ await file.close();
3593
+ }
3594
+ // An empty body is an empty asset (a 0-byte VTL partial is real), so it is written too.
3595
+ await promises.rename(partial, outputPath);
3596
+ return bytes;
3597
+ }
3598
+ catch (error) {
3599
+ await reader.cancel().catch(() => undefined);
3600
+ await promises.rm(partial, { force: true });
3601
+ throw error;
3602
+ }
3603
+ }
3604
+ async function enumerateAssets(dotcms, siteId, folder, recursive, include) {
3605
+ const matches = includeMatcher(include);
3606
+ const assets = [];
3607
+ // Every identifier the search returned, selected or not. Progress is measured on this, not
3608
+ // on `assets`: a full page the filters reject entirely still moved the walk forward.
3609
+ const enumerated = new Set();
3610
+ for (let page_ = 0; page_ < MAX_SEARCH_PAGES; page_++) {
3611
+ const offset = page_ * SEARCH_LIMIT;
3612
+ const response = await dotcms.request({
3613
+ method: 'POST',
3614
+ path: '/api/content/_search',
3615
+ body: {
3616
+ // `+conhost` keeps the walk on one site. Without it, `/shared` matched every
3617
+ // site's `/shared`, and same-named files overwrote or skipped each other locally.
3618
+ query: `+baseType:4 +conhost:${siteId} +path:${folder}/*`,
3619
+ sort: 'path asc',
3620
+ limit: SEARCH_LIMIT,
3621
+ offset
3622
+ }
3623
+ });
3624
+ const page = extractContentlets(response);
3625
+ const enumeratedBefore = enumerated.size;
3626
+ for (const asset of page) {
3627
+ if (!asset.identifier || !asset.path || enumerated.has(asset.identifier)) {
3628
+ continue;
3629
+ }
3630
+ enumerated.add(asset.identifier);
3631
+ const rel = relativeAssetPath(folder, normalizeDotCMSPath(asset.path).path);
3632
+ if (!rel || (!recursive && rel.includes('/')) || !matches(rel)) {
3633
+ continue;
3634
+ }
3635
+ assets.push(asset);
3636
+ }
3637
+ // A short page is the end of the results: the only way the walk is complete.
3638
+ if (page.length < SEARCH_LIMIT) {
3639
+ return { assets, truncated: false };
3640
+ }
3641
+ // Termination guard, NOT an optimisation. If the backend ignores or clamps `offset`,
3642
+ // every page comes back full of the same identifiers: the short-page exit never
3643
+ // fires, and the loop spins forever issuing identical POSTs — a tool call that never
3644
+ // returns while the instance takes sustained load. A page that returns nothing new
3645
+ // means we are not advancing, whatever the backend thinks it is doing — and whatever
3646
+ // lies past it was never read.
3647
+ if (enumerated.size === enumeratedBefore) {
3648
+ return { assets, truncated: true };
3649
+ }
3650
+ }
3651
+ // Out of pages on a full one: there are results left that this walk did not read.
3652
+ return { assets, truncated: true };
3653
+ }
3654
+ /** The path to send to the `/api/v2/assets?path=` query — host-qualified when available. */
3655
+ function assetQueryPath(normalized) {
3656
+ return normalized.siteQualified || normalized.path;
3657
+ }
3658
+ /**
3659
+ * Message for a folder enumeration that matched 0 assets. The common cause is the `//host/path`
3660
+ * ambiguity: a `//`-prefixed input has its FIRST segment consumed as the site, so `//application/themes`
3661
+ * searches the path `/themes` on site `application` — which usually doesn't exist. Surface exactly
3662
+ * that so the agent can correct it instead of treating an empty result as success.
3663
+ */
3664
+ function zeroMatchWarning(rawInput, parsed) {
3665
+ return (`No assets matched "${parsed.path}" — check the path. The result is empty, not a ` +
3666
+ `success.${siteReadingNote(rawInput, parsed)}`);
3667
+ }
3668
+ /** The hostname a `//host/path` input names. */
3669
+ function siteHost(parsed) {
3670
+ return (parsed.siteQualified ?? '').slice(2, (parsed.siteQualified ?? '').length - parsed.path.length);
3671
+ }
3672
+ /**
3673
+ * How a `//`-prefixed input was read, for a message about it — empty for any other input.
3674
+ * `//application/themes` reads `application` as the SITE, the common mistake: saying so, and
3675
+ * what to write instead, is what lets the caller correct it rather than retry it.
3676
+ */
3677
+ function siteReadingNote(rawInput, parsed) {
3678
+ const trimmed = rawInput.trim();
3679
+ if (!trimmed.startsWith('//')) {
3680
+ return '';
3681
+ }
3682
+ // The plain-path form is the input with one leading slash removed — i.e. the FULL path
3683
+ // including the segment that "//" consumed as the site (e.g. "//application/themes" → "/application/themes").
3684
+ const asPlainPath = trimmed.slice(1).replace(/\/+$/, '');
3685
+ return (` Note: "${rawInput}" was read as site="${siteHost(parsed)}", path="${parsed.path}" ` +
3686
+ `(a leading "//" treats the first segment as the dotCMS site). ` +
3687
+ `If you meant a path on the default site, use "${asPlainPath}"; ` +
3688
+ `if you meant a host-qualified path, keep "//<site>/<path>".`);
3689
+ }
3690
+ /**
3691
+ * The one site a folder is searched on: the `//host` the input names, or the default site.
3692
+ * An unknown host is the caller's input being wrong (VALIDATION), and says how it was read.
3693
+ */
3694
+ async function siteOf(dotcms, rawInput, parsed) {
3695
+ if (!parsed.siteQualified) {
3696
+ return resolveDefaultSite(dotcms);
3697
+ }
3698
+ try {
3699
+ return await resolveSite(dotcms, siteHost(parsed));
3700
+ }
3701
+ catch (error) {
3702
+ if (error instanceof errors.ValidationError) {
3703
+ throw withMessage(error, `${error.message}${siteReadingNote(rawInput, parsed)}`);
3704
+ }
3705
+ throw error;
3706
+ }
3707
+ }
3708
+ function relativeAssetPath(folder, assetPath) {
3709
+ const prefix = `${folder.replace(/\/+$/, '')}/`;
3710
+ return assetPath.startsWith(prefix) ? assetPath.slice(prefix.length) : '';
3711
+ }
3712
+ function looksLikeAssetPath(path) {
3713
+ return node_path.extname(path) !== '';
3714
+ }
3715
+ async function prepareWritableDir(dest, root) {
3716
+ if (!node_path.isAbsolute(dest)) {
3717
+ throw new errors.ValidationError(`Destination must be an absolute path: ${dest}`);
3718
+ }
3719
+ const resolved = node_path.resolve(dest);
3720
+ // Checked BEFORE mkdir, so a refused `dest` creates nothing — not even its parents.
3721
+ await assertInsideRoot(root, 'dest', resolved);
3722
+ await promises.mkdir(resolved, { recursive: true });
3723
+ await promises.access(resolved, fs.constants.W_OK);
3724
+ return resolved;
3725
+ }
3726
+ function safeJoin(root, rel) {
3727
+ if (node_path.posix.isAbsolute(rel) || rel.split('/').includes('..')) {
3728
+ throw new Error(`Unsafe relative path: ${rel}`);
3729
+ }
3730
+ const output = node_path.resolve(root, rel);
3731
+ const back = node_path.relative(root, output);
3732
+ if (back === '..' || back.startsWith(`..${node_path.sep}`) || node_path.isAbsolute(back)) {
3733
+ throw new Error(`Resolved path escapes destination: ${rel}`);
3734
+ }
3735
+ return output;
3736
+ }
3737
+ function extractContentlets(response) {
3738
+ const root = response;
3739
+ const candidates = [
3740
+ root.entity?.jsonObjectView?.contentlets,
3741
+ root.entity?.contentlets,
3742
+ root.entity?.results,
3743
+ root.contentlets
3744
+ ];
3745
+ for (const candidate of candidates) {
3746
+ if (Array.isArray(candidate)) {
3747
+ return candidate;
3748
+ }
3749
+ }
3750
+ return [];
3751
+ }
3752
+ async function exists(path) {
3753
+ try {
3754
+ await promises.access(path, fs.constants.F_OK);
3755
+ return true;
3756
+ }
3757
+ catch {
3758
+ return false;
3759
+ }
3760
+ }
3761
+
3762
+ const definition = defineTool({
3763
+ name: 'download_assets',
3764
+ title: 'Download dotCMS Assets',
3765
+ inputSchema: zod.z.object({
3766
+ path: zod.z
3767
+ .string()
3768
+ .min(1)
3769
+ .describe('dotCMS folder or asset path to download, e.g. /application/themes/travel or //demo.dotcms.com/application/themes/travel/css/styles.scss. A folder is searched on the one site the path names; a path with no //host means the default site.'),
3770
+ kind: zod.z
3771
+ .enum(['auto', 'asset', 'folder'])
3772
+ .default('auto')
3773
+ .describe('Whether `path` is one asset or a folder. "auto" (default) guesses from the name — an extension means an asset — and tries the other reading when the first finds nothing. Pass "folder" for a folder whose name has a dot (e.g. /themes/v1.2), "asset" for a file with no extension (e.g. /robots).'),
3774
+ dest: zod.z
3775
+ .string()
3776
+ .min(1)
3777
+ .describe('Absolute local directory the server writes files into. Must be inside the directory the server allows; a path outside it fails and names that directory.'),
3778
+ recursive: zod.z.boolean().default(true).describe('Include files in nested folders'),
3779
+ overwrite: zod.z
3780
+ .enum(['skip', 'overwrite', 'error'])
3781
+ .default('skip')
3782
+ .describe('Behavior when a destination file already exists'),
3783
+ include: zod.z
3784
+ .string()
3785
+ .optional()
3786
+ .describe('Optional comma-separated glob filter, e.g. *.vtl,*.scss')
3787
+ }),
3788
+ description: `Download one or more dotCMS file assets (themes, VTL, CSS, JS, images, fonts, …) to a local directory.
3789
+
3790
+ ALWAYS use this tool to pull files out of dotCMS — do NOT hand-roll downloads with the
3791
+ \`execute\` tool, a direct API call, or a custom script. This is the supported path and it is
3792
+ strictly better for two reasons:
3793
+ 1. File bytes are written to disk by the server — they NEVER pass through your context. Use
3794
+ this whenever you'd otherwise read file content into a tool call; it keeps that content
3795
+ out of the conversation entirely.
3796
+ 2. Auth is already configured on the server. You do NOT need a dotCMS token, a \`.env\` file, or
3797
+ any local credentials — never go looking for them.
3798
+
3799
+ Use it whenever you need dotCMS files on the local disk — whether the user explicitly asks to
3800
+ "download/pull/export," OR you decided you need the existing files to inspect or edit them as
3801
+ part of a larger task. Example: the user says "update the theme's CSS"; you download the current
3802
+ theme files, edit them locally, then upload them back. Getting files out of dotCMS is always a
3803
+ step you take with this tool — not something you wait to be told to do, and not something you improvise.
3804
+
3805
+ Provide the dotCMS folder or asset path and an absolute destination directory (\`dest\`). Optional
3806
+ \`include\` globs limit which files are fetched. The tool preserves relative paths and returns only
3807
+ a JSON manifest — never the file bytes.`,
3808
+ annotations: {
3809
+ readOnlyHint: false,
3810
+ destructiveHint: false,
3811
+ idempotentHint: true,
3812
+ openWorldHint: true
3813
+ },
3814
+ endpoints: DOWNLOAD_ASSETS_ENDPOINTS,
3815
+ async handler(args, ctx) {
3816
+ return downloadAssets({
3817
+ dotcms: ctx.runtime(),
3818
+ root: ctx.options.root,
3819
+ path: args.path,
3820
+ kind: args.kind,
3821
+ dest: args.dest,
3822
+ recursive: args.recursive,
3823
+ overwrite: args.overwrite,
3824
+ include: args.include
3825
+ });
3826
+ }
3827
+ });
3828
+ /**
3829
+ * The `download_assets` tool: streams dotCMS file assets to a local directory — the bytes
3830
+ * never pass through the model. Needs Node or Bun. Resolves to a {@link DownloadAssetsManifest}.
3831
+ * `options.root` bounds which local directories the model may write into.
3832
+ */
3833
+ function downloadAssetsTool(connection, options) {
3834
+ return createTool(definition, connection, options);
3835
+ }
3836
+
3837
+ exports.createPage = createPage;
3838
+ exports.dotcmsConnection = dotcmsConnection;
3839
+ exports.downloadAssets = downloadAssets;
3840
+ exports.downloadAssetsTool = downloadAssetsTool;
3841
+ exports.executeTool = executeTool;
3842
+ exports.isToolFailure = isToolFailure;
3843
+ exports.pageCreateTool = pageCreateTool;
3844
+ exports.pagePlaceContentTool = pagePlaceContentTool;
3845
+ exports.pageVerifyTool = pageVerifyTool;
3846
+ exports.placeContent = placeContent;
3847
+ exports.searchTool = searchTool;
3848
+ exports.uploadAssets = uploadAssets;
3849
+ exports.uploadAssetsTool = uploadAssetsTool;
3850
+ exports.verifyPage = verifyPage;