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