dotcms 0.2.0 → 26.9.3-1-next.2657

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +681 -320
  2. package/package.json +23 -22
package/index.js CHANGED
@@ -45,9 +45,6 @@ var confirmExclude = (files) => ask(
45
45
  `Add ${files.length === 1 ? "this file" : `these ${files.length} files`} to .gitignore? They contain an access token.`
46
46
  );
47
47
 
48
- // libs/sdk/cli/src/commands/agent/setup.ts
49
- import { parse as parseToml } from "smol-toml";
50
-
51
48
  // libs/sdk/cli/src/commands/agent/connect.ts
52
49
  import * as childProcess from "node:child_process";
53
50
 
@@ -60,25 +57,49 @@ var SERVER_ENV = {
60
57
  };
61
58
  var SKILLS_SOURCE = "dotCMS/agent-toolkit";
62
59
 
60
+ // libs/sdk/cli/src/shared/env.ts
61
+ var ENV_KEYS = {
62
+ url: "DOTCMS_URL",
63
+ password: "DOTCMS_PASSWORD",
64
+ authToken: "DOTCMS_AUTH_TOKEN",
65
+ codexHome: "CODEX_HOME"
66
+ };
67
+ function readEnv(key) {
68
+ const value = process.env[key];
69
+ return value && value.trim() !== "" ? value : void 0;
70
+ }
71
+ function envWithoutSecrets(extra = {}) {
72
+ const clean = { ...process.env };
73
+ delete clean[ENV_KEYS.authToken];
74
+ delete clean[ENV_KEYS.password];
75
+ return { ...clean, ...extra };
76
+ }
77
+
63
78
  // libs/sdk/cli/src/commands/agent/connect.ts
64
79
  var DEFAULT_TIMEOUT_MS = 6e4;
65
- function classify(stderr, code) {
80
+ function classify(stderr) {
66
81
  if (/404|E404|not found|ETARGET|ENOTFOUND|registry/i.test(stderr))
67
82
  return "fetch-failed";
68
83
  if (/Unsupported engine|requires Node|SyntaxError|Unexpected token/i.test(stderr)) {
69
84
  return "runtime-unsupported";
70
85
  }
71
- return code === null ? "exited" : "exited";
86
+ return "exited";
72
87
  }
73
88
  async function confirmConnection(args) {
74
89
  const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
75
90
  const child = childProcess.spawn("npx", ["-y", MCP_SERVER_PACKAGE], {
76
91
  stdio: ["pipe", "pipe", "pipe"],
77
- env: {
78
- ...process.env,
92
+ // `npx` is `npx.cmd` on Windows and Node refuses `.cmd` without a shell since the
93
+ // CVE-2024-27980 fix — so FR-024a failed on every Windows run, reporting a broken
94
+ // server for a configuration that was written correctly.
95
+ shell: process.platform === "win32",
96
+ // This child needs the URL and the token; it has no use for the developer's
97
+ // DOTCMS_PASSWORD, and it is an unpinned `@latest` package by design — so the smaller
98
+ // the environment it sees, the better.
99
+ env: envWithoutSecrets({
79
100
  [SERVER_ENV.url]: args.url,
80
101
  [SERVER_ENV.token]: args.token
81
- }
102
+ })
82
103
  });
83
104
  let stderr = "";
84
105
  child.stderr?.on("data", (chunk) => {
@@ -92,23 +113,48 @@ async function confirmConnection(args) {
92
113
  settled = true;
93
114
  clearTimeout(timer);
94
115
  try {
95
- child.kill();
116
+ child.stdin?.end();
117
+ } catch {
118
+ }
119
+ try {
120
+ if (process.platform === "win32" && child.pid) {
121
+ childProcess.spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"]);
122
+ } else {
123
+ child.kill();
124
+ }
96
125
  } catch {
97
126
  }
98
127
  resolve2(result);
99
128
  };
100
129
  const timer = setTimeout(
101
- () => finish({ ok: false, cause: "timeout", detail: `No response within ${timeoutMs}ms.` }),
130
+ () => finish({
131
+ ok: false,
132
+ cause: "timeout",
133
+ detail: `No response within ${timeoutMs}ms.`
134
+ }),
102
135
  timeoutMs
103
136
  );
104
137
  let buffer = "";
105
138
  child.stdout?.on("data", (chunk) => {
106
139
  buffer += String(chunk);
107
- for (const line of buffer.split("\n")) {
140
+ const frames = buffer.split("\n");
141
+ buffer = frames.pop() ?? "";
142
+ for (const line of frames) {
108
143
  if (!line.trim())
109
144
  continue;
110
145
  try {
111
146
  const message = JSON.parse(line);
147
+ if (message.id === 1) {
148
+ child.stdin?.write(
149
+ `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}
150
+ `
151
+ );
152
+ child.stdin?.write(
153
+ `${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" })}
154
+ `
155
+ );
156
+ continue;
157
+ }
112
158
  const tools = message.result?.tools;
113
159
  if (Array.isArray(tools))
114
160
  finish({ ok: true, toolCount: tools.length });
@@ -124,7 +170,7 @@ async function confirmConnection(args) {
124
170
  "exit",
125
171
  (code) => finish({
126
172
  ok: false,
127
- cause: classify(stderr, code),
173
+ cause: classify(stderr),
128
174
  detail: stderr.trim().split("\n").slice(-1)[0] || `Server exited with code ${code}.`
129
175
  })
130
176
  );
@@ -133,100 +179,32 @@ async function confirmConnection(args) {
133
179
  jsonrpc: "2.0",
134
180
  id: 1,
135
181
  method: "initialize",
136
- params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "dotcms", version: "0" } }
182
+ params: {
183
+ protocolVersion: "2024-11-05",
184
+ capabilities: {},
185
+ clientInfo: { name: "dotcms", version: "0" }
186
+ }
137
187
  })}
138
188
  `
139
189
  );
140
- child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" })}
141
- `);
142
190
  });
143
191
  }
144
192
 
145
193
  // libs/sdk/cli/src/commands/agent/gitignore.ts
194
+ import { spawnSync as spawnSync2 } from "node:child_process";
146
195
  import { existsSync } from "node:fs";
147
- import * as fs from "node:fs/promises";
148
- import * as path from "node:path";
149
- var CONVENTIONALLY_COMMITTED = /* @__PURE__ */ new Set([".mcp.json"]);
150
- function findRepositoryRoot(from) {
151
- let dir = path.resolve(from);
152
- for (; ; ) {
153
- if (existsSync(path.join(dir, ".git")))
154
- return dir;
155
- const parent = path.dirname(dir);
156
- if (parent === dir)
157
- return null;
158
- dir = parent;
159
- }
160
- }
161
- async function protectFromVersionControl(args) {
162
- const warnings = [];
163
- const root = findRepositoryRoot(args.cwd);
164
- for (const file of args.files) {
165
- if (CONVENTIONALLY_COMMITTED.has(path.basename(file))) {
166
- warnings.push(
167
- `${path.basename(file)} is normally committed to version control \u2014 it now holds a token, so committing it would publish that token.`
168
- );
169
- }
170
- }
171
- if (!root) {
172
- warnings.push(
173
- "This directory is not under version control, so these files are unprotected \u2014 nothing here can exclude them for you."
174
- );
175
- return { files: args.files, inRepository: false, excluded: false, warnings };
176
- }
177
- const proceed = args.confirmExclude ? await args.confirmExclude(args.files) : false;
178
- if (!proceed) {
179
- return { files: args.files, inRepository: true, excluded: false, warnings };
180
- }
181
- const gitignorePath = path.join(root, ".gitignore");
182
- let current = "";
183
- try {
184
- current = await fs.readFile(gitignorePath, "utf8");
185
- } catch {
186
- }
187
- const already = new Set(current.split("\n").map((line) => line.trim()));
188
- const toAdd = args.files.map((file) => path.relative(root, file).split(path.sep).join("/")).filter((entry) => !already.has(entry));
189
- if (toAdd.length) {
190
- const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
191
- const block = `${prefix}
192
- # dotCMS agent configuration \u2014 contains an access token
193
- ${toAdd.join("\n")}
194
- `;
195
- await fs.writeFile(gitignorePath, current + block, "utf8");
196
- }
197
- return { files: args.files, inRepository: true, excluded: true, warnings };
198
- }
199
-
200
- // libs/sdk/cli/src/commands/agent/skills.ts
201
- import * as childProcess2 from "node:child_process";
202
- function buildSkillsArgs(agentIds, global) {
203
- return [
204
- "-y",
205
- "skills",
206
- "add",
207
- SKILLS_SOURCE,
208
- ...agentIds.flatMap((id) => ["-a", id]),
209
- ...global ? ["-g"] : [],
210
- "-y"
211
- ];
212
- }
213
- async function installSkills(args) {
214
- const argv = buildSkillsArgs(args.agentIds, args.global);
215
- const command = `npx ${argv.join(" ")}`;
216
- try {
217
- const result = childProcess2.spawnSync("npx", argv, { stdio: "inherit" });
218
- if (result.status === 0)
219
- return { ok: true, command };
220
- return { ok: false, command, reason: `skills exited with code ${result.status}` };
221
- } catch (error) {
222
- return { ok: false, command, reason: error.message };
223
- }
224
- }
225
-
226
- // libs/sdk/cli/src/shared/config-file.ts
227
196
  import * as fs2 from "node:fs/promises";
228
197
  import * as path2 from "node:path";
229
198
 
199
+ // libs/sdk/cli/src/shared/config-file.ts
200
+ import {
201
+ findNodeAtLocation,
202
+ parse as parseJsonc,
203
+ parseTree
204
+ } from "jsonc-parser";
205
+ import * as fs from "node:fs/promises";
206
+ import * as path from "node:path";
207
+
230
208
  // libs/sdk/cli/src/shared/errors.ts
231
209
  var CliError = class extends Error {
232
210
  constructor(message) {
@@ -236,22 +214,52 @@ var CliError = class extends Error {
236
214
  };
237
215
  var UsageError = class extends CliError {
238
216
  };
217
+ function withoutCredentials(value) {
218
+ try {
219
+ const u = new URL(value);
220
+ u.username = "";
221
+ u.password = "";
222
+ const path5 = u.pathname === "/" && !/\/$/.test(value) ? "" : u.pathname;
223
+ return `${u.protocol}//${u.host}${path5}${u.search}`;
224
+ } catch {
225
+ return value.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i, "$1");
226
+ }
227
+ }
239
228
  var InvalidUrlError = class extends CliError {
240
229
  constructor(value) {
241
- const host = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^\/+/, "");
230
+ const safe = withoutCredentials(value);
231
+ const host = safe.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^\/+/, "");
242
232
  super(
243
- `"${value}" is not a valid instance address. Pass it with an http:// or https:// scheme, e.g. https://${host || "demo.dotcms.com"}`
233
+ `"${safe}" is not a valid instance address. Pass it with an http:// or https:// scheme, e.g. https://${host || "demo.dotcms.com"}`
234
+ );
235
+ }
236
+ };
237
+ var CredentialInUrlError = class extends CliError {
238
+ constructor(url) {
239
+ const safe = withoutCredentials(url);
240
+ super(
241
+ `The instance address must not carry a username or password. Use ${safe} on its own \u2014 setup will ask you to sign in, or pass --authToken.`
244
242
  );
245
243
  }
246
244
  };
247
245
  var InstanceUnreachableError = class extends CliError {
248
246
  constructor(url, reason) {
249
- super(`Could not reach ${url} \u2014 ${reason}. Check the address and that the instance is running.`);
247
+ super(
248
+ `Could not reach ${url} \u2014 ${reason}. Check the address and that the instance is running.`
249
+ );
250
250
  }
251
251
  };
252
252
  var NotADotCmsInstanceError = class extends CliError {
253
253
  constructor(url) {
254
- super(`${url} is not a valid dotCMS instance. Check the address.`);
254
+ let hint = " Check the address.";
255
+ try {
256
+ const parsed = new URL(url);
257
+ if (parsed.pathname !== "/" && parsed.pathname !== "") {
258
+ hint = ` That address includes a path; the instance itself is probably ${parsed.origin}.`;
259
+ }
260
+ } catch {
261
+ }
262
+ super(`${url} is not a valid dotCMS instance.${hint}`);
255
263
  }
256
264
  };
257
265
  var CredentialsRejectedError = class extends CliError {
@@ -284,20 +292,31 @@ var ConflictingAuthError = class extends UsageError {
284
292
  };
285
293
  var MissingInputError = class extends UsageError {
286
294
  constructor(what) {
287
- super(`${what} is required and there is no terminal to prompt on. Pass it as an option or set its environment variable.`);
295
+ super(
296
+ `${what} is required and there is no terminal to prompt on. Pass it as an option or set its environment variable.`
297
+ );
288
298
  }
289
299
  };
290
300
  var NoConfigPathError = class extends CliError {
291
301
  constructor(displayName, scope) {
292
302
  super(
293
- `${displayName} has no configuration file at ${scope} scope. Re-run with ${scope === "folder" ? "-g/--global" : "--project"}, or drop it from --agent.`
303
+ `${displayName} has no configuration file at ${scope} scope. Re-run ${scope === "folder" ? "with -g/--global" : "without -g/--global"}, or drop it from --agent.`
304
+ );
305
+ }
306
+ };
307
+ var UnreadableConfigError = class extends CliError {
308
+ constructor(file, reason) {
309
+ super(
310
+ `${file} could not be read \u2014 ${reason}. Nothing was changed. Check the file's permissions, or re-run with --skip-mcp.`
294
311
  );
295
312
  }
296
313
  };
297
314
  var MalformedConfigError = class extends CliError {
298
- constructor(file) {
315
+ /** The format is passed, not sniffed from the extension: both callers know exactly which
316
+ * parser just refused the file. */
317
+ constructor(file, format = "JSON") {
299
318
  super(
300
- `${file} is not valid ${file.endsWith(".toml") ? "TOML" : "JSON"} \u2014 fix it or re-run with --skip-mcp. It has been left untouched.`
319
+ `${file} is not valid ${format} \u2014 fix it or re-run with --skip-mcp. It has been left untouched.`
301
320
  );
302
321
  }
303
322
  };
@@ -306,105 +325,243 @@ var MalformedConfigError = class extends CliError {
306
325
  var CAN_RESTRICT = process.platform !== "win32";
307
326
  var FILE_MODE = 384;
308
327
  var DIR_MODE = 448;
309
- async function readJsonDocument(file) {
310
- let raw;
328
+ async function readFileIfPresent(file) {
311
329
  try {
312
- raw = await fs2.readFile(file, "utf8");
330
+ return await fs.readFile(file, "utf8");
331
+ } catch (error) {
332
+ if (error.code === "ENOENT")
333
+ return null;
334
+ throw new UnreadableConfigError(file, error.message);
335
+ }
336
+ }
337
+ async function writeFileAtomic(file, contents) {
338
+ const temp = `${file}.dotcms-${process.pid}.tmp`;
339
+ try {
340
+ await fs.writeFile(temp, contents, { encoding: "utf8", mode: FILE_MODE });
313
341
  } catch {
314
- return null;
342
+ await fs.writeFile(file, contents, { encoding: "utf8", mode: FILE_MODE });
343
+ return;
344
+ }
345
+ try {
346
+ await fs.rename(temp, file);
347
+ } catch (error) {
348
+ await fs.rm(temp, { force: true });
349
+ throw error;
315
350
  }
351
+ }
352
+ var PARSE_OPTIONS = { allowTrailingComma: true, allowEmptyContent: true };
353
+ function parseOrThrow(raw, file) {
316
354
  if (raw.trim() === "")
317
355
  return {};
318
- try {
319
- return JSON.parse(raw);
320
- } catch {
356
+ const errors = [];
357
+ const doc = parseJsonc(raw, errors, PARSE_OPTIONS);
358
+ if (errors.length > 0 || doc === void 0)
359
+ throw new MalformedConfigError(file);
360
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
321
361
  throw new MalformedConfigError(file);
322
362
  }
363
+ return doc;
364
+ }
365
+ async function readJsonDocument(file) {
366
+ const raw = await readFileIfPresent(file);
367
+ return raw === null ? null : parseOrThrow(raw, file);
368
+ }
369
+ function detectIndent(raw) {
370
+ const match = raw.match(/\n([ \t]+)\S/);
371
+ if (!match)
372
+ return { insertSpaces: true, tabSize: 2 };
373
+ const indent = match[1];
374
+ return indent.startsWith(" ") ? { insertSpaces: false, tabSize: 1 } : { insertSpaces: true, tabSize: indent.length };
323
375
  }
324
376
  async function hasEntry(args) {
325
- let doc;
326
- if (args.parse) {
327
- try {
328
- const raw = await fs2.readFile(args.file, "utf8");
329
- doc = raw.trim() === "" ? {} : args.parse(raw);
330
- } catch {
331
- return false;
332
- }
333
- } else {
334
- doc = await readJsonDocument(args.file);
335
- }
377
+ const doc = await readJsonDocument(args.file).catch(() => null);
336
378
  const container = doc?.[args.containerKey] ?? {};
337
379
  return Object.prototype.hasOwnProperty.call(container, args.entryKey);
338
380
  }
339
381
  async function restrictFile(file, canRestrict = CAN_RESTRICT) {
340
382
  if (!canRestrict)
341
383
  return false;
342
- await fs2.chmod(file, FILE_MODE);
384
+ await fs.chmod(file, FILE_MODE);
343
385
  return true;
344
386
  }
345
387
  async function ensureDir(dir) {
346
- const created = await fs2.mkdir(dir, { recursive: true });
388
+ const created = await fs.mkdir(dir, { recursive: true });
347
389
  if (created && CAN_RESTRICT)
348
- await fs2.chmod(dir, DIR_MODE).catch(() => void 0);
390
+ await fs.chmod(dir, DIR_MODE).catch(() => void 0);
391
+ }
392
+ function renderAt(value, unit, depth) {
393
+ const base = unit.repeat(depth);
394
+ return JSON.stringify(value, null, unit).split("\n").map((line, i) => i === 0 ? line : base + line).join("\n");
395
+ }
396
+ function setProperty(raw, object, key, value, unit, depth) {
397
+ const rendered = renderAt(value, unit, depth);
398
+ const properties = object.children ?? [];
399
+ const existing = properties.find((p) => p.children?.[0]?.value === key);
400
+ if (existing?.children?.[1]) {
401
+ const node = existing.children[1];
402
+ return raw.slice(0, node.offset) + rendered + raw.slice(node.offset + node.length);
403
+ }
404
+ const insertion = `"${key}": ${rendered}`;
405
+ const last = properties[properties.length - 1];
406
+ if (last) {
407
+ const end = last.offset + last.length;
408
+ return `${raw.slice(0, end)},
409
+ ${unit.repeat(depth)}${insertion}${raw.slice(end)}`;
410
+ }
411
+ const close = raw.lastIndexOf("}", object.offset + object.length);
412
+ return `${raw.slice(0, object.offset + 1)}
413
+ ${unit.repeat(depth)}${insertion}
414
+ ${unit.repeat(depth - 1)}${raw.slice(close)}`;
349
415
  }
350
416
  async function writeMerged(args) {
351
- const existing = await readJsonDocument(args.file) ?? {};
417
+ const raw = await readFileIfPresent(args.file);
418
+ const existing = raw === null ? {} : parseOrThrow(raw, args.file);
352
419
  const container = existing[args.containerKey] ?? {};
353
420
  const replacedExisting = Object.prototype.hasOwnProperty.call(container, args.entryKey);
354
- const next = {
355
- ...existing,
356
- [args.containerKey]: { ...container, [args.entryKey]: args.entry }
357
- };
358
- await ensureDir(path2.dirname(args.file));
359
- await fs2.writeFile(args.file, `${JSON.stringify(next, null, 2)}
360
- `, "utf8");
421
+ let next;
422
+ const tree = raw === null ? void 0 : parseTree(raw, [], PARSE_OPTIONS);
423
+ if (raw === null || raw.trim() === "" || tree?.type !== "object") {
424
+ next = `${JSON.stringify({ [args.containerKey]: { [args.entryKey]: args.entry } }, null, 2)}
425
+ `;
426
+ } else {
427
+ const { insertSpaces, tabSize } = detectIndent(raw);
428
+ const unit = insertSpaces ? " ".repeat(tabSize) : " ";
429
+ const containerNode = findNodeAtLocation(tree, [args.containerKey]);
430
+ next = containerNode?.type === "object" ? setProperty(raw, containerNode, args.entryKey, args.entry, unit, 2) : setProperty(
431
+ raw,
432
+ tree,
433
+ args.containerKey,
434
+ { [args.entryKey]: args.entry },
435
+ unit,
436
+ 1
437
+ );
438
+ }
439
+ await ensureDir(path.dirname(args.file));
440
+ await writeFileAtomic(args.file, next);
361
441
  const permissionsApplied = await restrictFile(args.file, args.canRestrict ?? CAN_RESTRICT);
362
442
  return { path: args.file, permissionsApplied, replacedExisting };
363
443
  }
364
444
 
365
- // libs/sdk/cli/src/commands/agent/targets/json-target.ts
366
- function buildEntry(target, url, token) {
367
- const env = { [SERVER_ENV.url]: url, [SERVER_ENV.token]: token };
368
- if (target.entryShape === "opencode-local") {
369
- return {
370
- type: "local",
371
- command: ["npx", "-y", MCP_SERVER_PACKAGE],
372
- enabled: true,
373
- environment: env
374
- };
445
+ // libs/sdk/cli/src/commands/agent/gitignore.ts
446
+ function asPattern(relative2) {
447
+ const escaped = relative2.replace(/([\\*?[\]])/g, "\\$1").replace(/ $/, "\\ ");
448
+ return `/${escaped}`;
449
+ }
450
+ function confirmIgnored(root, files) {
451
+ const missed = [];
452
+ for (const file of files) {
453
+ const result = spawnSync2("git", ["check-ignore", "-q", file], { cwd: root });
454
+ if (result.error || result.status === null || result.status > 1)
455
+ return null;
456
+ if (result.status === 1)
457
+ missed.push(path2.basename(file));
375
458
  }
376
- return { type: "stdio", command: "npx", args: ["-y", MCP_SERVER_PACKAGE], env };
459
+ return missed;
377
460
  }
378
- async function writeJsonTargetDetailed(args) {
379
- const file = args.target.configPath(args.scope, args.cwd);
380
- if (!file)
381
- throw new NoConfigPathError(args.target.displayName, args.scope);
382
- return writeMerged({
383
- file,
384
- containerKey: args.target.containerKey,
385
- entryKey: ENTRY_KEY,
386
- entry: buildEntry(args.target, args.url, args.token)
387
- });
461
+ function findRepositoryRoot(from) {
462
+ let dir = path2.resolve(from);
463
+ for (; ; ) {
464
+ if (existsSync(path2.join(dir, ".git")))
465
+ return dir;
466
+ const parent = path2.dirname(dir);
467
+ if (parent === dir)
468
+ return null;
469
+ dir = parent;
470
+ }
471
+ }
472
+ async function protectFromVersionControl(args) {
473
+ const warnings = [];
474
+ const root = findRepositoryRoot(args.cwd);
475
+ const committed = new Set(args.committedByConvention ?? []);
476
+ for (const file of args.files) {
477
+ if (committed.has(file)) {
478
+ warnings.push(
479
+ `${path2.basename(file)} is normally committed to version control \u2014 it now holds a token, so committing it would publish that token.`
480
+ );
481
+ }
482
+ }
483
+ if (!root) {
484
+ warnings.push(
485
+ "This directory is not under version control, so these files are unprotected \u2014 nothing here can exclude them for you."
486
+ );
487
+ return { files: args.files, inRepository: false, excluded: false, warnings };
488
+ }
489
+ const proceed = args.confirmExclude ? await args.confirmExclude(args.files) : false;
490
+ if (!proceed) {
491
+ return { files: args.files, inRepository: true, excluded: false, warnings };
492
+ }
493
+ const gitignorePath = path2.join(root, ".gitignore");
494
+ const current = await readFileIfPresent(gitignorePath) ?? "";
495
+ const already = new Set(current.split("\n").map((line) => line.trim()));
496
+ const relatives = args.files.map((file) => path2.relative(root, file).split(path2.sep).join("/"));
497
+ const toAdd = relatives.filter((rel) => !already.has(rel) && !already.has(asPattern(rel)));
498
+ if (toAdd.length) {
499
+ const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
500
+ const block = `${prefix}
501
+ # dotCMS agent configuration \u2014 contains an access token
502
+ ${toAdd.map(asPattern).join("\n")}
503
+ `;
504
+ await fs2.writeFile(gitignorePath, current + block, "utf8");
505
+ }
506
+ const unverified = confirmIgnored(root, args.files);
507
+ if (unverified === null) {
508
+ warnings.push(
509
+ "Could not confirm with git that these files are excluded \u2014 check `git status` before committing."
510
+ );
511
+ } else if (unverified.length) {
512
+ warnings.push(
513
+ `.gitignore was written but git still tracks: ${unverified.join(", ")}. Exclude them by hand before committing.`
514
+ );
515
+ }
516
+ return {
517
+ files: args.files,
518
+ inRepository: true,
519
+ excluded: unverified !== null && unverified.length === 0,
520
+ warnings
521
+ };
522
+ }
523
+
524
+ // libs/sdk/cli/src/commands/agent/skills.ts
525
+ import * as childProcess2 from "node:child_process";
526
+ function buildSkillsArgs(agentIds, global) {
527
+ return [
528
+ "-y",
529
+ "skills",
530
+ "add",
531
+ SKILLS_SOURCE,
532
+ ...agentIds.flatMap((id) => ["-a", id]),
533
+ ...global ? ["-g"] : [],
534
+ "-y"
535
+ ];
536
+ }
537
+ async function installSkills(args) {
538
+ const argv = buildSkillsArgs(args.agentIds, args.global);
539
+ const command = `npx ${argv.join(" ")}`;
540
+ try {
541
+ const result = childProcess2.spawnSync("npx", argv, {
542
+ stdio: "inherit",
543
+ // The doc comment above says no secret is passed; `spawnSync` defaults to the whole
544
+ // environment, so it was not true until this line.
545
+ env: envWithoutSecrets(),
546
+ // On Windows `npx` is `npx.cmd`, and since the CVE-2024-27980 fix Node refuses to
547
+ // execute `.cmd`/`.bat` without a shell — so this spawn failed on every Windows run
548
+ // and FR-025 never installed anything there.
549
+ shell: process.platform === "win32"
550
+ });
551
+ if (result.error)
552
+ return { ok: false, command, reason: result.error.message };
553
+ if (result.status === 0)
554
+ return { ok: true, command };
555
+ return { ok: false, command, reason: `skills exited with code ${result.status}` };
556
+ } catch (error) {
557
+ return { ok: false, command, reason: error.message };
558
+ }
388
559
  }
389
560
 
390
561
  // libs/sdk/cli/src/commands/agent/targets/registry.ts
391
562
  import { existsSync as existsSync2 } from "node:fs";
392
563
  import * as os from "node:os";
393
564
  import * as path3 from "node:path";
394
-
395
- // libs/sdk/cli/src/shared/env.ts
396
- var ENV_KEYS = {
397
- url: "DOTCMS_URL",
398
- password: "DOTCMS_PASSWORD",
399
- authToken: "DOTCMS_AUTH_TOKEN",
400
- codexHome: "CODEX_HOME"
401
- };
402
- function readEnv(key) {
403
- const value = process.env[key];
404
- return value && value.trim() !== "" ? value : void 0;
405
- }
406
-
407
- // libs/sdk/cli/src/commands/agent/targets/registry.ts
408
565
  var home = () => os.homedir();
409
566
  var inHome = (...parts) => path3.join(home(), ...parts);
410
567
  var inFolder = (cwd, ...parts) => path3.join(cwd ?? process.cwd(), ...parts);
@@ -431,6 +588,7 @@ var TARGETS = [
431
588
  containerKey: "mcpServers",
432
589
  entryShape: "stdio",
433
590
  detect: probe(".claude"),
591
+ folderConfigIsCommitted: true,
434
592
  configPath: (scope, cwd) => scope === "global" ? inHome(".claude.json") : inFolder(cwd, ".mcp.json")
435
593
  },
436
594
  {
@@ -519,12 +677,48 @@ async function detectTargets() {
519
677
  return results.filter((t) => t !== null);
520
678
  }
521
679
 
680
+ // libs/sdk/cli/src/commands/agent/targets/entry.ts
681
+ function buildEntry(target, url, token) {
682
+ const env = { [SERVER_ENV.url]: url, [SERVER_ENV.token]: token };
683
+ if (target.entryShape === "opencode-local") {
684
+ return {
685
+ type: "local",
686
+ command: ["npx", "-y", MCP_SERVER_PACKAGE],
687
+ enabled: true,
688
+ environment: env
689
+ };
690
+ }
691
+ return { type: "stdio", command: "npx", args: ["-y", MCP_SERVER_PACKAGE], env };
692
+ }
693
+
694
+ // libs/sdk/cli/src/commands/agent/targets/json-target.ts
695
+ function hasJsonEntry(file, target) {
696
+ return hasEntry({ file, containerKey: target.containerKey, entryKey: ENTRY_KEY });
697
+ }
698
+ async function writeJsonTarget(args) {
699
+ const file = args.target.configPath(args.scope, args.cwd);
700
+ if (!file)
701
+ throw new NoConfigPathError(args.target.displayName, args.scope);
702
+ return writeMerged({
703
+ file,
704
+ containerKey: args.target.containerKey,
705
+ entryKey: ENTRY_KEY,
706
+ entry: buildEntry(args.target, args.url, args.token)
707
+ });
708
+ }
709
+
522
710
  // libs/sdk/cli/src/commands/agent/targets/toml-target.ts
523
711
  import { parse, stringify } from "smol-toml";
524
- import * as fs3 from "node:fs/promises";
525
712
  import * as path4 from "node:path";
713
+ async function hasTomlEntry(file, target) {
714
+ const raw = await readFileIfPresent(file);
715
+ return raw === null ? false : findEntrySpan(raw.split("\n"), target.containerKey) !== null;
716
+ }
526
717
  function findEntrySpan(lines, containerKey) {
527
- const ours = new RegExp(`^\\s*\\[\\s*${containerKey}\\.${ENTRY_KEY}\\s*(\\.[^\\]]+)?\\]`);
718
+ const spelt = (key) => `(?:${key}|"${key}"|'${key}')`;
719
+ const ours = new RegExp(
720
+ `^\\s*\\[\\s*${spelt(containerKey)}\\s*\\.\\s*${spelt(ENTRY_KEY)}\\s*(\\.[^\\]]*)?\\]`
721
+ );
528
722
  const anyHeader = /^\s*\[/;
529
723
  const start = lines.findIndex((line) => ours.test(line));
530
724
  if (start === -1)
@@ -551,26 +745,24 @@ async function writeTomlTarget(args) {
551
745
  if (!file) {
552
746
  throw new NoConfigPathError(args.target.displayName, args.scope);
553
747
  }
554
- let original = "";
555
- try {
556
- original = await fs3.readFile(file, "utf8");
557
- } catch {
558
- }
748
+ const original = await readFileIfPresent(file) ?? "";
559
749
  if (original.trim() !== "") {
560
750
  try {
561
751
  parse(original);
562
752
  } catch {
563
- throw new MalformedConfigError(file);
753
+ throw new MalformedConfigError(file, "TOML");
564
754
  }
565
755
  }
566
756
  const block = renderEntry(args.target, args.url, args.token, args.target.containerKey);
567
757
  let next;
758
+ let replacedExisting = false;
568
759
  if (original.trim() === "") {
569
760
  next = block;
570
761
  } else {
571
762
  const lines = original.split("\n");
572
763
  const span = findEntrySpan(lines, args.target.containerKey);
573
764
  if (span) {
765
+ replacedExisting = true;
574
766
  lines.splice(span.start, span.end - span.start, ...block.trimEnd().split("\n"));
575
767
  next = lines.join("\n");
576
768
  } else {
@@ -579,10 +771,50 @@ async function writeTomlTarget(args) {
579
771
  }
580
772
  }
581
773
  await ensureDir(path4.dirname(file));
582
- await fs3.writeFile(file, next.endsWith("\n") ? next : `${next}
583
- `, "utf8");
584
- await restrictFile(file);
585
- return file;
774
+ await writeFileAtomic(file, next.endsWith("\n") ? next : `${next}
775
+ `);
776
+ return { path: file, permissionsApplied: await restrictFile(file), replacedExisting };
777
+ }
778
+
779
+ // libs/sdk/cli/src/commands/agent/targets/writers.ts
780
+ var WRITERS = {
781
+ json: { hasEntry: hasJsonEntry, write: writeJsonTarget },
782
+ toml: { hasEntry: hasTomlEntry, write: writeTomlTarget }
783
+ };
784
+
785
+ // libs/http/src/lib/fetch-retry.ts
786
+ function isSuccessStatus(status) {
787
+ return status >= 200 && status < 300;
788
+ }
789
+ function describeRequestFailure(error) {
790
+ if (isHttpError(error)) {
791
+ if (error.code === "ECONNREFUSED") {
792
+ return "Connection refused - service not accepting connections yet";
793
+ }
794
+ if (error.code === "ETIMEDOUT") {
795
+ return "Connection timeout - service too slow or not responding";
796
+ }
797
+ if (error.code === "ENOTFOUND") {
798
+ return "Host not found (DNS lookup failed)";
799
+ }
800
+ if (error.code === "ECONNRESET") {
801
+ return "Connection reset by the server";
802
+ }
803
+ if (error.code === "CERT_HAS_EXPIRED") {
804
+ return "TLS certificate has expired";
805
+ }
806
+ if (error.code === "DEPTH_ZERO_SELF_SIGNED_CERT" || error.code === "SELF_SIGNED_CERT_IN_CHAIN") {
807
+ return "TLS certificate is self-signed and not trusted";
808
+ }
809
+ if (error.response) {
810
+ return `HTTP ${error.response.status}: ${error.response.statusText}`;
811
+ }
812
+ return error.code || error.message;
813
+ }
814
+ if (error instanceof Error) {
815
+ return error.message;
816
+ }
817
+ return String(error);
586
818
  }
587
819
 
588
820
  // libs/http/src/lib/http.ts
@@ -601,11 +833,37 @@ function isHttpError(error) {
601
833
  return error instanceof HttpError;
602
834
  }
603
835
  var DEFAULT_TIMEOUT_MS2 = 1e4;
604
- function isSuccess(status) {
605
- return status >= 200 && status < 300;
606
- }
607
- async function readBody(response) {
608
- const text = await response.text().catch(() => "");
836
+ var MAX_BODY_BYTES = 10 * 1024 * 1024;
837
+ async function readBody(response, url) {
838
+ const tooLarge = () => new HttpError(`Response from ${url} is too large (over ${MAX_BODY_BYTES} bytes)`, {
839
+ status: response.status,
840
+ code: "EBODYTOOLARGE"
841
+ });
842
+ const declared = Number(response.headers.get("content-length"));
843
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
844
+ throw tooLarge();
845
+ let text = "";
846
+ if (!response.body) {
847
+ text = await response.text().catch(() => "");
848
+ } else {
849
+ const reader = response.body.getReader();
850
+ const decoder = new TextDecoder();
851
+ let size = 0;
852
+ for (; ; ) {
853
+ const { done, value } = await reader.read();
854
+ if (done)
855
+ break;
856
+ if (!value)
857
+ continue;
858
+ size += value.byteLength;
859
+ if (size > MAX_BODY_BYTES) {
860
+ await reader.cancel().catch(() => void 0);
861
+ throw tooLarge();
862
+ }
863
+ text += decoder.decode(value, { stream: true });
864
+ }
865
+ text += decoder.decode();
866
+ }
609
867
  if (!text) {
610
868
  return void 0;
611
869
  }
@@ -624,25 +882,38 @@ async function request(url, init, { token, timeoutMs = DEFAULT_TIMEOUT_MS2, acce
624
882
  }
625
883
  let response;
626
884
  try {
627
- response = await fetch(url, { ...init, headers, signal: controller.signal });
628
- } catch (error) {
629
- const aborted = error?.name === "AbortError";
630
- const cause = error?.cause;
631
- throw new HttpError(
632
- aborted ? `Request to ${url} timed out after ${timeoutMs}ms` : `Request to ${url} failed: ${error?.message ?? String(error)}`,
633
- { status: null, code: aborted ? "ETIMEDOUT" : cause?.code }
634
- );
885
+ try {
886
+ response = await fetch(url, { ...init, headers, signal: controller.signal });
887
+ } catch (error) {
888
+ const aborted = error?.name === "AbortError";
889
+ const cause = error?.cause;
890
+ throw new HttpError(
891
+ aborted ? `Request to ${url} timed out after ${timeoutMs}ms` : `Request to ${url} failed: ${error?.message ?? String(error)}`,
892
+ { status: null, code: aborted ? "ETIMEDOUT" : cause?.code }
893
+ );
894
+ }
895
+ let data;
896
+ try {
897
+ data = await readBody(response, url);
898
+ } catch (error) {
899
+ if (isHttpError(error))
900
+ throw error;
901
+ const aborted = error?.name === "AbortError";
902
+ throw new HttpError(
903
+ aborted ? `Request to ${url} timed out after ${timeoutMs}ms while reading the response` : `Reading the response from ${url} failed: ${error?.message ?? String(error)}`,
904
+ { status: response.status, code: aborted ? "ETIMEDOUT" : void 0 }
905
+ );
906
+ }
907
+ if (!isSuccessStatus(response.status) && !acceptAnyStatus) {
908
+ throw new HttpError(`Request failed with status code ${response.status}`, {
909
+ status: response.status,
910
+ statusText: response.statusText
911
+ });
912
+ }
913
+ return { status: response.status, data };
635
914
  } finally {
636
915
  clearTimeout(timer);
637
916
  }
638
- const data = await readBody(response);
639
- if (!isSuccess(response.status) && !acceptAnyStatus) {
640
- throw new HttpError(`Request failed with status code ${response.status}`, {
641
- status: response.status,
642
- statusText: response.statusText
643
- });
644
- }
645
- return { status: response.status, data };
646
917
  }
647
918
  function httpGet(url, options = {}) {
648
919
  return request(url, { method: "GET" }, options);
@@ -659,38 +930,6 @@ function httpPost(url, body, options = {}) {
659
930
  );
660
931
  }
661
932
 
662
- // libs/http/src/lib/fetch-retry.ts
663
- function describeRequestFailure(error) {
664
- if (isHttpError(error)) {
665
- if (error.code === "ECONNREFUSED") {
666
- return "Connection refused - service not accepting connections yet";
667
- }
668
- if (error.code === "ETIMEDOUT") {
669
- return "Connection timeout - service too slow or not responding";
670
- }
671
- if (error.code === "ENOTFOUND") {
672
- return "Host not found (DNS lookup failed)";
673
- }
674
- if (error.code === "ECONNRESET") {
675
- return "Connection reset by the server";
676
- }
677
- if (error.code === "CERT_HAS_EXPIRED") {
678
- return "TLS certificate has expired";
679
- }
680
- if (error.code === "DEPTH_ZERO_SELF_SIGNED_CERT" || error.code === "SELF_SIGNED_CERT_IN_CHAIN") {
681
- return "TLS certificate is self-signed and not trusted";
682
- }
683
- if (error.response) {
684
- return `HTTP ${error.response.status}: ${error.response.statusText}`;
685
- }
686
- return error.code || error.message;
687
- }
688
- if (error instanceof Error) {
689
- return error.message;
690
- }
691
- return String(error);
692
- }
693
-
694
933
  // libs/http/src/lib/endpoints.ts
695
934
  var DOTCMS_API = {
696
935
  /** Reachability probe, and the source of the instance version. */
@@ -737,14 +976,29 @@ async function verifyToken(url, token) {
737
976
  function normalizeUrl(url) {
738
977
  return url.trim().replace(/\/+$/, "");
739
978
  }
979
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
980
+ function insecureTransportWarning(url) {
981
+ let parsed;
982
+ try {
983
+ parsed = new URL(url);
984
+ } catch {
985
+ return null;
986
+ }
987
+ if (parsed.protocol !== "http:" || LOOPBACK.has(parsed.hostname))
988
+ return null;
989
+ return `${parsed.host} is plain http, so your password and the access token cross the network in the clear. Use https:// if the instance supports it.`;
990
+ }
740
991
  function validateUrl(url) {
741
992
  if (!/^https?:\/\//i.test(url))
742
993
  throw new InvalidUrlError(url);
994
+ let parsed;
743
995
  try {
744
- new URL(url);
996
+ parsed = new URL(url);
745
997
  } catch {
746
998
  throw new InvalidUrlError(url);
747
999
  }
1000
+ if (parsed.username || parsed.password)
1001
+ throw new CredentialInUrlError(url);
748
1002
  }
749
1003
  async function checkReachable(url) {
750
1004
  let response;
@@ -773,6 +1027,26 @@ function readVersion(data) {
773
1027
  const candidate = info?.version;
774
1028
  return typeof candidate === "string" && candidate.trim() !== "" ? candidate : null;
775
1029
  }
1030
+ function parseVersion(value) {
1031
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
1032
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
1033
+ }
1034
+ function compatibilityWarning(instanceVersion, toolVersion) {
1035
+ if (!instanceVersion)
1036
+ return null;
1037
+ const instance = parseVersion(instanceVersion);
1038
+ const tool = parseVersion(toolVersion);
1039
+ if (!instance || !tool)
1040
+ return null;
1041
+ for (let i = 0; i < 3; i++) {
1042
+ if (instance[i] > tool[i])
1043
+ return null;
1044
+ if (instance[i] < tool[i]) {
1045
+ return `This tool targets dotCMS ${toolVersion}, but the instance reports ${instanceVersion}. Install dotcms@${instanceVersion} to match your instance.`;
1046
+ }
1047
+ }
1048
+ return null;
1049
+ }
776
1050
 
777
1051
  // libs/sdk/cli/src/shared/prompts.ts
778
1052
  var DEFAULT_URL = "http://localhost:8082";
@@ -791,50 +1065,52 @@ async function resolveInstanceUrl(opts, port, interactive = Boolean(port)) {
791
1065
  return url;
792
1066
  }
793
1067
  async function resolveRequiredInputs(opts, port, interactive = Boolean(port)) {
794
- let prompted = false;
795
1068
  const ask2 = async (what, run) => {
796
1069
  if (!interactive || !port)
797
1070
  throw new MissingInputError(what);
798
- prompted = true;
799
1071
  return run();
800
1072
  };
801
1073
  const url = await resolveInstanceUrl(opts, port, interactive);
802
- if (!opts.url && !readEnv(ENV_KEYS.url))
803
- prompted = true;
804
1074
  const authToken = opts.authToken ?? readEnv(ENV_KEYS.authToken);
805
1075
  const user = opts.user;
806
1076
  const password = opts.password ?? readEnv(ENV_KEYS.password);
807
1077
  if (authToken)
808
- return { url, authToken, prompted };
1078
+ return { url, authToken };
809
1079
  if (user && password)
810
- return { url, user, password, prompted };
1080
+ return { url, user, password };
811
1081
  if (user) {
812
1082
  const typed = await ask2("A password", () => port.password("Password"));
813
- return { url, user, password: typed, prompted };
1083
+ return { url, user, password: typed };
814
1084
  }
815
1085
  if (password) {
816
1086
  const typed = await ask2("A username", () => port.text("Username"));
817
- return { url, user: typed, password, prompted };
1087
+ return { url, user: typed, password };
818
1088
  }
819
1089
  if (!interactive || !port) {
820
1090
  throw new MissingInputError("A username and password, or an authentication token,");
821
1091
  }
822
- prompted = true;
1092
+ return { url, ...await promptForAuth(port) };
1093
+ }
1094
+ async function promptForAuth(port) {
823
1095
  const mode = await port.select("How should we authenticate?", [
824
1096
  { name: "Sign in with a username and password", value: "signin" },
825
1097
  { name: "Paste an existing authentication token", value: "token" }
826
1098
  ]);
827
1099
  if (mode === "token") {
828
- return { url, authToken: await port.password("Authentication token"), prompted };
1100
+ return { authToken: await port.password("Authentication token") };
829
1101
  }
830
1102
  return {
831
- url,
832
1103
  user: await port.text("Username"),
833
- password: await port.password("Password"),
834
- prompted
1104
+ password: await port.password("Password")
835
1105
  };
836
1106
  }
837
1107
 
1108
+ // libs/sdk/cli/package.json
1109
+ var version = "26.9.3-1-next.2657";
1110
+
1111
+ // libs/sdk/cli/src/shared/version.ts
1112
+ var TOOL_VERSION = version;
1113
+
838
1114
  // libs/sdk/cli/src/commands/agent/setup.ts
839
1115
  var MAX_AUTH_ATTEMPTS = 3;
840
1116
  function resolveAuthMode(opts) {
@@ -861,10 +1137,31 @@ async function runSetup(opts) {
861
1137
  const auth = resolveAuthMode(opts);
862
1138
  const explicitTargets = resolveTargets(opts);
863
1139
  const scope = opts.scope ?? "folder";
1140
+ const outcome = (targetId, path5, result, reason = null, extra = {}) => ({
1141
+ targetId,
1142
+ scope,
1143
+ path: path5,
1144
+ result,
1145
+ reason,
1146
+ permissionsApplied: false,
1147
+ skillsInstalled: "no",
1148
+ ...extra
1149
+ });
864
1150
  const step = opts.onProgress ?? (() => void 0);
1151
+ const warnings = [];
865
1152
  const url = await resolveInstanceUrl(opts, opts.promptPort);
866
1153
  step(`Checking ${url}`);
867
- await checkReachable(url);
1154
+ const instance = await checkReachable(url);
1155
+ const warning = compatibilityWarning(instance.version, TOOL_VERSION);
1156
+ if (warning) {
1157
+ warnings.push(warning);
1158
+ opts.onWarning?.(warning);
1159
+ }
1160
+ const insecure = insecureTransportWarning(url);
1161
+ if (insecure) {
1162
+ warnings.push(insecure);
1163
+ opts.onWarning?.(insecure);
1164
+ }
868
1165
  let inputs = await resolveRequiredInputs(
869
1166
  { ...opts, url, authToken: auth.token, user: auth.user, password: auth.password },
870
1167
  opts.promptPort
@@ -890,7 +1187,8 @@ async function runSetup(opts) {
890
1187
  if (!rejected || !opts.promptPort || attempt >= MAX_AUTH_ATTEMPTS)
891
1188
  throw error;
892
1189
  opts.onAuthRetry?.(error.message, attempt, MAX_AUTH_ATTEMPTS);
893
- inputs = await resolveRequiredInputs({ url, cwd: opts.cwd }, opts.promptPort);
1190
+ const fresh = await promptForAuth(opts.promptPort);
1191
+ inputs = { url, ...fresh };
894
1192
  }
895
1193
  }
896
1194
  let targets;
@@ -923,81 +1221,73 @@ async function runSetup(opts) {
923
1221
  seen.add(file);
924
1222
  plan.push({ target, file });
925
1223
  }
1224
+ const configuredNothing = !plan.length && !opts.skipMcp;
1225
+ if (configuredNothing) {
1226
+ const noEditors = `No editor was configured. None of the supported editors was detected, and none was named. Re-run with --agent <id>, choosing from: ${TARGET_IDS.join(", ")}.`;
1227
+ warnings.push(noEditors);
1228
+ opts.onWarning?.(noEditors);
1229
+ }
926
1230
  const outcomes = [];
927
1231
  if (opts.skipMcp) {
928
1232
  for (const { target, file } of plan) {
929
- outcomes.push({
930
- targetId: target.id,
931
- scope,
932
- path: file,
933
- result: "skipped",
934
- reason: "configuration writing skipped (--skip-mcp)",
935
- permissionsApplied: false,
936
- skillsInstalled: "no"
937
- });
1233
+ outcomes.push(
1234
+ outcome(target.id, file, "skipped", "configuration writing skipped (--skip-mcp)")
1235
+ );
938
1236
  }
939
- }
940
- if (!opts.skipMcp && plan.length) {
1237
+ } else if (plan.length) {
941
1238
  step(`Writing configuration for ${plan.length} editor${plan.length === 1 ? "" : "s"}`);
942
1239
  }
943
1240
  for (const { target, file } of opts.skipMcp ? [] : plan) {
944
1241
  try {
945
- const isToml = target.format === "toml";
946
- const existing = await hasEntry({
947
- file,
948
- containerKey: target.containerKey,
949
- entryKey: ENTRY_KEY,
950
- parse: isToml ? (raw) => parseToml(raw) : void 0
951
- });
1242
+ const writer = WRITERS[target.format];
1243
+ const existing = await writer.hasEntry(file, target);
952
1244
  if (existing && !opts.force && !opts.yes && opts.confirmOverwrite) {
953
1245
  const proceed = await opts.confirmOverwrite(file);
954
1246
  if (!proceed) {
955
- outcomes.push({
956
- targetId: target.id,
957
- scope,
958
- path: file,
959
- result: "skipped",
960
- reason: "left the existing entry in place",
961
- permissionsApplied: false,
962
- skillsInstalled: "no"
963
- });
1247
+ outcomes.push(
1248
+ outcome(target.id, file, "skipped", "left the existing entry in place")
1249
+ );
964
1250
  continue;
965
1251
  }
966
1252
  }
967
- const written2 = isToml ? {
968
- path: await writeTomlTarget({ target, scope, url, token: token.value, cwd: opts.cwd }),
969
- permissionsApplied: CAN_RESTRICT,
970
- replacedExisting: existing
971
- } : await writeJsonTargetDetailed({ target, scope, url, token: token.value, cwd: opts.cwd });
972
- outcomes.push({
973
- targetId: target.id,
1253
+ const written2 = await writer.write({
1254
+ target,
974
1255
  scope,
975
- path: written2.path,
976
- result: written2.replacedExisting ? "replaced" : "written",
977
- reason: null,
978
- permissionsApplied: written2.permissionsApplied,
979
- skillsInstalled: "no"
1256
+ url,
1257
+ token: token.value,
1258
+ cwd: opts.cwd
980
1259
  });
1260
+ outcomes.push(
1261
+ outcome(
1262
+ target.id,
1263
+ written2.path,
1264
+ written2.replacedExisting ? "replaced" : "written",
1265
+ null,
1266
+ { permissionsApplied: written2.permissionsApplied }
1267
+ )
1268
+ );
981
1269
  } catch (error) {
982
- outcomes.push({
983
- targetId: target.id,
984
- scope,
985
- path: file,
986
- result: "failed",
987
- reason: error.message,
988
- permissionsApplied: false,
989
- skillsInstalled: "no"
990
- });
1270
+ outcomes.push(outcome(target.id, file, "failed", error.message));
991
1271
  }
992
1272
  }
993
1273
  let versionControl;
994
1274
  const written = outcomes.filter((o) => (o.result === "written" || o.result === "replaced") && o.path).map((o) => o.path);
995
1275
  if (scope === "folder" && written.length) {
996
- versionControl = await protectFromVersionControl({
997
- files: written,
998
- cwd: opts.cwd ?? process.cwd(),
999
- confirmExclude: opts.yes ? async () => true : opts.confirmExclude
1000
- });
1276
+ try {
1277
+ versionControl = await protectFromVersionControl({
1278
+ files: written,
1279
+ // Which of them a project would normally commit is the registry's to say, not a
1280
+ // basename set inside the gitignore module.
1281
+ committedByConvention: plan.filter(({ target }) => target.folderConfigIsCommitted).map(({ file }) => file),
1282
+ cwd: opts.cwd ?? process.cwd(),
1283
+ confirmExclude: opts.yes ? async () => true : opts.confirmExclude
1284
+ });
1285
+ } catch (error) {
1286
+ const vcFailed = `Could not update .gitignore \u2014 ${error.message}. These files hold an access token and are NOT excluded from version control:
1287
+ ${written.join("\n ")}`;
1288
+ warnings.push(vcFailed);
1289
+ opts.onWarning?.(vcFailed);
1290
+ }
1001
1291
  }
1002
1292
  if (!opts.skipSkills) {
1003
1293
  const eligible = opts.skipMcp ? plan.map((p) => p.target) : outcomes.filter((o) => o.result !== "failed").map((o) => byId.get(o.targetId)).filter((t) => Boolean(t));
@@ -1005,6 +1295,12 @@ async function runSetup(opts) {
1005
1295
  if (ids.length) {
1006
1296
  step("Installing the dotCMS skills");
1007
1297
  const skills = await installSkills({ agentIds: ids, global: scope === "global" });
1298
+ if (!skills.ok) {
1299
+ const failed = `Skills were not installed${skills.reason ? ` \u2014 ${skills.reason}` : ""}. Run this when the problem is fixed:
1300
+ ${skills.command}`;
1301
+ warnings.push(failed);
1302
+ opts.onWarning?.(failed);
1303
+ }
1008
1304
  for (const o of outcomes) {
1009
1305
  if (o.result === "failed")
1010
1306
  continue;
@@ -1016,14 +1312,24 @@ async function runSetup(opts) {
1016
1312
  let connection = "skipped";
1017
1313
  let connectionReason;
1018
1314
  if (!opts.skipVerify && !opts.skipMcp) {
1019
- step("Starting the server to confirm it responds (this can take a minute on a cold npx cache)");
1315
+ step(
1316
+ "Starting the server to confirm it responds (this can take a minute on a cold npx cache)"
1317
+ );
1020
1318
  const result = await confirmConnection({ url, token: token.value });
1021
1319
  connection = result.ok ? "ok" : "failed";
1022
1320
  if (!result.ok)
1023
1321
  connectionReason = `${result.cause}: ${result.detail}`;
1024
1322
  }
1025
1323
  const anyFailed = outcomes.some((o) => o.result === "failed") || connection === "failed";
1026
- return { outcomes, versionControl, connection, connectionReason, exitCode: anyFailed ? 1 : 0 };
1324
+ return {
1325
+ outcomes,
1326
+ versionControl,
1327
+ warnings,
1328
+ connection,
1329
+ connectionReason,
1330
+ skillsSkipped: Boolean(opts.skipSkills),
1331
+ exitCode: anyFailed || configuredNothing ? 1 : 0
1332
+ };
1027
1333
  }
1028
1334
 
1029
1335
  // libs/sdk/cli/src/shared/ui.ts
@@ -1041,10 +1347,11 @@ var RESULT_MARK = {
1041
1347
  };
1042
1348
  function renderSummary(input) {
1043
1349
  const lines = [];
1350
+ const idWidth = Math.max(...input.outcomes.map((o) => o.targetId.length), 0);
1044
1351
  for (const o of input.outcomes) {
1045
1352
  const bits = [
1046
1353
  ` ${RESULT_MARK[o.result]}`,
1047
- o.targetId.padEnd(13),
1354
+ o.targetId.padEnd(idWidth),
1048
1355
  o.scope.padEnd(7),
1049
1356
  o.path ?? "\u2014"
1050
1357
  ];
@@ -1055,9 +1362,14 @@ function renderSummary(input) {
1055
1362
  lines.push(" could not restrict file permissions on this platform");
1056
1363
  }
1057
1364
  if (o.skillsInstalled === "unverified") {
1058
- lines.push(" skills location unverified for this editor \u2014 not confirmed installed");
1365
+ lines.push(
1366
+ " skills location unverified for this editor \u2014 not confirmed installed"
1367
+ );
1059
1368
  }
1060
1369
  }
1370
+ for (const w of input.warnings ?? []) {
1371
+ lines.push(chalk.yellow(` ! ${w}`));
1372
+ }
1061
1373
  if (input.versionControl?.files.length) {
1062
1374
  const vc = input.versionControl;
1063
1375
  lines.push("");
@@ -1071,6 +1383,9 @@ function renderSummary(input) {
1071
1383
  for (const w of vc.warnings)
1072
1384
  lines.push(chalk.yellow(` ! ${w}`));
1073
1385
  }
1386
+ if (input.skillsSkipped) {
1387
+ lines.push(" \xB7 skills installation skipped (--skip-skills)");
1388
+ }
1074
1389
  lines.push("");
1075
1390
  if (input.connection === "ok") {
1076
1391
  lines.push(chalk.green(" \u2713 server responded"));
@@ -1080,10 +1395,16 @@ function renderSummary(input) {
1080
1395
  lines.push(chalk.red(` \u2717 ${input.connectionReason ?? "the server did not start"}`));
1081
1396
  lines.push(" Configuration was written and left in place; the server did not come up.");
1082
1397
  }
1083
- const allGood = input.connection === "ok" && input.outcomes.every((o) => o.result !== "failed");
1398
+ const done = (o) => o.result === "written" || o.result === "replaced";
1399
+ const left = input.outcomes.filter((o) => !done(o) && o.result !== "failed");
1400
+ const allGood = input.connection === "ok" && input.outcomes.length > 0 && input.outcomes.every(done);
1401
+ lines.push("");
1084
1402
  if (allGood) {
1085
- lines.push("");
1086
1403
  lines.push(` Ready \u2014 ${input.nextStep ?? "open your editor and start using dotCMS."}`);
1404
+ } else if (left.length && input.connection === "ok") {
1405
+ lines.push(
1406
+ ` ${left.length} editor${left.length === 1 ? " was" : "s were"} left unchanged; what they already point at was not verified.`
1407
+ );
1087
1408
  }
1088
1409
  return lines.join("\n");
1089
1410
  }
@@ -1106,8 +1427,24 @@ function makeProgress(interactive) {
1106
1427
  },
1107
1428
  done() {
1108
1429
  spinner?.stop();
1430
+ spinner = null;
1109
1431
  },
1110
- /** Leave the failed step visible instead of a spinner that never stops. */
1432
+ /**
1433
+ * Hand the terminal over to a question.
1434
+ *
1435
+ * Keeps the finished step visible, then stops repainting. `done()` erases the line;
1436
+ * here the step really did complete, so it should stay on screen above the prompt.
1437
+ */
1438
+ pause() {
1439
+ spinner?.succeed();
1440
+ spinner = null;
1441
+ },
1442
+ /**
1443
+ * Leave the failed step visible instead of a spinner that never stops.
1444
+ *
1445
+ * `warn` was a byte-identical second copy of this: a retry notice and a failure render
1446
+ * the same way — mark the attempt failed, then carry on.
1447
+ */
1111
1448
  fail(text) {
1112
1449
  if (spinner)
1113
1450
  spinner.fail(text);
@@ -1115,16 +1452,37 @@ function makeProgress(interactive) {
1115
1452
  writeOut(` \u2717 ${text}`);
1116
1453
  spinner = null;
1117
1454
  },
1118
- /** A retry notice: mark the attempt failed, then carry on. */
1119
1455
  warn(text) {
1120
- if (spinner)
1121
- spinner.fail(text);
1122
- else
1123
- writeOut(` \u2717 ${text}`);
1124
- spinner = null;
1456
+ this.fail(text);
1125
1457
  }
1126
1458
  };
1127
1459
  }
1460
+ function pausingPort(port, progress) {
1461
+ return {
1462
+ text(message, defaultValue) {
1463
+ progress.pause();
1464
+ return port.text(message, defaultValue);
1465
+ },
1466
+ password(message) {
1467
+ progress.pause();
1468
+ return port.password(message);
1469
+ },
1470
+ select(message, choices) {
1471
+ progress.pause();
1472
+ return port.select(message, choices);
1473
+ },
1474
+ multiSelect(message, choices) {
1475
+ progress.pause();
1476
+ return port.multiSelect(message, choices);
1477
+ }
1478
+ };
1479
+ }
1480
+ function pausingConfirm(confirm, progress) {
1481
+ return (...args) => {
1482
+ progress.pause();
1483
+ return confirm(...args);
1484
+ };
1485
+ }
1128
1486
  function registerAgentCommand(program2) {
1129
1487
  const agent = program2.command("agent").description("Connect an AI coding agent to dotCMS");
1130
1488
  agent.command("setup").description("Configure your editors to talk to a dotCMS instance").option("--url <url>", "dotCMS instance address (or set DOTCMS_URL)").option("--user <user>", "username, to mint a token").option(
@@ -1138,17 +1496,18 @@ function registerAgentCommand(program2) {
1138
1496
  `editor to configure, repeatable (${TARGET_IDS.join(", ")})`,
1139
1497
  (value, previous = []) => [...previous, value]
1140
1498
  ).option("-g, --global", "write to your user account instead of this folder").option("--skip-mcp", "do not write configuration").option("--skip-skills", "do not install the dotCMS skills").option("--skip-verify", "do not launch the server to confirm it responds").option("-y, --yes", "accept confirmations (never skips a required input)").option("--force", "replace an existing dotcms entry without asking").action(async (options) => {
1141
- if (canPrompt())
1142
- printBanner();
1143
1499
  const interactive = canPrompt();
1500
+ if (interactive)
1501
+ printBanner();
1144
1502
  const progress = makeProgress(interactive);
1145
1503
  try {
1146
1504
  const result = await runSetup({
1147
1505
  onProgress: progress.step,
1148
1506
  onAuthRetry: (message, attempt, max) => progress.warn(`${message} (attempt ${attempt} of ${max})`),
1149
- promptPort: interactive ? inquirerPort : void 0,
1150
- confirmOverwrite: interactive ? confirmOverwrite : void 0,
1151
- confirmExclude: interactive ? confirmExclude : void 0,
1507
+ onWarning: (message) => progress.warn(message),
1508
+ promptPort: interactive ? pausingPort(inquirerPort, progress) : void 0,
1509
+ confirmOverwrite: interactive ? pausingConfirm(confirmOverwrite, progress) : void 0,
1510
+ confirmExclude: interactive ? pausingConfirm(confirmExclude, progress) : void 0,
1152
1511
  url: options["url"],
1153
1512
  user: options["user"],
1154
1513
  password: options["password"],
@@ -1166,8 +1525,10 @@ function registerAgentCommand(program2) {
1166
1525
  renderSummary({
1167
1526
  outcomes: result.outcomes,
1168
1527
  versionControl: result.versionControl,
1528
+ warnings: result.warnings,
1169
1529
  connection: result.connection,
1170
- connectionReason: result.connectionReason
1530
+ connectionReason: result.connectionReason,
1531
+ skillsSkipped: result.skillsSkipped
1171
1532
  })
1172
1533
  );
1173
1534
  process.exitCode = result.exitCode;