@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.
- package/README.md +203 -29
- package/adapter.cjs.default.js +1 -0
- package/adapter.cjs.js +14 -0
- package/adapter.cjs.mjs +2 -0
- package/adapter.cjs2.js +0 -0
- package/adapter.d.ts +1 -0
- package/adapter.esm.js +2 -0
- package/adapter.esm2.js +0 -0
- package/errors.cjs.js +192 -0
- package/errors.esm.js +180 -0
- package/format-result.cjs.js +833 -0
- package/format-result.esm.js +825 -0
- package/package.json +25 -20
- package/runtime.cjs.js +0 -0
- package/runtime.esm.js +0 -0
- package/sandbox.cjs.default.js +1 -0
- package/sandbox.cjs.js +69 -0
- package/sandbox.cjs.mjs +2 -0
- package/sandbox.d.ts +1 -0
- package/sandbox.esm.js +50 -0
- package/spec.cjs.default.js +1 -0
- package/spec.cjs.js +3 -23912
- package/spec.cjs.mjs +2 -0
- package/spec.cjs2.js +23916 -0
- package/spec.d.ts +1 -0
- package/spec.esm.js +1 -23914
- package/spec.esm2.js +23914 -0
- package/src/adapter/context.d.ts +11 -0
- package/src/adapter/request-core.d.ts +30 -0
- package/src/adapter.d.ts +1 -0
- package/src/runtime.d.ts +5 -4
- package/src/sandbox/errors.d.ts +23 -1
- package/src/sandbox/index.d.ts +1 -1
- package/src/sandbox.d.ts +1 -0
- package/src/spec.d.ts +1 -0
- package/src/tools/definitions/download-assets.d.ts +27 -0
- package/src/tools/definitions/execute.d.ts +15 -0
- package/src/tools/definitions/page-create.d.ts +23 -0
- package/src/tools/definitions/page-place-content.d.ts +32 -0
- package/src/tools/definitions/page-verify.d.ts +20 -0
- package/src/tools/definitions/search.d.ts +13 -0
- package/src/tools/definitions/upload-assets.d.ts +23 -0
- package/src/tools/index.d.ts +40 -0
- package/src/tools/operations/download-assets.d.ts +55 -0
- package/src/tools/operations/page-create.d.ts +81 -0
- package/src/tools/operations/page-place-content.d.ts +102 -0
- package/src/tools/operations/page-verify.d.ts +164 -0
- package/src/tools/operations/shared/asset-common.d.ts +28 -0
- package/src/tools/operations/shared/glob.d.ts +22 -0
- package/src/tools/operations/shared/local-root.d.ts +11 -0
- package/src/tools/operations/shared/page-common.d.ts +45 -0
- package/src/tools/operations/shared/page-path.d.ts +49 -0
- package/src/tools/operations/shared/resolve.d.ts +43 -0
- package/src/tools/operations/upload-assets.d.ts +64 -0
- package/src/tools/toolkit/connection.d.ts +56 -0
- package/src/tools/toolkit/create-tool.d.ts +66 -0
- package/src/tools/toolkit/endpoints.d.ts +52 -0
- package/src/tools/toolkit/lenient-boolean.d.ts +14 -0
- package/src/tools/toolkit/results.d.ts +32 -0
- package/src/tools/toolkit/tool-runtime.d.ts +108 -0
- package/src/tools/toolkit/types.d.ts +120 -0
- package/src/tools.d.ts +1 -0
- package/tools.cjs.default.js +1 -0
- package/tools.cjs.js +3850 -0
- package/tools.cjs.mjs +2 -0
- package/tools.d.ts +1 -0
- package/tools.esm.js +3817 -0
- package/index.cjs.default.js +0 -1
- package/index.cjs.js +0 -7
- package/index.cjs.mjs +0 -2
- package/index.d.ts +0 -1
- 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 — \`\` 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 };
|