@agent-cms/agentcms 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +143 -0
  2. package/README.md +1119 -2
  3. package/changelog.json +31 -0
  4. package/dist/admin/admin-files.json +48 -0
  5. package/dist/admin/assets/CodeEditor-Wx1WlF3h.js +24 -0
  6. package/dist/admin/assets/flexweg-logo-CdtuqAFA.png +0 -0
  7. package/dist/admin/assets/index-DFVZw8E0.js +5460 -0
  8. package/dist/admin/assets/index-DMcGogR2.css +1 -0
  9. package/dist/admin/assets/sql-wasm-Bfkbn6Oa.js +2 -0
  10. package/dist/admin/assets/sql-wasm-C1U8OeUW.wasm +0 -0
  11. package/dist/admin/assets/sql-wasm-V5jTDcrT.js +1 -0
  12. package/dist/admin/config.js +11 -0
  13. package/dist/admin/external.default.json +72 -0
  14. package/dist/admin/index.html +116 -0
  15. package/dist/admin/plugins/core-seo/bundle.js +55 -0
  16. package/dist/admin/plugins/core-seo/manifest.json +7 -0
  17. package/dist/admin/plugins/flexweg-archives/bundle.js +1414 -0
  18. package/dist/admin/plugins/flexweg-archives/manifest.json +7 -0
  19. package/dist/admin/plugins/flexweg-multilang/bundle.js +1187 -0
  20. package/dist/admin/plugins/flexweg-multilang/manifest.json +7 -0
  21. package/dist/admin/plugins/flexweg-rss/bundle.js +1354 -0
  22. package/dist/admin/plugins/flexweg-rss/manifest.json +7 -0
  23. package/dist/admin/plugins/flexweg-search/bundle.js +1243 -0
  24. package/dist/admin/plugins/flexweg-search/manifest.json +7 -0
  25. package/dist/admin/plugins/flexweg-sitemaps/bundle.js +1293 -0
  26. package/dist/admin/plugins/flexweg-sitemaps/manifest.json +7 -0
  27. package/dist/admin/runtime/cms-runtime.js +164 -0
  28. package/dist/admin/runtime/react-dom-client.js +13 -0
  29. package/dist/admin/runtime/react-dom.js +16 -0
  30. package/dist/admin/runtime/react-i18next.js +21 -0
  31. package/dist/admin/runtime/react-jsx-runtime.js +17 -0
  32. package/dist/admin/runtime/react.js +55 -0
  33. package/dist/admin/themes/corporate/bundle.js +14465 -0
  34. package/dist/admin/themes/corporate/manifest.json +7 -0
  35. package/dist/admin/themes/corporate/theme.css +2 -0
  36. package/dist/admin/themes/magazine/bundle.js +10229 -0
  37. package/dist/admin/themes/magazine/manifest.json +7 -0
  38. package/dist/admin/themes/magazine/theme.css +2 -0
  39. package/dist/admin/themes/marketplace-core/bundle.js +11213 -0
  40. package/dist/admin/themes/marketplace-core/manifest.json +7 -0
  41. package/dist/admin/themes/marketplace-core/theme.css +1613 -0
  42. package/dist/admin/themes/portfolio/bundle.js +9195 -0
  43. package/dist/admin/themes/portfolio/manifest.json +7 -0
  44. package/dist/admin/themes/portfolio/theme.css +2 -0
  45. package/dist/admin/themes/storefront/bundle.js +13172 -0
  46. package/dist/admin/themes/storefront/manifest.json +7 -0
  47. package/dist/admin/themes/storefront/theme.css +2 -0
  48. package/dist/agent/agentcms.mjs +1711 -0
  49. package/dist/agent/chunks/hosting-BftlDdsL.mjs +117 -0
  50. package/dist/agent/chunks/index-BG05qslv.mjs +3608 -0
  51. package/dist/agent/chunks/install-DnmgOT3F.mjs +577 -0
  52. package/dist/agent/chunks/plan-BrM8ztEp.mjs +245 -0
  53. package/dist/agent/chunks/registry-C9TSnZ77.mjs +53517 -0
  54. package/dist/agent/chunks/siteAdd-BcXhN9fk.mjs +118 -0
  55. package/dist/agent/chunks/sql-wasm-GjJDiefO.mjs +10 -0
  56. package/dist/agent/chunks/sqljs-DjSCStJ0.mjs +20 -0
  57. package/dist/agent/vendor/sql-wasm.cjs +188 -0
  58. package/dist/theme-assets/corporate-menu.js +266 -0
  59. package/dist/theme-assets/corporate-posts.js +571 -0
  60. package/dist/theme-assets/corporate.css +2 -0
  61. package/dist/theme-assets/default-menu.js +266 -0
  62. package/dist/theme-assets/default-posts.js +378 -0
  63. package/dist/theme-assets/default.css +1 -0
  64. package/dist/theme-assets/magazine-menu.js +227 -0
  65. package/dist/theme-assets/magazine-posts.js +361 -0
  66. package/dist/theme-assets/magazine.css +2 -0
  67. package/dist/theme-assets/portfolio-filters.js +104 -0
  68. package/dist/theme-assets/portfolio-menu.js +240 -0
  69. package/dist/theme-assets/portfolio-posts.js +358 -0
  70. package/dist/theme-assets/portfolio.css +2 -0
  71. package/dist/theme-assets/storefront-catalog.js +504 -0
  72. package/dist/theme-assets/storefront-menu.js +239 -0
  73. package/dist/theme-assets/storefront-posts.js +404 -0
  74. package/dist/theme-assets/storefront.css +2 -0
  75. package/dist/version.json +9 -0
  76. package/package.json +101 -4
  77. package/plugin/.claude-plugin/plugin.json +16 -0
  78. package/plugin/skills/agentcms-install/SKILL.md +110 -0
  79. package/plugin/skills/agentcms-install/references/cloudflare.md +15 -0
  80. package/plugin/skills/agentcms-install/references/flexweg.md +11 -0
  81. package/plugin/skills/agentcms-install/references/github.md +67 -0
  82. package/plugin/skills/agentcms-install/references/gitlab.md +15 -0
  83. package/plugin/skills/agentcms-install/references/s3.md +81 -0
  84. package/plugin/skills/agentcms-manage/SKILL.md +92 -0
@@ -0,0 +1,1711 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __acmsRequire } from "node:module";
3
+ import { fileURLToPath as __acmsPath } from "node:url";
4
+ import { dirname as __acmsDirname } from "node:path";
5
+ const require2 = __acmsRequire(import.meta.url);
6
+ const __filename = __acmsPath(import.meta.url);
7
+ const __dirname = __acmsDirname(__filename);
8
+ import { parseArgs } from "node:util";
9
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, chmodSync, renameSync, statSync, openSync, writeSync, closeSync, unlinkSync, createReadStream } from "node:fs";
10
+ import { join, resolve, dirname, sep, extname, normalize } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { homedir } from "node:os";
13
+ import { spawnSync, execFileSync } from "node:child_process";
14
+ import { createServer } from "node:http";
15
+ class CliError extends Error {
16
+ constructor(code, message, hint, exitCode = 1) {
17
+ super(message);
18
+ this.code = code;
19
+ this.hint = hint;
20
+ this.exitCode = exitCode;
21
+ }
22
+ }
23
+ function createIo(json) {
24
+ return {
25
+ json,
26
+ out: (text) => process.stdout.write(text.endsWith("\n") ? text : text + "\n"),
27
+ err: (text) => process.stderr.write(text.endsWith("\n") ? text : text + "\n")
28
+ };
29
+ }
30
+ function printResult(io, data, human) {
31
+ if (io.json) io.out(JSON.stringify({ ok: true, data }));
32
+ else io.out(human());
33
+ }
34
+ function printError(io, error) {
35
+ const e = error instanceof CliError ? error : new CliError("internal", error instanceof Error ? error.message : String(error));
36
+ if (io.json) {
37
+ io.out(JSON.stringify({ ok: false, error: { code: e.code, message: e.message, ...e.hint ? { hint: e.hint } : {} } }));
38
+ } else {
39
+ io.err(`Error: ${e.message}${e.hint ? `
40
+ Hint: ${e.hint}` : ""}`);
41
+ }
42
+ return e.exitCode;
43
+ }
44
+ function distRoot() {
45
+ if (process.env.AGENTCMS_DIST) return resolve(process.env.AGENTCMS_DIST);
46
+ const here = dirname(fileURLToPath(import.meta.url));
47
+ const bundled = true;
48
+ return bundled ? resolve(here, "..") : resolve(here, "..", "dist");
49
+ }
50
+ function readVersionInfo() {
51
+ const file2 = join(distRoot(), "version.json");
52
+ if (!existsSync(file2)) {
53
+ throw new Error(`${file2} is missing — run \`npm run build\` first.`);
54
+ }
55
+ return JSON.parse(readFileSync(file2, "utf8"));
56
+ }
57
+ class MemoryStorage {
58
+ data = /* @__PURE__ */ new Map();
59
+ get length() {
60
+ return this.data.size;
61
+ }
62
+ key(index) {
63
+ return [...this.data.keys()][index] ?? null;
64
+ }
65
+ getItem(key) {
66
+ return this.data.get(key) ?? null;
67
+ }
68
+ setItem(key, value) {
69
+ this.data.set(key, String(value));
70
+ }
71
+ removeItem(key) {
72
+ this.data.delete(key);
73
+ }
74
+ clear() {
75
+ this.data.clear();
76
+ }
77
+ }
78
+ let installed = false;
79
+ function installBrowserEnv() {
80
+ if (installed) return;
81
+ installed = true;
82
+ const g = globalThis;
83
+ for (const name of ["localStorage", "sessionStorage"]) {
84
+ Object.defineProperty(globalThis, name, { value: new MemoryStorage(), configurable: true, writable: true });
85
+ }
86
+ if (typeof g.window === "undefined") {
87
+ Object.defineProperty(globalThis, "window", { value: globalThis, configurable: true, writable: true });
88
+ }
89
+ }
90
+ function homeDir() {
91
+ return resolve(process.env.AGENTCMS_HOME || join(homedir(), ".agentcms"));
92
+ }
93
+ function homePath(...parts) {
94
+ return join(homeDir(), ...parts);
95
+ }
96
+ function readJsonFile(file2, fallback) {
97
+ if (!existsSync(file2)) return fallback;
98
+ return JSON.parse(readFileSync(file2, "utf8"));
99
+ }
100
+ function writeJsonFile(file2, value, mode = 420) {
101
+ mkdirSync(dirname(file2), { recursive: true, mode: 448 });
102
+ const tmp = `${file2}.${process.pid}.tmp`;
103
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode });
104
+ chmodSync(tmp, mode);
105
+ renameSync(tmp, file2);
106
+ }
107
+ function isWorldReadable(file2) {
108
+ if (process.platform === "win32" || !existsSync(file2)) return false;
109
+ return (statSync(file2).mode & 63) !== 0;
110
+ }
111
+ const CREDENTIAL_TYPES = {
112
+ github: {
113
+ fields: ["token"],
114
+ label: "GitHub personal access token",
115
+ howTo: "https://github.com/settings/personal-access-tokens/new — fine-grained, 'Only select repositories' (content + site repos), Repository permissions: Contents Read and write, Pages Read and write (optional, lets the site be published automatically), Metadata Read-only."
116
+ },
117
+ gitlab: {
118
+ fields: ["token"],
119
+ label: "GitLab access token",
120
+ howTo: "GitLab project → Settings → Access tokens (or user Preferences → Access tokens): scope `api`, role Developer or higher."
121
+ },
122
+ s3: {
123
+ fields: ["accessKeyId", "secretAccessKey"],
124
+ label: "S3 access key pair",
125
+ howTo: "AWS IAM user with s3:GetObject/PutObject/DeleteObject/ListBucket on the bucket, or OVHcloud Public Cloud → Object Storage → S3 users. Import an existing aws CLI profile with --from-aws-profile."
126
+ },
127
+ flexweg: {
128
+ fields: ["apiKey"],
129
+ label: "Flexweg API key",
130
+ howTo: "Flexweg dashboard → your site → API keys."
131
+ },
132
+ passphrase: {
133
+ fields: ["passphrase"],
134
+ label: "SQLite database passphrase",
135
+ howTo: "Generated by `agentcms install` (or `credentials set passphrase:<name> --generate`). Each browser asks for it once; copy it with `agentcms credentials copy passphrase:<name>` into a password manager."
136
+ },
137
+ "php-bridge": {
138
+ fields: ["apiKey"],
139
+ label: "PHP bridge key",
140
+ howTo: "The FLEXWEG_BRIDGE_KEY value from flexweg-bridge.config.php on your server."
141
+ }
142
+ };
143
+ const ACCOUNT_RE = /^([a-z0-9-]+):([A-Za-z0-9._@-]{1,100})$/;
144
+ function file$1() {
145
+ return homePath("credentials.json");
146
+ }
147
+ function load$1() {
148
+ return readJsonFile(file$1(), { version: 1, accounts: {} });
149
+ }
150
+ function save$1(data) {
151
+ writeJsonFile(file$1(), data, 384);
152
+ }
153
+ function parseAccount(account) {
154
+ const m = ACCOUNT_RE.exec(account);
155
+ if (!m || !CREDENTIAL_TYPES[m[1]]) {
156
+ throw new CliError(
157
+ "invalid-account",
158
+ `Invalid account "${account}".`,
159
+ `Use <type>:<name> with type one of ${Object.keys(CREDENTIAL_TYPES).join(", ")} — e.g. github:my-login.`,
160
+ 2
161
+ );
162
+ }
163
+ return { type: m[1], name: m[2] };
164
+ }
165
+ function setCredential(account, values) {
166
+ const { type } = parseAccount(account);
167
+ const missing = CREDENTIAL_TYPES[type].fields.filter((f) => !values[f]?.trim());
168
+ if (missing.length) throw new CliError("missing-fields", `Missing ${missing.join(", ")} for ${account}.`);
169
+ const data = load$1();
170
+ const entry = {
171
+ type,
172
+ values: Object.fromEntries(CREDENTIAL_TYPES[type].fields.map((f) => [f, values[f].trim()])),
173
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
174
+ };
175
+ data.accounts[account] = entry;
176
+ save$1(data);
177
+ return entry;
178
+ }
179
+ function removeCredential(account) {
180
+ const data = load$1();
181
+ if (!data.accounts[account]) return false;
182
+ delete data.accounts[account];
183
+ save$1(data);
184
+ return true;
185
+ }
186
+ function getCredential(account) {
187
+ parseAccount(account);
188
+ const path = file$1();
189
+ if (isWorldReadable(path)) chmodSync(path, 384);
190
+ return load$1().accounts[account] ?? null;
191
+ }
192
+ function requireCredential(account) {
193
+ const cred = getCredential(account);
194
+ if (!cred) {
195
+ const { type } = parseAccount(account);
196
+ throw new CliError(
197
+ "credential-missing",
198
+ `No stored credential for ${account}.`,
199
+ `Ask the user to run \`agentcms credentials set ${account}\` in their own terminal (input is hidden). ${CREDENTIAL_TYPES[type].howTo}`
200
+ );
201
+ }
202
+ return cred;
203
+ }
204
+ function listCredentials() {
205
+ return Object.entries(load$1().accounts).map(([account, c]) => ({
206
+ account,
207
+ type: c.type,
208
+ fields: Object.keys(c.values),
209
+ updatedAt: c.updatedAt
210
+ }));
211
+ }
212
+ const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
213
+ function file() {
214
+ return homePath("sites.json");
215
+ }
216
+ function load() {
217
+ return readJsonFile(file(), { version: 1, default: null, sites: {} });
218
+ }
219
+ function save(data) {
220
+ writeJsonFile(file(), data);
221
+ }
222
+ function validateAlias(alias) {
223
+ if (!ALIAS_RE.test(alias)) {
224
+ throw new CliError("invalid-alias", `Invalid site alias "${alias}".`, "Lower-case letters, digits and dashes, e.g. my-blog.", 2);
225
+ }
226
+ return alias;
227
+ }
228
+ function listSites() {
229
+ const data = load();
230
+ return { default: data.default, sites: Object.values(data.sites).sort((a, b) => a.alias.localeCompare(b.alias)) };
231
+ }
232
+ function getSite(alias) {
233
+ return load().sites[alias] ?? null;
234
+ }
235
+ function putSite(site) {
236
+ const data = load();
237
+ data.sites[site.alias] = { ...site, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
238
+ save(data);
239
+ }
240
+ function removeSite(alias) {
241
+ const data = load();
242
+ if (!data.sites[alias]) return false;
243
+ delete data.sites[alias];
244
+ if (data.default === alias) data.default = null;
245
+ save(data);
246
+ return true;
247
+ }
248
+ function renameSite(from, to) {
249
+ validateAlias(to);
250
+ const data = load();
251
+ const site = data.sites[from];
252
+ if (!site) throw siteNotFound(from, data);
253
+ if (data.sites[to]) throw new CliError("site-exists", `A site named "${to}" already exists.`);
254
+ delete data.sites[from];
255
+ data.sites[to] = { ...site, alias: to, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
256
+ if (data.default === from) data.default = to;
257
+ save(data);
258
+ }
259
+ function setDefaultSite(alias) {
260
+ const data = load();
261
+ if (!data.sites[alias]) throw siteNotFound(alias, data);
262
+ data.default = alias;
263
+ save(data);
264
+ }
265
+ function findProjectSite(cwd = process.cwd()) {
266
+ let dir = resolve(cwd);
267
+ for (; ; ) {
268
+ const candidate = join(dir, "agentcms.json");
269
+ if (existsSync(candidate)) {
270
+ const alias = JSON.parse(readFileSync(candidate, "utf8")).site;
271
+ if (typeof alias === "string" && alias) return { alias, file: candidate };
272
+ }
273
+ const parent = dirname(dir);
274
+ if (parent === dir) return null;
275
+ dir = parent;
276
+ }
277
+ }
278
+ function siteNotFound(alias, data) {
279
+ const known = Object.keys(data.sites);
280
+ return new CliError(
281
+ "site-not-found",
282
+ `No site named "${alias}".`,
283
+ known.length ? `Known sites: ${known.join(", ")}.` : "No site registered yet — run `agentcms install` or `agentcms site add`."
284
+ );
285
+ }
286
+ function resolveSite(explicit) {
287
+ const data = load();
288
+ const chosen = explicit || findProjectSite()?.alias;
289
+ if (chosen) {
290
+ const site = data.sites[chosen];
291
+ if (!site) throw siteNotFound(chosen, data);
292
+ return site;
293
+ }
294
+ const all = Object.values(data.sites);
295
+ if (all.length === 1) return all[0];
296
+ if (all.length === 0) throw siteNotFound("(none)", data);
297
+ if (data.default && data.sites[data.default]) return data.sites[data.default];
298
+ throw new CliError(
299
+ "site-required",
300
+ `Several sites are registered: ${all.map((s) => s.alias).join(", ")}.`,
301
+ "Ask the user which one, then pass --site <alias>.",
302
+ 2
303
+ );
304
+ }
305
+ function lockSite(alias) {
306
+ const dir = homePath("locks");
307
+ mkdirSync(dir, { recursive: true, mode: 448 });
308
+ const path = join(dir, `${alias}.lock`);
309
+ for (let attempt = 0; attempt < 2; attempt++) {
310
+ try {
311
+ const fd = openSync(path, "wx");
312
+ writeSync(fd, String(process.pid));
313
+ closeSync(fd);
314
+ return () => {
315
+ try {
316
+ unlinkSync(path);
317
+ } catch {
318
+ }
319
+ };
320
+ } catch (err) {
321
+ if (err.code !== "EEXIST") throw err;
322
+ const pid = Number(readFileSync(path, "utf8"));
323
+ if (pid && isAlive(pid)) {
324
+ throw new CliError("site-busy", `Another agentcms process (pid ${pid}) is working on "${alias}".`, "Wait for it to finish.");
325
+ }
326
+ unlinkSync(path);
327
+ }
328
+ }
329
+ throw new CliError("site-busy", `Could not lock "${alias}".`);
330
+ }
331
+ function isAlive(pid) {
332
+ try {
333
+ process.kill(pid, 0);
334
+ return true;
335
+ } catch (err) {
336
+ return err.code === "EPERM";
337
+ }
338
+ }
339
+ const compactDataCommand = {
340
+ name: "compact-data",
341
+ summary: "Shrink the git history of a SQLite site's data branch (e.g. agentcms-data) to one commit with the current database. GitHub only. Prints the plan unless --yes.",
342
+ usage: "compact-data [--site <alias>] [--yes]",
343
+ options: [
344
+ { name: "site", type: "string", description: "Site alias." },
345
+ { name: "yes", type: "boolean", description: "Apply — only after the user confirmed." }
346
+ ],
347
+ async run({ io, options }) {
348
+ const site = resolveSite(options.site);
349
+ const runtime = site.runtime;
350
+ if (runtime.backend !== "sqlite-file" || runtime.sqlite.connectorId !== "git-pages") {
351
+ throw new CliError("not-applicable", `${site.alias} doesn't keep a SQLite database in git — nothing to compact.`);
352
+ }
353
+ const s = runtime.sqlite.settings;
354
+ if (s.provider !== "github") throw new CliError("unsupported", "Compaction is only implemented for GitHub repositories.");
355
+ const account = site.credentials.data ?? site.credentials.hosting;
356
+ if (!account) throw new CliError("credential-required", `No credential recorded for the data of ${site.alias}.`);
357
+ const token = requireCredential(account).values.token;
358
+ installBrowserEnv();
359
+ const { createRemoteGitTransport, joinRepoPath } = await import("./chunks/index-BG05qslv.mjs").then((n) => n.g);
360
+ const transport = createRemoteGitTransport({
361
+ provider: "github",
362
+ owner: s.owner ?? "",
363
+ repo: s.repo ?? "",
364
+ branch: s.branch ?? "",
365
+ token,
366
+ apiBaseUrl: s.apiBaseUrl
367
+ });
368
+ const tree = await transport.listTree();
369
+ if (!tree.headSha) throw new CliError("empty", "The data branch doesn't exist yet.");
370
+ const dbPath = joinRepoPath(s.dir ?? "", runtime.sqlite.path);
371
+ const foreign = tree.entries.filter((e) => e.path !== dbPath && e.path !== ".gitkeep");
372
+ if (foreign.length) {
373
+ throw new CliError(
374
+ "shared-branch",
375
+ `Branch ${s.branch} also holds ${foreign.length} other file(s) (${foreign.slice(0, 3).map((e) => e.path).join(", ")}…) — compaction would drop their history too.`,
376
+ "Only a dedicated data branch can be compacted."
377
+ );
378
+ }
379
+ const api = (s.apiBaseUrl || "https://api.github.com").replace(/\/+$/, "");
380
+ const res = await fetch(`${api}/repos/${s.owner}/${s.repo}/commits?sha=${encodeURIComponent(s.branch ?? "")}&per_page=1`, {
381
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }
382
+ });
383
+ const last = /[?&]page=(\d+)>; rel="last"/.exec(res.headers.get("link") ?? "");
384
+ const commits = res.ok ? last ? Number(last[1]) : 1 : null;
385
+ const size = tree.entries.find((e) => e.path === dbPath)?.size ?? null;
386
+ const plan = { site: site.alias, repo: `${s.owner}/${s.repo}`, branch: s.branch, commits, databaseBytes: size, files: tree.entries.map((e) => e.path) };
387
+ if (!options.yes) {
388
+ printResult(
389
+ io,
390
+ { needsConfirmation: true, ...plan },
391
+ () => `${site.alias}: ${plan.repo}@${plan.branch} has ${commits ?? "?"} commit(s); compaction keeps 1 (database ${size ?? "?"} bytes). Nothing was changed. Re-run with --yes once the user agrees.`
392
+ );
393
+ return;
394
+ }
395
+ if (commits !== null && commits <= 1) {
396
+ printResult(io, { ...plan, compacted: false }, () => "Already a single commit.");
397
+ return;
398
+ }
399
+ const unlock = lockSite(site.alias);
400
+ try {
401
+ const files = [];
402
+ for (const entry of tree.entries) {
403
+ files.push({ path: entry.path, content: Buffer.from(await transport.readBytes(entry)).toString("base64"), encoding: "base64" });
404
+ }
405
+ await transport.resetBranch(files, { message: "Compact data history — AgentCMS", expectedHeadSha: tree.headSha });
406
+ printResult(io, { ...plan, compacted: true }, () => `${site.alias}: ${plan.branch} compacted from ${commits ?? "?"} commits to 1.`);
407
+ } finally {
408
+ unlock();
409
+ }
410
+ }
411
+ };
412
+ function fromEnv(names, fields) {
413
+ if (names.length !== fields.length) {
414
+ throw new CliError("usage", `--from-env needs ${fields.length} variable name(s) (${fields.join(", ")}), comma-separated.`, void 0, 2);
415
+ }
416
+ return Object.fromEntries(
417
+ fields.map((field, i) => {
418
+ const value = process.env[names[i]];
419
+ if (!value) throw new CliError("env-missing", `Environment variable ${names[i]} is empty or not set.`);
420
+ return [field, value];
421
+ })
422
+ );
423
+ }
424
+ function fromGh(host) {
425
+ try {
426
+ const args = ["auth", "token", ...host ? ["--hostname", host] : []];
427
+ const token = execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
428
+ if (!token) throw new Error("empty");
429
+ return { token };
430
+ } catch {
431
+ throw new CliError(
432
+ "gh-unavailable",
433
+ "Could not read a token from the GitHub CLI.",
434
+ "Install gh and run `gh auth login`, or create a fine-grained token and use `credentials set` without --from-gh."
435
+ );
436
+ }
437
+ }
438
+ function parseIni(text) {
439
+ const out = {};
440
+ let section = null;
441
+ for (const raw of text.split(/\r?\n/)) {
442
+ const line = raw.trim();
443
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
444
+ const s = /^\[([^\]]+)\]$/.exec(line);
445
+ if (s) {
446
+ section = out[s[1].trim()] = {};
447
+ continue;
448
+ }
449
+ const kv = /^([^=]+?)\s*=\s*(.*)$/.exec(line);
450
+ if (kv && section) section[kv[1].trim()] = kv[2].trim();
451
+ }
452
+ return out;
453
+ }
454
+ function fromAwsProfile(profile) {
455
+ const file2 = process.env.AWS_SHARED_CREDENTIALS_FILE || join(homedir(), ".aws", "credentials");
456
+ if (!existsSync(file2)) throw new CliError("aws-missing", `No aws credentials file at ${file2}.`, "Run `aws configure` first.");
457
+ const section = parseIni(readFileSync(file2, "utf8"))[profile];
458
+ if (!section?.aws_access_key_id || !section.aws_secret_access_key) {
459
+ throw new CliError("aws-profile-missing", `Profile "${profile}" has no access key pair in ${file2}.`);
460
+ }
461
+ return { accessKeyId: section.aws_access_key_id, secretAccessKey: section.aws_secret_access_key };
462
+ }
463
+ async function promptHidden(question) {
464
+ const stdin = process.stdin;
465
+ if (!stdin.isTTY) {
466
+ throw new CliError(
467
+ "needs-terminal",
468
+ "No terminal to type the secret in.",
469
+ "Ask the user to run this command themselves in their terminal (in Claude Code: prefix it with `!`), or pass --from-env / --from-gh / --from-aws-profile."
470
+ );
471
+ }
472
+ process.stderr.write(question);
473
+ stdin.setRawMode(true);
474
+ stdin.resume();
475
+ stdin.setEncoding("utf8");
476
+ return new Promise((resolve2, reject) => {
477
+ let value = "";
478
+ const done = (err) => {
479
+ stdin.setRawMode(false);
480
+ stdin.pause();
481
+ stdin.off("data", onData);
482
+ process.stderr.write("\n");
483
+ if (err) reject(err);
484
+ else resolve2(value);
485
+ };
486
+ const onData = (chunk) => {
487
+ for (const ch of chunk) {
488
+ if (ch === "\r" || ch === "\n") return done();
489
+ if (ch === "") return done(new CliError("cancelled", "Cancelled."));
490
+ if (ch === "" || ch === "\b") value = value.slice(0, -1);
491
+ else value += ch;
492
+ }
493
+ };
494
+ stdin.on("data", onData);
495
+ });
496
+ }
497
+ function copyToClipboard(text) {
498
+ const candidates = process.platform === "darwin" ? [["pbcopy", []]] : process.platform === "win32" ? [["clip", []]] : [
499
+ ["wl-copy", []],
500
+ ["xclip", ["-selection", "clipboard"]],
501
+ ["xsel", ["--clipboard", "--input"]]
502
+ ];
503
+ for (const [cmd, args] of candidates) {
504
+ const res = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
505
+ if (res.status === 0) return cmd;
506
+ }
507
+ throw new CliError("no-clipboard", "No clipboard tool found (pbcopy, clip, wl-copy, xclip or xsel).", "Install one, or read the value from ~/.agentcms/credentials.json yourself.");
508
+ }
509
+ const MAGIC = new TextEncoder().encode("ACMSENC1");
510
+ const HEADER = MAGIC.length + 4 + 16 + 12;
511
+ const PBKDF2_ITERATIONS = 6e5;
512
+ class SqlitePassphraseError extends Error {
513
+ constructor(reason) {
514
+ super(reason === "missing" ? "This database is encrypted: enter its passphrase." : "Wrong passphrase for this database.");
515
+ this.reason = reason;
516
+ this.name = "SqlitePassphraseError";
517
+ }
518
+ }
519
+ function isEncryptedDatabase(bytes) {
520
+ return !!bytes && bytes.length >= HEADER && MAGIC.every((b, i) => bytes[i] === b);
521
+ }
522
+ const keyCache = /* @__PURE__ */ new Map();
523
+ function hex(bytes) {
524
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
525
+ }
526
+ function deriveKey(passphrase, salt, iterations) {
527
+ const id = `${iterations}:${hex(salt)}:${passphrase}`;
528
+ let key = keyCache.get(id);
529
+ if (!key) {
530
+ key = (async () => {
531
+ const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase.normalize("NFC")), "PBKDF2", false, ["deriveKey"]);
532
+ return crypto.subtle.deriveKey(
533
+ { name: "PBKDF2", hash: "SHA-256", salt, iterations },
534
+ material,
535
+ { name: "AES-GCM", length: 256 },
536
+ false,
537
+ ["encrypt", "decrypt"]
538
+ );
539
+ })();
540
+ key.catch(() => keyCache.delete(id));
541
+ keyCache.set(id, key);
542
+ }
543
+ return key;
544
+ }
545
+ function encryptionParams(bytes) {
546
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
547
+ return {
548
+ iterations: view.getUint32(MAGIC.length),
549
+ salt: bytes.slice(MAGIC.length + 4, MAGIC.length + 20)
550
+ };
551
+ }
552
+ async function encryptDatabase(plain, passphrase, params) {
553
+ if (!passphrase) throw new SqlitePassphraseError("missing");
554
+ const salt = params?.salt ?? crypto.getRandomValues(new Uint8Array(16));
555
+ const iterations = params?.iterations ?? PBKDF2_ITERATIONS;
556
+ const iv = crypto.getRandomValues(new Uint8Array(12));
557
+ const key = await deriveKey(passphrase, salt, iterations);
558
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain));
559
+ const out = new Uint8Array(HEADER + cipher.length);
560
+ out.set(MAGIC, 0);
561
+ new DataView(out.buffer).setUint32(MAGIC.length, iterations);
562
+ out.set(salt, MAGIC.length + 4);
563
+ out.set(iv, MAGIC.length + 20);
564
+ out.set(cipher, HEADER);
565
+ return out;
566
+ }
567
+ async function decryptDatabase(bytes, passphrase) {
568
+ if (!passphrase) throw new SqlitePassphraseError("missing");
569
+ const { salt, iterations } = encryptionParams(bytes);
570
+ const iv = bytes.slice(MAGIC.length + 20, HEADER);
571
+ const key = await deriveKey(passphrase, salt, iterations);
572
+ try {
573
+ return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, bytes.slice(HEADER)));
574
+ } catch {
575
+ throw new SqlitePassphraseError("wrong");
576
+ }
577
+ }
578
+ function generatePassphrase() {
579
+ const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
580
+ const pick = crypto.getRandomValues(new Uint8Array(30));
581
+ const chars = [...pick].map((b) => alphabet[b % alphabet.length]);
582
+ return Array.from({ length: 6 }, (_, i) => chars.slice(i * 5, i * 5 + 5).join("")).join("-");
583
+ }
584
+ const credentialsCommand = {
585
+ name: "credentials",
586
+ summary: "Store, list, copy or remove access tokens / keys per account (<type>:<name>). `set` reads the secret from --from-env, --from-gh, --from-aws-profile, --generate (passphrases) or a hidden prompt the USER types in; `copy` puts it on the clipboard without printing it.",
587
+ usage: "credentials <set|list|copy|remove|types> [<type>:<name>] [--from-env VAR[,VAR]] [--from-gh] [--from-aws-profile <name>] [--generate] [--field <name>]",
588
+ options: [
589
+ { name: "from-env", type: "string", description: "Read the value(s) from these environment variables (comma-separated, in field order)." },
590
+ { name: "from-gh", type: "boolean", description: "github: take the token of the signed-in GitHub CLI (gh auth token)." },
591
+ { name: "from-aws-profile", type: "string", description: "s3: import the key pair of this aws CLI profile." },
592
+ { name: "generate", type: "boolean", description: "passphrase: generate a strong random passphrase." },
593
+ { name: "field", type: "string", description: "copy: which field (default: the first, e.g. token / passphrase)." }
594
+ ],
595
+ async run({ io, options, positionals }) {
596
+ const [action = "list", account] = positionals;
597
+ if (action === "types") {
598
+ printResult(
599
+ io,
600
+ CREDENTIAL_TYPES,
601
+ () => Object.entries(CREDENTIAL_TYPES).map(([type2, c]) => `${type2}: ${c.label} (${c.fields.join(", ")})
602
+ ${c.howTo}`).join("\n")
603
+ );
604
+ return;
605
+ }
606
+ if (action === "list") {
607
+ const { sites } = listSites();
608
+ const list = listCredentials().map((c) => ({
609
+ ...c,
610
+ usedBy: sites.filter((s) => s.credentials.data === c.account || s.credentials.hosting === c.account).map((s) => s.alias)
611
+ }));
612
+ printResult(
613
+ io,
614
+ list,
615
+ () => list.length ? list.map((c) => `${c.account} (${c.fields.join(", ")})${c.usedBy.length ? ` used by ${c.usedBy.join(", ")}` : ""}`).join("\n") : "No stored credentials."
616
+ );
617
+ return;
618
+ }
619
+ if (!account) throw new CliError("usage", `credentials ${action} needs an account, e.g. github:my-login.`, void 0, 2);
620
+ const { type } = parseAccount(account);
621
+ if (action === "remove") {
622
+ const removed = removeCredential(account);
623
+ printResult(io, { account, removed }, () => removed ? `Removed ${account}.` : `${account} was not stored.`);
624
+ return;
625
+ }
626
+ if (action === "copy") {
627
+ const cred = requireCredential(account);
628
+ const field = typeof options.field === "string" ? options.field : CREDENTIAL_TYPES[type].fields[0];
629
+ if (!cred.values[field]) throw new CliError("usage", `${account} has no field "${field}".`, `Fields: ${Object.keys(cred.values).join(", ")}.`, 2);
630
+ const tool = copyToClipboard(cred.values[field]);
631
+ printResult(io, { account, field, copied: true, tool }, () => `Copied ${field} of ${account} to the clipboard.`);
632
+ return;
633
+ }
634
+ if (action !== "set") throw new CliError("usage", `Unknown action "${action}".`, "Use set, list, copy, remove or types.", 2);
635
+ const spec = CREDENTIAL_TYPES[type];
636
+ let values;
637
+ if (options.generate) {
638
+ if (type !== "passphrase") throw new CliError("usage", "--generate only applies to passphrase accounts.", void 0, 2);
639
+ values = { passphrase: generatePassphrase() };
640
+ } else if (typeof options["from-env"] === "string") values = fromEnv(options["from-env"].split(",").map((s) => s.trim()), spec.fields);
641
+ else if (options["from-gh"]) {
642
+ if (type !== "github") throw new CliError("usage", "--from-gh only applies to github accounts.", void 0, 2);
643
+ values = fromGh();
644
+ } else if (typeof options["from-aws-profile"] === "string") {
645
+ if (type !== "s3") throw new CliError("usage", "--from-aws-profile only applies to s3 accounts.", void 0, 2);
646
+ values = fromAwsProfile(options["from-aws-profile"]);
647
+ } else {
648
+ io.err(`${spec.label} for ${account}. ${spec.howTo}`);
649
+ values = {};
650
+ for (const field of spec.fields) values[field] = await promptHidden(`${field}: `);
651
+ }
652
+ const saved = setCredential(account, values);
653
+ printResult(io, { account, type, fields: Object.keys(saved.values) }, () => `Stored ${account} (${Object.keys(saved.values).join(", ")}).`);
654
+ }
655
+ };
656
+ function parse(v) {
657
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
658
+ if (!m) throw new Error(`Invalid version: ${v}`);
659
+ return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? ""];
660
+ }
661
+ function compareVersions(a, b) {
662
+ const pa = parse(a);
663
+ const pb = parse(b);
664
+ for (let i = 0; i < 3; i++) {
665
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
666
+ }
667
+ if (pa[3] === pb[3]) return 0;
668
+ if (!pa[3]) return 1;
669
+ if (!pb[3]) return -1;
670
+ return pa[3] < pb[3] ? -1 : 1;
671
+ }
672
+ async function fetchText(url, fetchImpl = fetch) {
673
+ try {
674
+ const u = new URL(url);
675
+ u.searchParams.set("agentcms", String(Date.now()));
676
+ const res = await fetchImpl(u.href, { cache: "no-store", redirect: "follow", signal: AbortSignal.timeout(15e3) });
677
+ const text = await res.text();
678
+ return { ok: res.ok, status: res.status, contentType: res.headers.get("content-type") ?? "", text };
679
+ } catch (err) {
680
+ return { ok: false, status: 0, contentType: "", text: "", error: err.message };
681
+ }
682
+ }
683
+ function adminUrlOf(site) {
684
+ return site.adminFolder ? `${site.siteUrl.replace(/\/+$/, "")}/${site.adminFolder}/` : null;
685
+ }
686
+ function parseConfigJs(source) {
687
+ const m = /window\.__FLEXWEG_CONFIG__\s*=\s*([\s\S]*?);?\s*$/.exec(source.trim());
688
+ if (!m) throw new Error("config.js does not set window.__FLEXWEG_CONFIG__");
689
+ const value = JSON.parse(m[1].trim().replace(/;$/, ""));
690
+ return value && typeof value === "object" ? value : null;
691
+ }
692
+ function findSecretKeys(value, path = "") {
693
+ if (!value || typeof value !== "object") return [];
694
+ return Object.entries(value).flatMap(([k, v]) => {
695
+ const here = path ? `${path}.${k}` : k;
696
+ if (/^(token|apiKey|secretAccessKey|accessToken|password|sqliteToken)$/.test(k) && typeof v === "string" && v) {
697
+ if (k === "apiKey" && path === "firebase") return [];
698
+ return [here];
699
+ }
700
+ return findSecretKeys(v, here);
701
+ });
702
+ }
703
+ async function fetchHostedAdmin(adminUrl, fetchImpl = fetch) {
704
+ const res = await fetchText(`${adminUrl}admin-files.json`, fetchImpl);
705
+ if (!res.ok) return { version: null, dataSchema: null, files: [], error: res.error ?? `HTTP ${res.status}` };
706
+ try {
707
+ const m = JSON.parse(res.text);
708
+ return { version: m.version ?? null, dataSchema: m.dataSchema ?? null, files: m.files ?? [] };
709
+ } catch {
710
+ return { version: null, dataSchema: null, files: [], error: "admin-files.json is not valid JSON" };
711
+ }
712
+ }
713
+ function readChangelog() {
714
+ try {
715
+ const data = JSON.parse(readFileSync(join(distRoot(), "..", "changelog.json"), "utf8"));
716
+ return data.versions ?? [];
717
+ } catch {
718
+ return [];
719
+ }
720
+ }
721
+ function changesBetween(entries, from, to) {
722
+ const list = entries.filter((e) => compareVersions(e.version, from) > 0 && compareVersions(e.version, to) <= 0);
723
+ return {
724
+ versions: list.map((e) => e.version),
725
+ breaking: list.some((e) => e.breaking),
726
+ changes: list.flatMap((e) => e.changes.map((c) => `${e.version}: ${c}`)),
727
+ actions: [...new Set(list.flatMap((e) => e.actions))]
728
+ };
729
+ }
730
+ const deployAdminCommand = {
731
+ name: "deploy-admin",
732
+ summary: "Update a site's hosted admin (and optionally its theme assets) to this release. Prints the plan unless --yes is given; refuses downgrades below the stored data format.",
733
+ usage: "deploy-admin [--site <alias> | --all] [--theme-assets] [--yes] [--allow-downgrade]",
734
+ options: [
735
+ { name: "site", type: "string", description: "Site alias." },
736
+ { name: "all", type: "boolean", description: "Every registered site with a hosted admin (each listed separately)." },
737
+ { name: "theme-assets", type: "boolean", description: "Also upload theme CSS / loaders (changelog action sync-theme-assets)." },
738
+ { name: "yes", type: "boolean", description: "Apply — only after the user confirmed the plan." },
739
+ { name: "allow-downgrade", type: "boolean", description: "Allow installing an older admin than the hosted one." }
740
+ ],
741
+ async run({ io, options }) {
742
+ const info = readVersionInfo();
743
+ const sites = options.all ? listSites().sites.filter((s) => s.adminFolder) : [resolveSite(options.site)];
744
+ if (sites.length === 0) throw new CliError("no-sites", "No registered site has a hosted admin.");
745
+ installBrowserEnv();
746
+ const { adminOps, applyOps, hostingConnector, themeAssetOps } = await import("./chunks/hosting-BftlDdsL.mjs");
747
+ const { collectToasts, settleHosting } = await import("./chunks/install-DnmgOT3F.mjs");
748
+ const plans = [];
749
+ for (const site of sites) {
750
+ const adminUrl = adminUrlOf(site);
751
+ if (!adminUrl) throw new CliError("not-hosted", `${site.alias} has no hosted admin.`);
752
+ if (!site.hosting.connectorId) throw new CliError("hosting-unknown", `The hosting of ${site.alias} is unknown.`, "Re-register it with `agentcms site add … --hosting-credential`.");
753
+ if (!site.credentials.hosting) throw new CliError("credential-required", `No hosting credential recorded for ${site.alias}.`, "Re-register the site with --hosting-credential.");
754
+ const hosted = await fetchHostedAdmin(adminUrl);
755
+ const plan = {
756
+ alias: site.alias,
757
+ adminUrl,
758
+ from: hosted.version,
759
+ to: info.version,
760
+ files: adminOps(site.adminFolder, site.runtime).ops.length + (options["theme-assets"] ? themeAssetOps().length : 0),
761
+ themeAssets: !!options["theme-assets"],
762
+ changes: hosted.version ? changesBetween(readChangelog(), hosted.version, info.version) : null
763
+ };
764
+ if (hosted.dataSchema !== null && hosted.dataSchema > info.dataSchema) {
765
+ plan.refused = `the hosted admin stores data format ${hosted.dataSchema}, this release only knows ${info.dataSchema} — update agentcms instead`;
766
+ } else if (hosted.version && compareVersions(hosted.version, info.version) > 0 && !options["allow-downgrade"]) {
767
+ plan.refused = `the hosted admin (${hosted.version}) is newer than this release — update agentcms, or pass --allow-downgrade`;
768
+ }
769
+ plans.push(plan);
770
+ }
771
+ if (!options.yes) {
772
+ printResult(
773
+ io,
774
+ { needsConfirmation: true, plans },
775
+ () => [
776
+ ...plans.map(
777
+ (p) => p.refused ? `${p.alias}: refused — ${p.refused}` : `${p.alias}: admin ${p.from ?? "(none)"} → ${p.to}, ${p.files} files${p.changes?.breaking ? " — BREAKING changes" : ""}`
778
+ ),
779
+ "Nothing was changed. Re-run with --yes once the user agrees."
780
+ ].join("\n")
781
+ );
782
+ return;
783
+ }
784
+ const results = [];
785
+ for (const [i, site] of sites.entries()) {
786
+ const plan = plans[i];
787
+ if (plan.refused) {
788
+ results.push({ alias: site.alias, deployed: false, reason: plan.refused });
789
+ continue;
790
+ }
791
+ const unlock = lockSite(site.alias);
792
+ const toasts = collectToasts();
793
+ try {
794
+ const cred = requireCredential(site.credentials.hosting);
795
+ const target = { connectorId: site.hosting.connectorId, settings: { ...site.hosting.settings, ...cred.values } };
796
+ const { ops } = adminOps(site.adminFolder, site.runtime);
797
+ if (options["theme-assets"]) ops.unshift(...themeAssetOps());
798
+ io.err(`• ${site.alias}: uploading ${ops.length} files…`);
799
+ await applyOps(hostingConnector(target), ops);
800
+ await settleHosting(target);
801
+ putSite({ ...site, adminVersion: info.version });
802
+ const pending = (plan.changes?.actions ?? []).filter((a) => a !== "deploy-admin" && !(a === "sync-theme-assets" && plan.themeAssets));
803
+ results.push({ alias: site.alias, deployed: true, from: plan.from, to: info.version, files: ops.length, pendingActions: pending, notices: toasts.notices });
804
+ } catch (err) {
805
+ results.push({ alias: site.alias, deployed: false, reason: err.message });
806
+ } finally {
807
+ toasts.stop();
808
+ unlock();
809
+ }
810
+ }
811
+ printResult(
812
+ io,
813
+ { results },
814
+ () => results.map((r) => r.deployed ? `${r.alias}: admin ${r.from ?? "(none)"} → ${r.to}` : `${r.alias}: not deployed — ${r.reason}`).join("\n")
815
+ );
816
+ if (results.some((r) => !r.deployed)) return 1;
817
+ }
818
+ };
819
+ async function runDoctor(site, opts = {}) {
820
+ const f = opts.fetchImpl ?? fetch;
821
+ const checks = [];
822
+ const add = (c) => checks.push(c);
823
+ const info = readVersionInfo();
824
+ const siteUrl = site.siteUrl.replace(/\/+$/, "");
825
+ add(
826
+ siteUrl.startsWith("https://") || /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/.test(siteUrl) ? { id: "https", status: "ok", message: siteUrl.startsWith("https://") ? "Site served over HTTPS." : "Local site (a secure context for browsers)." } : {
827
+ id: "https",
828
+ status: "warn",
829
+ message: `${siteUrl} is plain HTTP.`,
830
+ fix: "Use the host's HTTPS URL (S3: the bucket's https endpoint) — the admin can't sign requests on http pages."
831
+ }
832
+ );
833
+ const home = await fetchText(`${siteUrl}/`, f);
834
+ add(
835
+ home.ok ? { id: "site", status: "ok", message: `Home page reachable (${home.status}).` } : home.status === 404 ? { id: "site", status: "warn", message: "No home page yet (404).", fix: "Publish a post or run Regenerate → Everything in the admin." } : { id: "site", status: "fail", message: `Site not reachable: ${home.error ?? `HTTP ${home.status}`}.`, fix: "Check siteUrl, DNS, and that the host serves the bucket / repo (GitHub Pages enabled, S3 website hosting on)." }
836
+ );
837
+ const css = await fetchText(`${siteUrl}/theme-assets/default.css`, f);
838
+ add(
839
+ css.ok && css.contentType.includes("text/css") ? { id: "theme-assets", status: "ok", message: "Theme stylesheet served as text/css." } : {
840
+ id: "theme-assets",
841
+ status: "fail",
842
+ message: css.ok ? `theme-assets/default.css has type "${css.contentType}".` : `theme-assets/default.css missing (${css.error ?? css.status}).`,
843
+ fix: `\`agentcms deploy-admin --site ${site.alias} --theme-assets --yes\` (after confirming with the user), or Themes → Sync theme assets in the admin.`
844
+ }
845
+ );
846
+ const adminUrl = adminUrlOf(site);
847
+ if (!adminUrl) {
848
+ add({ id: "admin", status: "skip", message: "Admin not hosted on this site." });
849
+ } else {
850
+ const index = await fetchText(adminUrl, f);
851
+ if (!index.ok || !index.contentType.includes("text/html")) {
852
+ add({
853
+ id: "admin",
854
+ status: "fail",
855
+ message: `Admin page ${adminUrl} not served (${index.error ?? `HTTP ${index.status}`}${index.ok ? `, type ${index.contentType}` : ""}).`,
856
+ fix: `\`agentcms deploy-admin --site ${site.alias}\`; on GitHub Pages wait a minute after a commit.`
857
+ });
858
+ } else {
859
+ add({ id: "admin", status: "ok", message: `Admin page served at ${adminUrl}.` });
860
+ const hosted = await fetchHostedAdmin(adminUrl, f);
861
+ const refs = [...index.text.matchAll(/(?:src|href)="\.\/([^"?#]+)"/g)].map((m) => m[1]);
862
+ const missing = refs.filter((r) => r !== "config.js" && !hosted.files.includes(r));
863
+ const script = refs.find((r) => r.startsWith("assets/") && r.endsWith(".js"));
864
+ const bundle = script ? await fetchText(`${adminUrl}${script}`, f) : null;
865
+ if (!hosted.version || missing.length || bundle && (!bundle.ok || !/javascript/.test(bundle.contentType))) {
866
+ add({
867
+ id: "admin-files",
868
+ status: "fail",
869
+ message: !hosted.version ? "admin-files.json missing — the admin was copied by hand or is incomplete." : missing.length ? `index.html references files of another build: ${missing.join(", ")}.` : `Admin bundle ${script} ${bundle.ok ? `served as "${bundle.contentType}" (must be JavaScript)` : `missing (${bundle.status})`}.`,
870
+ fix: `\`agentcms deploy-admin --site ${site.alias}\` re-uploads a consistent build with the right content types.`
871
+ });
872
+ } else {
873
+ add({ id: "admin-files", status: "ok", message: `Admin ${hosted.version}: files consistent, bundle served as JavaScript.` });
874
+ add(
875
+ compareVersions(hosted.version, info.version) >= 0 ? { id: "admin-version", status: "ok", message: `Admin ${hosted.version} is up to date.` } : { id: "admin-version", status: "warn", message: `Admin ${hosted.version} is older than ${info.version}.`, fix: `Offer \`agentcms deploy-admin --site ${site.alias}\`.` }
876
+ );
877
+ }
878
+ const cfg = await fetchText(`${adminUrl}config.js`, f);
879
+ try {
880
+ if (!cfg.ok) throw new Error(cfg.error ?? `HTTP ${cfg.status}`);
881
+ const runtime = parseConfigJs(cfg.text);
882
+ const secrets = findSecretKeys(runtime);
883
+ if (secrets.length) {
884
+ add({ id: "config-js", status: "fail", message: `config.js publishes secrets: ${secrets.join(", ")}.`, fix: "Revoke those keys now, then redeploy the admin (config.js is regenerated without secrets)." });
885
+ } else if (!runtime || runtime.backend !== site.runtime.backend) {
886
+ add({ id: "config-js", status: "fail", message: `config.js backend is ${String(runtime?.backend ?? "empty")}, expected ${site.runtime.backend}.`, fix: `\`agentcms deploy-admin --site ${site.alias}\`.` });
887
+ } else {
888
+ add({ id: "config-js", status: "ok", message: `config.js: ${site.runtime.backend}, no secrets.` });
889
+ }
890
+ } catch (err) {
891
+ add({ id: "config-js", status: "fail", message: `config.js unreadable: ${err.message}.`, fix: `\`agentcms deploy-admin --site ${site.alias}\`.` });
892
+ }
893
+ }
894
+ }
895
+ if (site.runtime.backend === "sqlite-file") {
896
+ const path = site.runtime.sqlite.path;
897
+ const db = await fetchText(`${siteUrl}/${path}`, f);
898
+ if (!db.ok) add({ id: "database-exposure", status: "ok", message: `Database not served by the public site (${db.status || db.error}).` });
899
+ else if (db.text.startsWith("ACMSENC1")) add({ id: "database-exposure", status: "ok", message: "Database served publicly but encrypted." });
900
+ else
901
+ add({
902
+ id: "database-exposure",
903
+ status: "fail",
904
+ message: `Anyone can download the unencrypted database at ${siteUrl}/${path} (drafts, settings).`,
905
+ fix: "Turn on encryption (reinstall with data.encryption, or store the database in a separate private location) and block the path at the host if possible."
906
+ });
907
+ }
908
+ for (const [role, account] of Object.entries(site.credentials)) {
909
+ if (!account) continue;
910
+ add(
911
+ getCredential(account) ? { id: `credential-${role}`, status: "ok", message: `${role} credential ${account} stored.` } : { id: `credential-${role}`, status: "warn", message: `${role} credential ${account} is not stored on this computer.`, fix: `Ask the user to run \`agentcms credentials set ${account}\`.` }
912
+ );
913
+ }
914
+ const s = site.hosting.settings;
915
+ const hostingCred = site.credentials.hosting ? getCredential(site.credentials.hosting) : null;
916
+ if (site.hosting.connectorId === "git-pages" && s.provider === "github" && (s.host ?? "github-pages") === "github-pages" && hostingCred) {
917
+ const api = String(s.apiBaseUrl || "https://api.github.com").replace(/\/+$/, "");
918
+ try {
919
+ const res = await f(`${api}/repos/${s.owner}/${s.repo}/pages`, {
920
+ headers: { Authorization: `Bearer ${hostingCred.values.token}`, Accept: "application/vnd.github+json" },
921
+ signal: AbortSignal.timeout(15e3)
922
+ });
923
+ if (res.ok) add({ id: "github-pages", status: "ok", message: `GitHub Pages enabled for ${s.owner}/${s.repo}.` });
924
+ else if (res.status === 404)
925
+ add({
926
+ id: "github-pages",
927
+ status: "fail",
928
+ message: `GitHub Pages is not enabled for ${s.owner}/${s.repo}.`,
929
+ fix: `https://github.com/${s.owner}/${s.repo}/settings/pages → Source: Deploy from a branch → ${String(s.branch ?? "main")} / root. (Free plans need a public repo.)`
930
+ });
931
+ else add({ id: "github-pages", status: "warn", message: `Could not read the Pages settings (HTTP ${res.status}) — the token may lack the Pages permission.` });
932
+ } catch (err) {
933
+ add({ id: "github-pages", status: "warn", message: `Could not reach the GitHub API: ${err.message}.` });
934
+ }
935
+ }
936
+ if (opts.deep) {
937
+ if (!hostingCred) {
938
+ add({ id: "hosting-access", status: "skip", message: "No hosting credential to test." });
939
+ } else {
940
+ installBrowserEnv();
941
+ const { testStorageConnection } = await import("./chunks/registry-C9TSnZ77.mjs").then((n) => n.H);
942
+ const res = await testStorageConnection(site.hosting.connectorId, { ...site.hosting.settings, ...hostingCred.values });
943
+ add(
944
+ res.ok ? { id: "hosting-access", status: "ok", message: `Hosting writable: ${res.detail}.` } : { id: "hosting-access", status: "fail", message: `Hosting access failed: ${res.detail}.`, fix: "Check the credential's permissions / expiry, then `agentcms credentials set` it again." }
945
+ );
946
+ }
947
+ }
948
+ return checks;
949
+ }
950
+ const doctorCommand = {
951
+ name: "doctor",
952
+ summary: "Health check of a site: HTTPS, home page, theme assets, hosted admin (consistent build, JavaScript content type, version), config.js without secrets, stored credentials, GitHub Pages. --deep also tests write access.",
953
+ usage: "doctor [--site <alias>] [--deep]",
954
+ options: [
955
+ { name: "site", type: "string", description: "Site alias." },
956
+ { name: "deep", type: "boolean", description: "Also test write access to the hosting (writes and deletes a probe file on file hosts)." }
957
+ ],
958
+ async run({ io, options }) {
959
+ const site = resolveSite(options.site);
960
+ const checks = await runDoctor(site, { deep: !!options.deep });
961
+ const failed = checks.filter((c) => c.status === "fail").length;
962
+ const warned = checks.filter((c) => c.status === "warn").length;
963
+ printResult(
964
+ io,
965
+ { site: site.alias, healthy: failed === 0, failed, warned, checks },
966
+ () => [
967
+ `${site.alias} — ${failed ? `${failed} problem(s)` : "healthy"}${warned ? `, ${warned} warning(s)` : ""}`,
968
+ ...checks.map((c) => ` [${c.status}] ${c.id}: ${c.message}${c.fix ? `
969
+ fix: ${c.fix}` : ""}`)
970
+ ].join("\n")
971
+ );
972
+ if (failed) return 1;
973
+ }
974
+ };
975
+ function readPlan(source) {
976
+ const text = source === "-" ? readFileSync(0, "utf8") : readFileSync(source, "utf8");
977
+ try {
978
+ return JSON.parse(text);
979
+ } catch (err) {
980
+ throw new CliError("invalid-plan", `The plan is not valid JSON: ${err.message}`, void 0, 2);
981
+ }
982
+ }
983
+ const SECRET_FIELDS = {
984
+ s3: "access key + secret key",
985
+ flexweg: "API key",
986
+ "php-bridge": "bridge key"
987
+ };
988
+ const installCommand = {
989
+ name: "install",
990
+ summary: "Install a site from a JSON plan (see `options`): checks access, creates the content store, saves the publish target, uploads theme assets and the admin, registers the site. --dry-run only checks.",
991
+ usage: "install --plan <file|-> [--dry-run] [--overwrite-target]",
992
+ options: [
993
+ { name: "plan", type: "string", description: "Plan file path, or - for stdin." },
994
+ { name: "dry-run", type: "boolean", description: "Validate the plan and test access without writing anything." },
995
+ { name: "overwrite-target", type: "boolean", description: "Replace the publish target already stored in existing content." }
996
+ ],
997
+ async run({ io, options }) {
998
+ if (typeof options.plan !== "string") throw new CliError("usage", "install needs --plan <file|->.", "Run `agentcms options` for the format.", 2);
999
+ installBrowserEnv();
1000
+ const { resolvePlan } = await import("./chunks/plan-BrM8ztEp.mjs").then((n) => n.p);
1001
+ const plan = resolvePlan(readPlan(options.plan));
1002
+ if (getSite(plan.alias)) {
1003
+ throw new CliError("site-exists", `A site named "${plan.alias}" is already registered.`, "Use another alias, or `agentcms site forget` it first.");
1004
+ }
1005
+ const { checkPlan, runInstall } = await import("./chunks/install-DnmgOT3F.mjs");
1006
+ io.err("Checking access…");
1007
+ const checks = await checkPlan(plan);
1008
+ const stored = checks.data.storedTarget;
1009
+ const conflict = !!stored && (stored.connectorId !== plan.target.connectorId || String(stored.settings.siteUrl ?? "").replace(/\/+$/, "") !== plan.siteUrl);
1010
+ const summary = {
1011
+ alias: plan.alias,
1012
+ data: plan.backend,
1013
+ hosting: plan.hostingType,
1014
+ siteUrl: plan.siteUrl,
1015
+ adminUrl: adminUrlOf({ siteUrl: plan.siteUrl, adminFolder: plan.adminFolder }),
1016
+ credentials: plan.credentials,
1017
+ checks
1018
+ };
1019
+ if (!checks.hosting.ok || !checks.data.ok) {
1020
+ throw new CliError(
1021
+ "check-failed",
1022
+ [!checks.data.ok && `Data: ${checks.data.detail}`, !checks.hosting.ok && `Hosting: ${checks.hosting.detail}`].filter(Boolean).join(" — "),
1023
+ "Fix the access (token permissions, repo / bucket names, URL) and retry."
1024
+ );
1025
+ }
1026
+ if (conflict && !options["overwrite-target"]) {
1027
+ throw new CliError(
1028
+ "content-exists",
1029
+ `This content is already set up to publish to ${stored.connectorId} (${String(stored.settings.siteUrl ?? "?")}).`,
1030
+ "Ask the user: keep that site (register it with `agentcms site add`) or pass --overwrite-target to publish elsewhere."
1031
+ );
1032
+ }
1033
+ if (options["dry-run"]) {
1034
+ printResult(
1035
+ io,
1036
+ { ...summary, dryRun: true, existingContent: checks.data.existing },
1037
+ () => `Plan OK for ${plan.alias}: ${plan.backend} → ${plan.hostingType} (${plan.siteUrl}). Nothing was written.`
1038
+ );
1039
+ return;
1040
+ }
1041
+ const unlock = lockSite(plan.alias);
1042
+ try {
1043
+ const outcome = await runInstall(plan, (label) => io.err(`• ${label}`));
1044
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1045
+ putSite({
1046
+ alias: plan.alias,
1047
+ siteUrl: plan.siteUrl,
1048
+ adminFolder: plan.adminFolder,
1049
+ runtime: plan.runtime,
1050
+ hosting: plan.publicTarget,
1051
+ credentials: plan.credentials,
1052
+ adminVersion: outcome.adminVersion ?? void 0,
1053
+ installedAt: now,
1054
+ updatedAt: now
1055
+ });
1056
+ const nextSteps = [];
1057
+ if (summary.adminUrl) nextSteps.push(`Open the admin: ${summary.adminUrl}`);
1058
+ else nextSteps.push("The admin was not copied to the hosting: run `agentcms serve` to use it locally.");
1059
+ if (plan.backend === "markdown-git") {
1060
+ nextSteps.push("Each browser asks once for the editor's own git token (Contents read/write on the content repository).");
1061
+ } else {
1062
+ nextSteps.push(
1063
+ `Each new browser asks once for the ${plan.dataLocation && plan.dataLocation !== plan.target ? "data location" : "hosting"} keys${plan.passphraseAccount ? " and the database passphrase" : ""} (they stay in that browser). No login: protect the admin folder at the hosting level.`
1064
+ );
1065
+ if (plan.passphraseAccount) {
1066
+ nextSteps.push(
1067
+ `The database is encrypted. Have the user save its passphrase in a password manager now: \`agentcms credentials copy ${plan.passphraseAccount}\` puts it on the clipboard. Without it the content can't be opened.`
1068
+ );
1069
+ } else if (!plan.dataLocation || plan.dataLocation === plan.target) {
1070
+ nextSteps.push("Warning: the database is NOT encrypted and is served by the public site — anyone who guesses its path can download it.");
1071
+ } else if (plan.dataLocation.connectorId === "git-pages") {
1072
+ nextSteps.push("Warning: the database is NOT encrypted — anyone who can read the repository (public repos: everyone) can download it.");
1073
+ }
1074
+ }
1075
+ if (plan.backend === "markdown-git" && SECRET_FIELDS[plan.target.connectorId]) {
1076
+ nextSteps.push(`Publishing from a browser needs the hosting ${SECRET_FIELDS[plan.target.connectorId]} once: admin → Settings → Publish target.`);
1077
+ }
1078
+ if (plan.target.connectorId === "git-pages") nextSteps.push("Pages hosts take about a minute to serve new commits.");
1079
+ nextSteps.push(`Check everything with \`agentcms doctor --site ${plan.alias}\`.`);
1080
+ printResult(
1081
+ io,
1082
+ { ...summary, installed: true, files: outcome.files, adminVersion: outcome.adminVersion, notices: outcome.notices, nextSteps },
1083
+ () => [`Installed ${plan.alias}.`, ...outcome.notices.map((n) => `${n.level}: ${n.message}`), ...nextSteps.map((s) => `- ${s}`)].join("\n")
1084
+ );
1085
+ } finally {
1086
+ unlock();
1087
+ }
1088
+ }
1089
+ };
1090
+ const DATA_BACKENDS = [
1091
+ {
1092
+ id: "sqlite-file",
1093
+ summary: "One SQLite file stored on the site's own hosting.",
1094
+ goodFor: "One owner, simplest setup, no other account.",
1095
+ cli: true,
1096
+ fields: [
1097
+ { key: "path", required: true, description: "File path on the hosting (or in the separate location).", example: "data/site.sqlite" },
1098
+ {
1099
+ key: "encryption",
1100
+ required: false,
1101
+ description: 'Default: encrypted with a passphrase generated at install, stored as passphrase:<alias>. { credential: "passphrase:<name>" } to use an existing one, false for a plain file.'
1102
+ },
1103
+ {
1104
+ key: "location",
1105
+ required: false,
1106
+ description: "Separate PRIVATE place for the database: { type, settings, credential } like hosting (e.g. a private S3 / R2 bucket, a private GitHub repo via git-other). Default: the hosting itself — for git hostings, the dedicated branch agentcms-data of the same repo (never published, no site rebuild on save)."
1107
+ }
1108
+ ],
1109
+ credentialTypes: ["passphrase"],
1110
+ notes: [
1111
+ "A static host serves the database file like any page: keep it encrypted (default) and/or in a separate private location.",
1112
+ "No login screen: anyone who can open the admin and has the passphrase can edit; each browser asks for the keys + passphrase once.",
1113
+ "Losing the passphrase means losing the content: have the user save it (`agentcms credentials copy passphrase:<alias>`)."
1114
+ ]
1115
+ },
1116
+ {
1117
+ id: "markdown-git",
1118
+ summary: "Posts as Markdown files in a GitHub or GitLab repository; every change is a commit.",
1119
+ goodFor: "Several editors or computers, full history.",
1120
+ cli: true,
1121
+ fields: [
1122
+ { key: "git.provider", required: true, description: "github | gitlab", example: "github" },
1123
+ { key: "git.owner", required: false, description: "GitHub user or organisation (github).", example: "my-login" },
1124
+ { key: "git.repo", required: false, description: "GitHub repository name (github).", example: "my-site-content" },
1125
+ { key: "git.project", required: false, description: "GitLab project path (gitlab).", example: "group/my-site-content" },
1126
+ { key: "git.branch", required: false, description: "Branch.", default: "main" },
1127
+ { key: "git.contentDir", required: false, description: 'Folder inside the repo ("" = root).', default: "" },
1128
+ { key: "git.apiBaseUrl", required: false, description: "Self-hosted GitHub Enterprise / GitLab API URL." }
1129
+ ],
1130
+ credentialTypes: ["github", "gitlab"],
1131
+ notes: ["Each editor signs in to the admin with their own token; repository permission = role."]
1132
+ },
1133
+ {
1134
+ id: "firebase",
1135
+ summary: "Firestore + Firebase Auth.",
1136
+ goodFor: "Real-time multi-editor with email/password logins.",
1137
+ cli: false,
1138
+ fields: [],
1139
+ credentialTypes: [],
1140
+ notes: ["Not scriptable yet: run `agentcms serve` and use the setup wizard."]
1141
+ },
1142
+ {
1143
+ id: "local",
1144
+ summary: "Data kept in one browser (IndexedDB).",
1145
+ goodFor: "Trying it out on one computer.",
1146
+ cli: false,
1147
+ fields: [],
1148
+ credentialTypes: [],
1149
+ notes: ["Browser-only by nature: run `agentcms serve` and use the setup wizard."]
1150
+ }
1151
+ ];
1152
+ const SITE_URL = {
1153
+ key: "siteUrl",
1154
+ required: true,
1155
+ description: "Public URL the site is served at.",
1156
+ example: "https://my-login.github.io/my-site"
1157
+ };
1158
+ const gitFields = (provider) => [
1159
+ ...provider === "github" ? [
1160
+ { key: "owner", required: true, description: "GitHub user or organisation.", example: "my-login" },
1161
+ { key: "repo", required: true, description: "Repository holding the published site.", example: "my-site" }
1162
+ ] : [{ key: "project", required: true, description: "GitLab project path.", example: "group/my-site" }],
1163
+ { key: "branch", required: false, description: "Branch the host publishes.", default: "main" },
1164
+ { key: "dir", required: false, description: "Repo folder holding the site.", default: provider === "gitlab" ? "public" : "" },
1165
+ { key: "apiBaseUrl", required: false, description: "Self-hosted API URL." },
1166
+ SITE_URL
1167
+ ];
1168
+ const HOSTINGS = [
1169
+ {
1170
+ id: "github-pages",
1171
+ connectorId: "git-pages",
1172
+ summary: "GitHub Pages — free hosting from a GitHub repository.",
1173
+ cli: true,
1174
+ fields: gitFields("github"),
1175
+ credentialTypes: ["github"],
1176
+ notes: [
1177
+ "siteUrl is https://<owner>.github.io/<repo> (or https://<owner>.github.io for a <owner>.github.io repo).",
1178
+ "GitHub Pages is enabled automatically when the token has the Pages permission; otherwise Settings → Pages → Deploy from a branch → main / root.",
1179
+ "Free plans need a public repository for Pages."
1180
+ ]
1181
+ },
1182
+ {
1183
+ id: "cloudflare-pages",
1184
+ connectorId: "git-pages",
1185
+ summary: "Cloudflare Pages connected to a GitHub repository.",
1186
+ cli: true,
1187
+ fields: gitFields("github"),
1188
+ credentialTypes: ["github"],
1189
+ notes: ["Connect the repo once in the Cloudflare dashboard (Workers & Pages → Create → Pages → Connect to Git), no build command, output directory = repo root."]
1190
+ },
1191
+ {
1192
+ id: "gitlab-pages",
1193
+ connectorId: "git-pages",
1194
+ summary: "GitLab Pages.",
1195
+ cli: true,
1196
+ fields: gitFields("gitlab"),
1197
+ credentialTypes: ["gitlab"],
1198
+ notes: ["A minimal .gitlab-ci.yml is committed with the first publish."]
1199
+ },
1200
+ {
1201
+ id: "git-other",
1202
+ connectorId: "git-pages",
1203
+ summary: "Any host that deploys a GitHub repository (Netlify, Vercel…).",
1204
+ cli: true,
1205
+ fields: gitFields("github"),
1206
+ credentialTypes: ["github"],
1207
+ notes: []
1208
+ },
1209
+ {
1210
+ id: "s3",
1211
+ connectorId: "s3",
1212
+ summary: "S3-compatible bucket (AWS, OVHcloud, Scaleway, Cloudflare R2, MinIO…).",
1213
+ cli: true,
1214
+ fields: [
1215
+ { key: "endpoint", required: false, description: "S3 API endpoint (empty = AWS).", example: "https://s3.eu-west-par.io.cloud.ovh.net" },
1216
+ { key: "region", required: true, description: "Region.", example: "eu-west-par" },
1217
+ { key: "bucket", required: true, description: "Bucket name.", example: "my-site" },
1218
+ { key: "prefix", required: false, description: "Key prefix inside the bucket.", default: "" },
1219
+ { key: "publicRead", required: false, description: "Send the public-read ACL (OVHcloud / Scaleway buckets without a bucket policy).", default: false },
1220
+ { key: "forcePathStyle", required: false, description: "Path-style URLs (default: off on AWS, on elsewhere)." },
1221
+ SITE_URL
1222
+ ],
1223
+ credentialTypes: ["s3"],
1224
+ notes: [
1225
+ "The bucket needs CORS allowing GET/PUT/DELETE/HEAD from the admin's origin(s) and static website hosting with index.html.",
1226
+ "Host the admin on the bucket's HTTPS endpoint — signing requests needs a secure context."
1227
+ ]
1228
+ },
1229
+ {
1230
+ id: "flexweg",
1231
+ connectorId: "flexweg",
1232
+ summary: "Flexweg static hosting.",
1233
+ cli: true,
1234
+ fields: [SITE_URL, { key: "apiBaseUrl", required: false, description: "Flexweg API URL.", default: "https://www.flexweg.com/api/v1" }],
1235
+ credentialTypes: ["flexweg"],
1236
+ notes: []
1237
+ },
1238
+ {
1239
+ id: "own-server",
1240
+ connectorId: "php-bridge",
1241
+ summary: "Advanced — your own PHP server through the bridge script in external/bridges/php/ (not a static host; prefer a static hosting).",
1242
+ cli: true,
1243
+ fields: [
1244
+ { key: "apiBaseUrl", required: true, description: "URL of flexweg-bridge.php.", example: "https://example.com/cms-bridge/flexweg-bridge.php" },
1245
+ SITE_URL
1246
+ ],
1247
+ credentialTypes: ["php-bridge"],
1248
+ notes: []
1249
+ },
1250
+ {
1251
+ id: "local-folder",
1252
+ connectorId: "local-folder",
1253
+ summary: "A folder on this computer (browser File System Access).",
1254
+ cli: false,
1255
+ fields: [],
1256
+ credentialTypes: [],
1257
+ notes: ["Browser-only: use `agentcms serve` and the setup wizard."]
1258
+ },
1259
+ {
1260
+ id: "zip",
1261
+ connectorId: "browser-mirror",
1262
+ summary: "ZIP download to upload by hand.",
1263
+ cli: false,
1264
+ fields: [],
1265
+ credentialTypes: [],
1266
+ notes: ["Browser-only: use `agentcms serve` and the setup wizard."]
1267
+ }
1268
+ ];
1269
+ function findHosting(id) {
1270
+ return HOSTINGS.find((h) => h.id === id);
1271
+ }
1272
+ function findDataBackend(id) {
1273
+ return DATA_BACKENDS.find((d) => d.id === id);
1274
+ }
1275
+ const PLAN_EXAMPLE = {
1276
+ alias: "my-blog",
1277
+ data: { backend: "markdown-git", git: { provider: "github", owner: "my-login", repo: "my-blog-content", branch: "main" }, credential: "github:my-login" },
1278
+ hosting: { type: "github-pages", settings: { owner: "my-login", repo: "my-blog", siteUrl: "https://my-login.github.io/my-blog" } },
1279
+ admin: { folder: "admin" }
1280
+ };
1281
+ const optionsCommand = {
1282
+ name: "options",
1283
+ summary: "List data backends, hostings, credential types and the install plan format — ask the user about data first, then hosting.",
1284
+ usage: "options",
1285
+ options: [],
1286
+ async run({ io }) {
1287
+ const data = {
1288
+ order: ["data", "hosting"],
1289
+ dataBackends: DATA_BACKENDS,
1290
+ hostings: HOSTINGS,
1291
+ credentialTypes: CREDENTIAL_TYPES,
1292
+ planExample: PLAN_EXAMPLE,
1293
+ planNotes: [
1294
+ "Secrets never go in the plan: store them first with `agentcms credentials set <type>:<name>` and reference the account id.",
1295
+ "hosting.credential may be omitted for git hostings on the same provider as markdown-git content (the content token is reused).",
1296
+ "admin: false skips copying the admin onto the hosting.",
1297
+ "SQLite is encrypted by default (passphrase generated and stored as passphrase:<alias>); data.location keeps the file in a separate private place.",
1298
+ "Entries with cli: false need the setup wizard: `agentcms serve`."
1299
+ ]
1300
+ };
1301
+ printResult(
1302
+ io,
1303
+ data,
1304
+ () => [
1305
+ "1. Where is the content stored?",
1306
+ ...DATA_BACKENDS.map((d) => ` ${d.id.padEnd(14)} ${d.summary}${d.cli ? "" : " (wizard only)"}`),
1307
+ "",
1308
+ "2. Where is the site hosted?",
1309
+ ...HOSTINGS.map((h) => ` ${h.id.padEnd(17)} ${h.summary}${h.cli ? "" : " (wizard only)"}`),
1310
+ "",
1311
+ "Plan example (agentcms install --plan plan.json):",
1312
+ JSON.stringify(PLAN_EXAMPLE, null, 2)
1313
+ ].join("\n")
1314
+ );
1315
+ }
1316
+ };
1317
+ const MIME = {
1318
+ ".html": "text/html; charset=utf-8",
1319
+ ".js": "text/javascript; charset=utf-8",
1320
+ ".mjs": "text/javascript; charset=utf-8",
1321
+ ".css": "text/css; charset=utf-8",
1322
+ ".json": "application/json; charset=utf-8",
1323
+ ".wasm": "application/wasm",
1324
+ ".svg": "image/svg+xml",
1325
+ ".png": "image/png",
1326
+ ".jpg": "image/jpeg",
1327
+ ".jpeg": "image/jpeg",
1328
+ ".webp": "image/webp",
1329
+ ".gif": "image/gif",
1330
+ ".ico": "image/x-icon",
1331
+ ".woff": "font/woff",
1332
+ ".woff2": "font/woff2",
1333
+ ".txt": "text/plain; charset=utf-8",
1334
+ ".zip": "application/zip"
1335
+ };
1336
+ const NO_CACHE = /* @__PURE__ */ new Set(["/index.html", "/config.js", "/admin-files.json", "/external.default.json"]);
1337
+ function resolveRequestPath(root, url) {
1338
+ let pathname;
1339
+ try {
1340
+ pathname = decodeURIComponent(new URL(url, "http://localhost").pathname);
1341
+ } catch {
1342
+ return null;
1343
+ }
1344
+ if (pathname.endsWith("/")) pathname += "index.html";
1345
+ const file2 = normalize(join(root, pathname));
1346
+ if (file2 !== root && !file2.startsWith(root.endsWith(sep) ? root : root + sep)) return null;
1347
+ return file2;
1348
+ }
1349
+ function startAdminServer(root, host, port) {
1350
+ const server = createServer((req, res) => {
1351
+ if (req.method !== "GET" && req.method !== "HEAD") {
1352
+ res.writeHead(405).end();
1353
+ return;
1354
+ }
1355
+ const file2 = resolveRequestPath(root, req.url ?? "/");
1356
+ if (!file2 || !existsSync(file2) || !statSync(file2).isFile()) {
1357
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
1358
+ return;
1359
+ }
1360
+ const rel = "/" + file2.slice(root.length).split(sep).filter(Boolean).join("/");
1361
+ res.writeHead(200, {
1362
+ "content-type": MIME[extname(file2).toLowerCase()] ?? "application/octet-stream",
1363
+ "cache-control": NO_CACHE.has(rel) ? "no-store" : "no-cache",
1364
+ "x-content-type-options": "nosniff"
1365
+ });
1366
+ if (req.method === "HEAD") res.end();
1367
+ else createReadStream(file2).pipe(res);
1368
+ });
1369
+ return new Promise((resolve2, reject) => {
1370
+ server.once("error", reject);
1371
+ server.listen(port, host, () => {
1372
+ server.off("error", reject);
1373
+ resolve2(server);
1374
+ });
1375
+ });
1376
+ }
1377
+ const MAX_PORT_TRIES = 20;
1378
+ const serveCommand = {
1379
+ name: "serve",
1380
+ summary: "Serve the admin on this computer (127.0.0.1) to run the setup wizard or work locally. Runs until stopped.",
1381
+ usage: "serve [--port <n>]",
1382
+ options: [{ name: "port", type: "string", description: "First port to try (the next free one is used).", default: "4173" }],
1383
+ async run({ io, options }) {
1384
+ const root = join(distRoot(), "admin");
1385
+ if (!existsSync(join(root, "index.html"))) {
1386
+ throw new CliError("admin-missing", `No built admin in ${root}.`, "Run `npm run build` (from a clone) or use the npm package.");
1387
+ }
1388
+ const first = Number(options.port ?? 4173);
1389
+ if (!Number.isInteger(first) || first < 1 || first > 65535) {
1390
+ throw new CliError("usage", `Invalid port: ${options.port}`, void 0, 2);
1391
+ }
1392
+ const host = "127.0.0.1";
1393
+ let server = null;
1394
+ let port = first;
1395
+ for (; port < first + MAX_PORT_TRIES && port <= 65535; port++) {
1396
+ try {
1397
+ server = await startAdminServer(root, host, port);
1398
+ break;
1399
+ } catch (e) {
1400
+ if (e.code !== "EADDRINUSE") throw e;
1401
+ }
1402
+ }
1403
+ if (!server) throw new CliError("port-busy", `Ports ${first}-${port - 1} are all in use.`, "Pass --port <n>.");
1404
+ const url = `http://localhost:${port}/`;
1405
+ printResult(io, { url, root }, () => `Admin running at ${url}
1406
+ Press Ctrl+C to stop.`);
1407
+ await new Promise((resolve2) => {
1408
+ const stop = () => server.close(() => resolve2());
1409
+ process.once("SIGINT", stop);
1410
+ process.once("SIGTERM", stop);
1411
+ });
1412
+ }
1413
+ };
1414
+ const siteCommand = {
1415
+ name: "site",
1416
+ summary: "Manage the registry of sites this computer drives: list, show, use <alias> (default), rename <from> <to>, forget <alias> (local only — nothing is deleted online), add <alias> --admin-url <url> (register an existing site).",
1417
+ usage: "site <list|show|use|rename|forget|add> [args] [--site <alias>] [--admin-url <url>] [--site-url <url>] [--data-credential <account>] [--hosting-credential <account>] [--passphrase-credential <account>]",
1418
+ options: [
1419
+ { name: "site", type: "string", description: "Site alias (show)." },
1420
+ { name: "admin-url", type: "string", description: "add: URL of the hosted admin, e.g. https://example.com/admin/." },
1421
+ { name: "site-url", type: "string", description: "add: public site URL when it isn't the admin's parent folder." },
1422
+ { name: "data-credential", type: "string", description: "add: account of the content repo token (Markdown in git) or of the SQLite data location." },
1423
+ { name: "hosting-credential", type: "string", description: "add: account used to write to the hosting." },
1424
+ { name: "passphrase-credential", type: "string", description: "add: passphrase account of an encrypted SQLite database." }
1425
+ ],
1426
+ async run({ io, options, positionals }) {
1427
+ const [action = "list", a, b] = positionals;
1428
+ switch (action) {
1429
+ case "list": {
1430
+ const { default: def, sites } = listSites();
1431
+ const rows = sites.map((s) => ({
1432
+ alias: s.alias,
1433
+ default: s.alias === def,
1434
+ siteUrl: s.siteUrl,
1435
+ adminUrl: adminUrlOf(s),
1436
+ backend: s.runtime.backend,
1437
+ hosting: s.hosting.connectorId || null,
1438
+ adminVersion: s.adminVersion ?? null
1439
+ }));
1440
+ printResult(
1441
+ io,
1442
+ { default: def, sites: rows },
1443
+ () => rows.length ? rows.map((r) => `${r.default ? "*" : " "} ${r.alias.padEnd(20)} ${r.siteUrl} [${r.backend} → ${r.hosting ?? "?"}]`).join("\n") : "No site registered. Install one with `agentcms install` or register one with `agentcms site add`."
1444
+ );
1445
+ return;
1446
+ }
1447
+ case "show": {
1448
+ const site = resolveSite(a ?? options.site);
1449
+ printResult(io, { ...site, adminUrl: adminUrlOf(site) }, () => JSON.stringify(site, null, 2));
1450
+ return;
1451
+ }
1452
+ case "use": {
1453
+ if (!a) throw new CliError("usage", "site use needs an alias.", void 0, 2);
1454
+ setDefaultSite(a);
1455
+ printResult(io, { default: a }, () => `Default site: ${a}`);
1456
+ return;
1457
+ }
1458
+ case "rename": {
1459
+ if (!a || !b) throw new CliError("usage", "site rename needs <from> <to>.", void 0, 2);
1460
+ renameSite(a, b);
1461
+ printResult(io, { from: a, to: b }, () => `Renamed ${a} → ${b}`);
1462
+ return;
1463
+ }
1464
+ case "forget": {
1465
+ if (!a) throw new CliError("usage", "site forget needs an alias.", void 0, 2);
1466
+ const removed = removeSite(a);
1467
+ printResult(io, { alias: a, removed }, () => removed ? `Forgot ${a} (nothing was deleted online).` : `${a} was not registered.`);
1468
+ return;
1469
+ }
1470
+ case "add": {
1471
+ if (!a) throw new CliError("usage", "site add needs an alias.", void 0, 2);
1472
+ validateAlias(a);
1473
+ if (getSite(a)) throw new CliError("site-exists", `A site named "${a}" already exists.`, "Pick another alias or `site forget` it first.");
1474
+ if (typeof options["admin-url"] !== "string") throw new CliError("usage", "site add needs --admin-url.", void 0, 2);
1475
+ installBrowserEnv();
1476
+ const { siteFromAdminUrl } = await import("./chunks/siteAdd-BcXhN9fk.mjs");
1477
+ const site = await siteFromAdminUrl({
1478
+ alias: a,
1479
+ adminUrl: options["admin-url"],
1480
+ siteUrl: options["site-url"],
1481
+ dataCredential: options["data-credential"],
1482
+ hostingCredential: options["hosting-credential"],
1483
+ passphraseCredential: options["passphrase-credential"]
1484
+ });
1485
+ putSite(site);
1486
+ printResult(io, site, () => `Registered ${a}: ${site.siteUrl} [${site.runtime.backend} → ${site.hosting.connectorId || "unknown hosting"}]`);
1487
+ return;
1488
+ }
1489
+ default:
1490
+ throw new CliError("usage", `Unknown action "${action}".`, "Use list, show, use, rename, forget or add.", 2);
1491
+ }
1492
+ }
1493
+ };
1494
+ function registryLatestUrl(name) {
1495
+ return `https://registry.npmjs.org/${name.replace("/", "%2f")}/latest`;
1496
+ }
1497
+ async function fetchLatestVersion(fetchImpl = fetch, name = readVersionInfo().name) {
1498
+ try {
1499
+ const res = await fetchImpl(registryLatestUrl(name), { signal: AbortSignal.timeout(5e3) });
1500
+ if (!res.ok) return null;
1501
+ const body = await res.json();
1502
+ return typeof body.version === "string" ? body.version : null;
1503
+ } catch {
1504
+ return null;
1505
+ }
1506
+ }
1507
+ const versionCommand = {
1508
+ name: "version",
1509
+ summary: "Show this tool's version, the admin API version and (with --check) whether a newer release exists.",
1510
+ usage: "version [--check]",
1511
+ options: [{ name: "check", type: "boolean", description: "Ask the npm registry for the latest release." }],
1512
+ async run({ io, options }) {
1513
+ const info = readVersionInfo();
1514
+ const data = { ...info };
1515
+ if (options.check) {
1516
+ const latest = await fetchLatestVersion();
1517
+ data.latest = latest;
1518
+ data.updateAvailable = latest ? compareVersions(latest, info.version) > 0 : null;
1519
+ }
1520
+ printResult(io, data, () => {
1521
+ const lines = [`agentcms ${info.version} (admin API ${info.apiVersion}, data schema ${info.dataSchema})`];
1522
+ if (options.check) {
1523
+ if (!data.latest) lines.push("Could not reach the npm registry to check for updates.");
1524
+ else if (data.updateAvailable) lines.push(`Update available: ${data.latest}`);
1525
+ else lines.push("This is the latest release.");
1526
+ }
1527
+ return lines.join("\n");
1528
+ });
1529
+ }
1530
+ };
1531
+ async function siteStatus(site, version, dataSchema) {
1532
+ const adminUrl = adminUrlOf(site);
1533
+ const base = { alias: site.alias, siteUrl: site.siteUrl, adminUrl };
1534
+ if (!adminUrl) return { ...base, adminVersion: null, state: "not-hosted" };
1535
+ const hosted = await fetchHostedAdmin(adminUrl);
1536
+ if (!hosted.version) return { ...base, adminVersion: null, state: "unreachable", error: hosted.error };
1537
+ if (hosted.version !== site.adminVersion) putSite({ ...site, adminVersion: hosted.version });
1538
+ const cmp = compareVersions(hosted.version, version);
1539
+ if (cmp === 0) return { ...base, adminVersion: hosted.version, state: "current" };
1540
+ if (cmp > 0) return { ...base, adminVersion: hosted.version, state: "newer" };
1541
+ return {
1542
+ ...base,
1543
+ adminVersion: hosted.version,
1544
+ state: "update",
1545
+ update: changesBetween(readChangelog(), hosted.version, version),
1546
+ blockedByDataSchema: hosted.dataSchema !== null && hosted.dataSchema > dataSchema
1547
+ };
1548
+ }
1549
+ const statusCommand = {
1550
+ name: "status",
1551
+ summary: "Versions overview — run it first. Compares this tool and its skills with the latest release, and every registered site's hosted admin with this release (what changed, what to run).",
1552
+ usage: "status [--site <alias>]",
1553
+ options: [{ name: "site", type: "string", description: "Only this site (default: every registered site)." }],
1554
+ async run({ io, options }) {
1555
+ const info = readVersionInfo();
1556
+ const sites = typeof options.site === "string" ? [resolveSite(options.site)] : listSites().sites;
1557
+ const [latest, statuses] = await Promise.all([
1558
+ fetchLatestVersion(),
1559
+ Promise.all(sites.map((s) => siteStatus(s, info.version, info.dataSchema)))
1560
+ ]);
1561
+ const toolUpdate = latest && compareVersions(latest, info.version) > 0 ? changesBetween(readChangelog(), info.version, latest) : null;
1562
+ const data = {
1563
+ tool: { version: info.version, latest, updateAvailable: latest ? compareVersions(latest, info.version) > 0 : null },
1564
+ // Release notes of the newer tool are only known once it's installed;
1565
+ // `changes` is filled when this package already ships them.
1566
+ toolUpdate,
1567
+ sites: statuses,
1568
+ advice: [
1569
+ ...latest && compareVersions(latest, info.version) > 0 ? [`agentcms ${latest} is available (this is ${info.version}): offer to update the skills first (Claude Code: /plugin marketplace update agentcms, then /reload-plugins).`] : [],
1570
+ ...statuses.filter((s) => s.state === "update").map(
1571
+ (s) => s.blockedByDataSchema ? `${s.alias}: hosted admin ${s.adminVersion} stores data in a newer format than this tool — update the tool, never downgrade.` : `${s.alias}: admin ${s.adminVersion} → ${info.version} available${s.update?.breaking ? " (BREAKING — read the changes)" : ""}: offer \`agentcms deploy-admin --site ${s.alias}\`.`
1572
+ ),
1573
+ ...statuses.filter((s) => s.state === "newer").map((s) => `${s.alias}: hosted admin ${s.adminVersion} is newer than this tool — update the tool before deploying.`),
1574
+ ...statuses.filter((s) => s.state === "unreachable").map((s) => `${s.alias}: admin not reachable (${s.error}) — run \`agentcms doctor --site ${s.alias}\`.`)
1575
+ ]
1576
+ };
1577
+ printResult(
1578
+ io,
1579
+ data,
1580
+ () => [
1581
+ `agentcms ${info.version}${data.tool.updateAvailable ? ` — ${latest} available` : latest ? " (latest)" : ""}`,
1582
+ ...statuses.map((s) => ` ${s.alias.padEnd(20)} admin ${s.adminVersion ?? "-"} ${s.state}${s.error ? ` (${s.error})` : ""}`),
1583
+ ...data.advice.length ? ["", ...data.advice.map((a) => `- ${a}`)] : []
1584
+ ].join("\n")
1585
+ );
1586
+ }
1587
+ };
1588
+ const helpCommand = {
1589
+ name: "help",
1590
+ summary: "List commands and their options (--json gives a machine-readable list).",
1591
+ usage: "help [<command>]",
1592
+ options: [],
1593
+ async run({ io, positionals }) {
1594
+ const wanted = positionals[0];
1595
+ const list = wanted ? COMMANDS.filter((c) => c.name === wanted) : COMMANDS;
1596
+ const { version } = readVersionInfo();
1597
+ const data = {
1598
+ name: "agentcms",
1599
+ version,
1600
+ globalOptions: GLOBAL_OPTIONS,
1601
+ commands: list.map(({ name, summary, usage, options }) => ({ name, summary, usage, options }))
1602
+ };
1603
+ printResult(
1604
+ io,
1605
+ data,
1606
+ () => [
1607
+ `agentcms ${version} — install and manage AgentCMS sites from a terminal or an AI agent.`,
1608
+ "",
1609
+ "Usage: agentcms <command> [options] [--json]",
1610
+ "",
1611
+ ...list.flatMap((c) => [
1612
+ ` ${c.usage}`,
1613
+ ` ${c.summary}`,
1614
+ ...c.options.map((o) => ` --${o.name}${o.type === "string" ? " <value>" : ""} ${o.description}`)
1615
+ ]),
1616
+ "",
1617
+ "Global options:",
1618
+ ...GLOBAL_OPTIONS.map((o) => ` --${o.name} ${o.description}`)
1619
+ ].join("\n")
1620
+ );
1621
+ }
1622
+ };
1623
+ const GLOBAL_OPTIONS = [
1624
+ { name: "json", type: "boolean", description: "Print one JSON object on stdout: { ok, data } or { ok: false, error }." }
1625
+ ];
1626
+ const COMMANDS = [
1627
+ helpCommand,
1628
+ versionCommand,
1629
+ statusCommand,
1630
+ optionsCommand,
1631
+ credentialsCommand,
1632
+ siteCommand,
1633
+ installCommand,
1634
+ deployAdminCommand,
1635
+ doctorCommand,
1636
+ compactDataCommand,
1637
+ serveCommand
1638
+ ];
1639
+ function findCommand(name) {
1640
+ return COMMANDS.find((c) => c.name === name);
1641
+ }
1642
+ async function main(argv, io) {
1643
+ const json = argv.includes("--json");
1644
+ const out = createIo(json);
1645
+ try {
1646
+ const [name = "help", ...rest] = argv.filter((a) => a !== "--json");
1647
+ if (name === "--help" || name === "-h") return await run("help", rest, out) ?? 0;
1648
+ if (name === "--version" || name === "-v") return await run("version", rest, out) ?? 0;
1649
+ return await run(name, rest, out) ?? 0;
1650
+ } catch (error) {
1651
+ return printError(out, error);
1652
+ }
1653
+ }
1654
+ async function run(name, args, io) {
1655
+ const command = findCommand(name);
1656
+ if (!command) {
1657
+ throw new CliError(
1658
+ "unknown-command",
1659
+ `Unknown command "${name}".`,
1660
+ `Available: ${COMMANDS.map((c) => c.name).join(", ")}. Run \`agentcms help\`.`,
1661
+ 2
1662
+ );
1663
+ }
1664
+ let parsed;
1665
+ try {
1666
+ parsed = parseArgs({
1667
+ args,
1668
+ allowPositionals: true,
1669
+ strict: true,
1670
+ options: Object.fromEntries(
1671
+ [...command.options, ...GLOBAL_OPTIONS].map((o) => [o.name, { type: o.type }])
1672
+ )
1673
+ });
1674
+ } catch (e) {
1675
+ throw new CliError("usage", e.message, `Usage: agentcms ${command.usage}`, 2);
1676
+ }
1677
+ return command.run({
1678
+ io,
1679
+ options: parsed.values,
1680
+ positionals: parsed.positionals
1681
+ });
1682
+ }
1683
+ const [major] = process.versions.node.split(".").map(Number);
1684
+ if (major < 20) {
1685
+ process.stderr.write(`agentcms needs Node.js 20 or newer (found ${process.versions.node}).
1686
+ `);
1687
+ process.exit(1);
1688
+ }
1689
+ main(process.argv.slice(2)).then((code) => {
1690
+ process.exitCode = code;
1691
+ });
1692
+ export {
1693
+ CliError as C,
1694
+ SqlitePassphraseError as S,
1695
+ parseAccount as a,
1696
+ fetchHostedAdmin as b,
1697
+ findDataBackend as c,
1698
+ distRoot as d,
1699
+ getCredential as e,
1700
+ fetchText as f,
1701
+ generatePassphrase as g,
1702
+ findHosting as h,
1703
+ isEncryptedDatabase as i,
1704
+ encryptionParams as j,
1705
+ decryptDatabase as k,
1706
+ encryptDatabase as l,
1707
+ parseConfigJs as p,
1708
+ requireCredential as r,
1709
+ setCredential as s,
1710
+ validateAlias as v
1711
+ };