caracms 0.7.0 → 0.8.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 (75) hide show
  1. package/README.md +108 -19
  2. package/bin/caracms.mjs +11 -3
  3. package/bin/init.mjs +332 -34
  4. package/bin/media.mjs +60 -6
  5. package/bin/templates/railway-cloud.ts.txt +73 -0
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +30 -13
  8. package/skills/caracms-hosting/SKILL.md +240 -0
  9. package/skills/caracms-tina/SKILL.md +45 -15
  10. package/src/body.ts +55 -0
  11. package/src/cli/validate.ts +1 -1
  12. package/src/cloud-middleware.ts +51 -5
  13. package/src/config.ts +20 -1
  14. package/src/content.ts +15 -6
  15. package/src/document-problems.ts +48 -0
  16. package/src/editor/client/collections/CollectionGrid.tsx +2 -2
  17. package/src/editor/client/collections/CollectionTable.tsx +3 -3
  18. package/src/editor/client/documents/DocMenu.tsx +56 -1
  19. package/src/editor/client/documents/actions.ts +30 -1
  20. package/src/editor/client/documents/dialogs.tsx +61 -3
  21. package/src/editor/client/editor.ts +98 -2
  22. package/src/editor/client/fields/LockupField.tsx +4 -4
  23. package/src/editor/client/inbox/InboxStage.tsx +25 -1
  24. package/src/editor/client/live/notices.tsx +19 -0
  25. package/src/editor/client/live/publish.tsx +58 -2
  26. package/src/editor/client/nav/DocRow.tsx +4 -4
  27. package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
  28. package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
  29. package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
  30. package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
  31. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
  32. package/src/editor/client/pane/parts.tsx +67 -4
  33. package/src/editor/client/pane/switcher.tsx +4 -4
  34. package/src/editor/client/shell/Bar.tsx +11 -0
  35. package/src/editor/client/state/keeper.ts +28 -4
  36. package/src/editor/client/state/live.ts +18 -1
  37. package/src/editor/routes/_live.ts +1 -1
  38. package/src/editor/routes/admin.astro +9 -0
  39. package/src/editor/routes/discard.ts +11 -0
  40. package/src/editor/server/admin-props.ts +18 -2
  41. package/src/editor/server/arrange.ts +4 -4
  42. package/src/editor/server/create.ts +4 -4
  43. package/src/editor/server/drafts-store.ts +2 -2
  44. package/src/editor/server/empty-site.ts +23 -0
  45. package/src/editor/server/media/cloud-mux.ts +4 -1
  46. package/src/editor/server/media/config.ts +12 -5
  47. package/src/editor/server/media/upload.ts +2 -41
  48. package/src/editor/server/mode.ts +3 -1
  49. package/src/editor/server/move.ts +32 -4
  50. package/src/editor/server/write.ts +4 -4
  51. package/src/editor/styles/levels/live.css +2 -0
  52. package/src/editor/types.ts +92 -4
  53. package/src/email.ts +4 -1
  54. package/src/forms/cloud-store.ts +4 -1
  55. package/src/forms/inbox.ts +10 -5
  56. package/src/forms/store.ts +21 -4
  57. package/src/index.ts +21 -6
  58. package/src/live/auth/login.ts +4 -3
  59. package/src/live/auth/views.ts +8 -1
  60. package/src/live/autosaves.ts +94 -6
  61. package/src/live/cloud.ts +64 -20
  62. package/src/live/drafts-store.ts +17 -6
  63. package/src/live/editor.ts +232 -16
  64. package/src/live/git.ts +492 -22
  65. package/src/live/github.ts +84 -17
  66. package/src/live/locks.ts +22 -0
  67. package/src/live/messages.ts +49 -2
  68. package/src/live/mode.ts +19 -8
  69. package/src/live/notices.ts +5 -1
  70. package/src/live/runtime.ts +2 -1
  71. package/src/live/services.ts +114 -0
  72. package/src/live/status.ts +5 -0
  73. package/src/live/sweep.ts +44 -1
  74. package/src/live/webhook.ts +8 -3
  75. package/src/media.ts +9 -3
package/bin/init.mjs CHANGED
@@ -1,9 +1,16 @@
1
- // `caracms init [--write] [--railway]` (M8 Decisions 7–13): add caracms's plumbing to an
2
- // existing Astro site, and nothing about its design.
1
+ // `caracms init [--write] [--railway] [--cloud | --self-hosted]` (M8 Decisions 7–13,
2
+ // caracms-cloud M5 Decisions 5–11): add caracms's plumbing to an existing Astro site, and
3
+ // nothing about its design.
3
4
  //
4
5
  // - Run plainly, it prints every file it would add and every edit as a diff, and writes
5
6
  // nothing. `--write` applies exactly that, then writes the schemas (`caracms schemas`).
6
- // It never prompts.
7
+ // - Where the site runs (the config's `services`, M5): `--cloud` or `--self-hosted` writes
8
+ // it into the `defineSite({` literal. With neither and no field yet, it asks only when a
9
+ // person can answer (a terminal on both ends); otherwise a new site stops and names both
10
+ // flags, and a site already set up gets everything else and "Mode not set". A field
11
+ // already there is the mode, never asked again; a flag against it is the move: `--cloud`
12
+ // prints the move list for a self-hosted site, `--self-hosted` the way back. `--cloud`
13
+ // checks the key (`GET /v1/settings`) when it can find one, never printing it.
7
14
  // - It stops on anything in the way (Decision 9), naming each problem with what to do, and
8
15
  // writes nothing. One run reports every problem, the git checks included.
9
16
  // - An existing file it can't edit safely (an astro.config it can't follow, an existing
@@ -15,14 +22,17 @@
15
22
  // - Run again, it re-links the skills, refreshes the CLAUDE.md block and the schemas, and
16
23
  // reports the rest as already done.
17
24
  //
18
- // Plain JavaScript importing only Node built-ins (and, for the schemas, the site's own
19
- // Vite through site-runner.mjs), so it runs on Node 22.12 with no flags.
25
+ // Plain JavaScript importing only Node built-ins and media.mjs (and, for the schemas and the
26
+ // config's `services`, the site's own Vite through site-runner.mjs), so it runs on Node
27
+ // 22.12 with no flags.
20
28
 
21
29
  import { execFileSync } from 'node:child_process';
22
30
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
23
31
  import { createRequire } from 'node:module';
32
+ import { createInterface } from 'node:readline/promises';
24
33
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
25
34
  import { fileURLToPath } from 'node:url';
35
+ import { DEFAULT_CLOUD_URL, OWN_MEDIA_NAMES, parseDotenv, REPLACED_NAMES } from './media.mjs';
26
36
 
27
37
  const packageRoot = fileURLToPath(new URL('..', import.meta.url));
28
38
  const templates = fileURLToPath(new URL('./templates/', import.meta.url));
@@ -53,13 +63,20 @@ export const HOST_VARIABLES = [
53
63
  { name: 'GITHUB_WEBHOOK_SECRET', need: 'optional', what: "For a webhook added in the repo's settings. Without it, a check once a minute does the same job." },
54
64
  ];
55
65
 
56
- /** HOST_VARIABLES as init prints it: one table, name, whether it's needed, what it's for. */
57
- export function hostVariablesText() {
58
- const rows = [{ name: 'Name', need: 'Needed', what: "What it's for" }, ...HOST_VARIABLES];
66
+ /** What the cloud key replaces in HOST_VARIABLES (M5 Contracts), and the key itself. */
67
+ const CLOUD_KEY_VARIABLE = { name: 'CARACMS_CLOUD_KEY', need: 'required', what: "The site's key from app.caracms.com (cck_…), labelled for this server. It brings the site's GitHub access, images, video, and forms and email." };
68
+
69
+ /** HOST_VARIABLES as init prints it: one table, name, whether it's needed, what it's for. On CaraCMS Cloud, the key in place of what it replaces. */
70
+ /** @param {string | null} [mode] the config's `services` */
71
+ export function hostVariablesText(mode = null) {
72
+ const variables = mode === 'cloud' ? [CLOUD_KEY_VARIABLE, ...HOST_VARIABLES.filter((v) => !REPLACED_NAMES.includes(v.name) && v.name !== 'GITHUB_WEBHOOK_SECRET').map((v) => (v.name === 'CARACMS_ALLOW' ? { ...v, need: 'for email sign-in (sent by the cloud)' } : v))] : HOST_VARIABLES;
73
+ const rows = [{ name: 'Name', need: 'Needed', what: "What it's for" }, ...variables];
59
74
  const w1 = Math.max(...rows.map((r) => r.name.length));
60
75
  const w2 = Math.max(...rows.map((r) => r.need.length));
61
76
  return [
62
- "The variables any host needs, on the site's server (`caracms start` runs it; one instance, behind HTTPS). Set email sign-in, the backup sign-in or both, or nobody can sign in. Images, video and forms have their own variables: the caracms README's Hosting section lists them. `caracms init --railway` writes a Railway setup instead.",
77
+ mode === 'cloud'
78
+ ? "The variables any host needs, on the site's server (`caracms start` runs it; one instance, behind HTTPS), on CaraCMS Cloud. Set email sign-in, the backup sign-in or both, or nobody can sign in. `caracms init --railway` writes a Railway setup instead."
79
+ : "The variables any host needs, on the site's server (`caracms start` runs it; one instance, behind HTTPS). Set email sign-in, the backup sign-in or both, or nobody can sign in. Images, video and forms have their own variables: the caracms README's Hosting section lists them. `caracms init --railway` writes a Railway setup instead.",
63
80
  ...rows.map((r) => ` ${r.name.padEnd(w1)} ${r.need.padEnd(w2)} ${r.what}`),
64
81
  ].join('\n');
65
82
  }
@@ -83,6 +100,7 @@ const SKILL_BLURBS = {
83
100
  'caracms-blocks': 'building the site: blocks and their fields, collections, text styles, the page component, forms, and scripts beside the editor',
84
101
  'caracms-adopt': 'bringing the site\'s existing pages onto caracms, one page at a time, and checking they look the same',
85
102
  'caracms-tina': 'porting a site built on Tina (a Tina template site) onto caracms with the reference conversion script, re-runnable on newer content',
103
+ 'caracms-hosting': "where the site runs, CaraCMS Cloud or self-hosted: choosing the mode with `caracms init`, the server's host, moving a self-hosted site onto the cloud, and what to do about each of the cloud's refusals",
86
104
  };
87
105
 
88
106
  const posix = (p) => p.split(sep).join('/');
@@ -186,7 +204,8 @@ function inspect(root) {
186
204
  : existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb'))
187
205
  ? 'bun'
188
206
  : (pkg?.packageManager?.split('@')[0] ?? 'npm');
189
- return { root, pkgText, pkg, deps, configName, configText, srcDir, pm: lock, setUp: existsSync(join(root, 'caracms.config.ts')) };
207
+ const caracmsConfigText = read(join(root, 'caracms.config.ts'));
208
+ return { root, pkgText, pkg, deps, configName, configText, srcDir, pm: lock, setUp: caracmsConfigText !== null, caracmsConfigText };
190
209
  }
191
210
 
192
211
  function installedAstro(root) {
@@ -327,6 +346,219 @@ function listFiles(dir) {
327
346
  return out;
328
347
  }
329
348
 
349
+ // ── Where the site runs (caracms-cloud M5) ──────────────────────────────────────
350
+
351
+ /** The config's `services` values, as the flags name them. */
352
+ export const MODES = ['cloud', 'self-hosted'];
353
+ /** Decision 5: a new site with no mode, no flag and no terminal to ask in. */
354
+ export const NO_MODE_PROBLEM =
355
+ "Where will this site run? There's no terminal to ask in, so run init again with `--cloud` (CaraCMS Cloud) or `--self-hosted` (the site's own GitHub access, buckets and imgproxy).";
356
+ export const BOTH_MODES_PROBLEM = '`--cloud` and `--self-hosted` are the two places a site runs: give one of them.';
357
+ /** The CLAUDE.md block's and the report's line for the mode (Decision 8). */
358
+ export const MODE_LINES = {
359
+ cloud: 'This site runs on CaraCMS Cloud: see the `caracms-hosting` skill.',
360
+ 'self-hosted': 'This site is self-hosted (its own GitHub access, buckets and imgproxy): see the `caracms-hosting` skill.',
361
+ none: 'Mode not set: run `caracms init --cloud` or `--self-hosted`.',
362
+ };
363
+
364
+ /** The end of a string literal starting at `i` (its closing quote's index). */
365
+ function stringEnd(text, i) {
366
+ const quote = text[i];
367
+ for (let j = i + 1; j < text.length; j++) {
368
+ if (text[j] === '\\') j++;
369
+ else if (text[j] === quote) return j;
370
+ }
371
+ return text.length;
372
+ }
373
+
374
+ /**
375
+ * The config's text with `services: '<mode>',` written (Decision 7): an existing `services`
376
+ * property of the `defineSite({` object literal set to the string, or the property inserted
377
+ * as the literal's first. `{ text }`, or `{ problem }` naming the line to add by hand when
378
+ * the argument isn't an object literal or `services` is set from something else.
379
+ * @returns {{ text?: string, problem?: string }}
380
+ */
381
+ export function withServices(text, mode, file = 'caracms.config.ts') {
382
+ const line = `services: '${mode}',`;
383
+ const lineOf = (i) => text.slice(0, i).split('\n').length;
384
+ const byHand = (where) => ({
385
+ problem: `${file}: ${where}, so init can't write where the site runs. Add this line by hand, as the first inside \`defineSite({ … })\`, then run init again:\n ${line}`,
386
+ });
387
+ const call = /\bdefineSite\s*\(\s*/.exec(text);
388
+ if (!call) return byHand("there's no `defineSite(…)` call in it");
389
+ const open = call.index + call[0].length;
390
+ if (text[open] !== '{') return byHand(`what line ${lineOf(call.index)} passes to defineSite isn't an object literal ({ … })`);
391
+ // The literal's top level: strings and comments skipped, brackets counted.
392
+ let depth = 0;
393
+ let found = -1;
394
+ for (let i = open; i < text.length; i++) {
395
+ const c = text[i];
396
+ if (c === '"' || c === "'" || c === '`') {
397
+ const end = stringEnd(text, i);
398
+ if (depth === 1 && text.slice(i + 1, end) === 'services' && /^\s*:/.test(text.slice(end + 1))) {
399
+ found = i;
400
+ break;
401
+ }
402
+ i = end;
403
+ } else if (c === '/' && text[i + 1] === '/') {
404
+ const end = text.indexOf('\n', i);
405
+ i = end === -1 ? text.length : end;
406
+ } else if (c === '/' && text[i + 1] === '*') {
407
+ const end = text.indexOf('*/', i + 2);
408
+ i = end === -1 ? text.length : end + 1;
409
+ } else if ('{[('.includes(c)) depth++;
410
+ else if ('}])'.includes(c)) {
411
+ if (--depth === 0) break;
412
+ } else if (depth === 1 && /[A-Za-z_$]/.test(c) && !/[\w$]/.test(text[i - 1] ?? '')) {
413
+ const word = /^[A-Za-z_$][\w$]*/.exec(text.slice(i))[0];
414
+ if (word === 'services' && /^\s*:/.test(text.slice(i + word.length))) {
415
+ found = i;
416
+ break;
417
+ }
418
+ i += word.length - 1;
419
+ }
420
+ }
421
+ if (found !== -1) {
422
+ const m = /^(['"]?services['"]?\s*:\s*)(?:(['"])[^'"\n]*\2)?/.exec(text.slice(found));
423
+ if (!m[2]) return byHand(`line ${lineOf(found)} sets services from something other than a string`);
424
+ return { text: `${text.slice(0, found)}${m[1]}'${mode}'${text.slice(found + m[0].length)}` };
425
+ }
426
+ const rest = text.slice(open + 1);
427
+ if (rest.startsWith('\n') || rest.startsWith('\r\n')) {
428
+ const indent = /\n([ \t]*)\S/.exec(rest)?.[1] ?? '\t';
429
+ return { text: `${text.slice(0, open + 1)}\n${indent}${line}${rest}` };
430
+ }
431
+ return { text: `${text.slice(0, open + 1)} ${line}${/^\s/.test(rest) ? '' : ' '}${rest}` };
432
+ }
433
+
434
+ /** The cloud's replaced variables, by group, for the move list (src/live/services.ts `REPLACED_VARIABLES`). */
435
+ const REPLACED_GROUPS = [
436
+ ['Images', OWN_MEDIA_NAMES],
437
+ ['Video', ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET']],
438
+ ['Forms and email', ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM']],
439
+ ['GitHub', ['CARACMS_GITHUB_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_REPO']],
440
+ ];
441
+
442
+ /**
443
+ * A self-hosted site's signs in the repo (Decision 9): each replaced variable found in
444
+ * `.env` or `.railway/railway.ts` (name → where), and whether railway.ts declares buckets or imgproxy.
445
+ */
446
+ export function selfHostedSigns(root) {
447
+ const railwayText = read(join(root, '.railway/railway.ts'));
448
+ const dotenv = parseDotenv(read(join(root, '.env')) ?? '');
449
+ const found = new Map();
450
+ for (const name of REPLACED_NAMES) {
451
+ if (dotenv[name]?.trim()) found.set(name, '.env');
452
+ else if (railwayText && new RegExp(`\\b${name}\\b`).test(railwayText)) found.set(name, '.railway/railway.ts');
453
+ }
454
+ const services = Boolean(railwayText && /\bbucket\s*\(|\bservice\(\s*["']imgproxy|ghcr\.io\/imgproxy/.test(railwayText));
455
+ return { found, railway: railwayText !== null, services, any: found.size > 0 || services };
456
+ }
457
+
458
+ /** The move to the cloud, in the Contracts' order, only the steps that apply, each with its check (Decision 9). */
459
+ export function moveList(signs, { write }) {
460
+ const has = (names) => names.some((n) => signs.found.has(n));
461
+ const media = has(OWN_MEDIA_NAMES) || signs.services;
462
+ const video = has(['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET']);
463
+ const steps = [];
464
+ const step = (id, text, check) => steps.push({ id, text, check });
465
+ step('setup', "Make the site and a key for it in app.caracms.com, and install the caracms GitHub App on this repo (only this one). Then open the site's page in app.caracms.com: the cloud refreshes the App's install state only when that page opens (or a repo is linked, or the site mints a token).", 'run `caracms init --cloud` again: it shows "App installed".');
466
+ step('push', `Commit and push \`services: 'cloud'\` in caracms.config.ts${write ? '' : ' (after `caracms init --cloud --write`)'}.`, 'the host\'s build fails with "CARACMS_CLOUD_KEY isn\'t set" until the key is on the host. That\'s expected: the old deployment keeps serving.');
467
+ if (media) step('images', "Copy the images into the site's cloud folder: `caracms media push`, with the old bucket's S3_* on its command line (or in .env) and the key in .env.", 'it reports failed: 0, and a second run skips everything.');
468
+ step('key', 'Set CARACMS_CLOUD_KEY on the host. Leave the old variables there for now: they\'re ignored.', "the deploy's logs list them as ignored; an editor signs in; an upload lands in the cloud.");
469
+ if (video) step('video', 'Upload each video clip again in the editor, and place it again.', 'no page plays a clip from the old Mux account.');
470
+ const marked = REPLACED_GROUPS.map(([group, names]) => ` ${group}: ${names.map((n) => (signs.found.has(n) ? `${n} (* ${signs.found.get(n)})` : n)).join(', ')}`);
471
+ step('remove', ['Remove every variable the cloud replaces from the host, one by one (* found in this repo):', ...marked].join('\n'), 'the next deploy logs no "ignored" lines.');
472
+ const n = Object.fromEntries(steps.map((s, i) => [s.id, i + 1]));
473
+ const after = ['key', 'video', 'remove'].filter((id) => n[id]).map((id) => n[id]);
474
+ step(
475
+ 'retire',
476
+ `Once ${after.length > 1 ? `${after[0]}–${after[after.length - 1]}` : after[0]} hold, retire what the cloud replaced: imgproxy; the media bucket; the GitHub token or App on this repo; Resend. Keep the submissions bucket until its old submissions are exported or no longer wanted (the cloud doesn't copy them)${video ? `, and the old Mux account until step ${n.video} is done` : ''}.`,
477
+ null,
478
+ );
479
+ if (signs.railway) step('railway', 'Cut .railway/railway.ts down to the site and Redis: what `caracms init --cloud --railway` writes for a new site, keeping this site\'s names. init leaves the file alone: cut down any earlier, `railway config apply` would remove the buckets before the images are copied.', '`railway config plan` shows nothing to create or change.');
480
+ return [
481
+ "The move to CaraCMS Cloud, in order. init only sees this repo (.env and .railway/railway.ts): check the variables on the host's own page against the list in step " +
482
+ `${n.remove} too.`,
483
+ ...steps.map((s, i) => ` ${i + 1}. ${s.text}${s.check ? `\n Check: ${s.check}` : ''}`),
484
+ ].join('\n');
485
+ }
486
+
487
+ /** The way back from the cloud (Decision 12; the README's leaving notes). */
488
+ export const WAY_BACK = [
489
+ "Leaving CaraCMS Cloud: the site's services are its own again.",
490
+ " 1. Set them up as the caracms README's Hosting section says: GitHub access (a token or an App), a media bucket and imgproxy, a submissions bucket, Resend, and Mux for video.",
491
+ " 2. Copy the site's folder in CaraCMS Cloud's media bucket to the new media bucket: the keys in content (uploads/…) stay the same. There's no export command yet: ask CaraCMS for a copy of the folder.",
492
+ ' 3. Set the variables on the host, deploy, and check the editor and an upload.',
493
+ ' 4. Remove CARACMS_CLOUD_KEY from the host (it\'s ignored now), then revoke the key in app.caracms.com.',
494
+ ].join('\n');
495
+
496
+ /** A variable from the environment, else the site's `.env` (as `media import` reads it). */
497
+ function envOrDotenv(root, env) {
498
+ const dotenv = parseDotenv(read(join(root, '.env')) ?? '');
499
+ return (name) => {
500
+ const v = env[name]?.trim();
501
+ if (v) return { value: v, from: 'the environment' };
502
+ const d = dotenv[name]?.trim();
503
+ return d ? { value: d, from: '.env' } : null;
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Decision 11: the key, when one can be found, checked against `GET /v1/settings`: the site,
509
+ * its team, its repo and whether the App is installed on it, or the cloud's own sentence.
510
+ * The key's value is never in what this returns.
511
+ */
512
+ export async function keyCheck(root, { env = process.env, fetch: fetchImpl = fetch, timeoutMs = 10_000 } = {}) {
513
+ const get = envOrDotenv(root, env);
514
+ const key = get('CARACMS_CLOUD_KEY');
515
+ if (!key) return "CaraCMS Cloud key: none here (CARACMS_CLOUD_KEY in the environment or .env). Make one in app.caracms.com, a labelled key per laptop or environment, and put this laptop's in .env (never committed); then run `caracms init --cloud` again to check it.";
516
+ const url = (get('CARACMS_CLOUD_URL')?.value ?? DEFAULT_CLOUD_URL).replace(/\/+$/, '');
517
+ let res;
518
+ let answer;
519
+ try {
520
+ res = await fetchImpl(`${url}/v1/settings`, { headers: { authorization: `Bearer ${key.value}`, accept: 'application/json', 'user-agent': 'caracms' }, signal: AbortSignal.timeout(timeoutMs) });
521
+ answer = await res.json();
522
+ } catch {
523
+ return `CaraCMS Cloud key (from ${key.from}): CaraCMS Cloud couldn't be reached at ${url} to check it. Try again in a minute.`;
524
+ }
525
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
526
+ if (!res.ok) {
527
+ const message = typeof obj(answer).message === 'string' && obj(answer).message.trim() ? obj(answer).message.trim() : `the cloud answered ${res.status}.`;
528
+ return `CaraCMS Cloud key (from ${key.from}): refused. ${message}`;
529
+ }
530
+ const site = obj(obj(answer).site).name;
531
+ const team = obj(obj(answer).team).name;
532
+ const github = obj(obj(answer).github);
533
+ const repo = typeof github.repo === 'string' && github.repo ? github.repo : null;
534
+ const app = github.installed === true ? 'App installed' : github.installed === false ? "App not installed: install the caracms App on it from the site's page in app.caracms.com" : 'App not checked yet: the site\'s page in app.caracms.com checks it';
535
+ return [
536
+ `CaraCMS Cloud key (from ${key.from}):`,
537
+ ` Site: ${typeof site === 'string' && site ? site : '(not named)'}`,
538
+ ` Team: ${typeof team === 'string' && team ? team : '(this cloud doesn\'t say)'}`,
539
+ ` Repo: ${repo ? `${repo} (${app})` : "none linked yet: link it on the site's page in app.caracms.com"}`,
540
+ ].join('\n');
541
+ }
542
+
543
+ /** Ask where the site runs (Decision 5), on a terminal. Null when there's no answer (the input closed). */
544
+ export async function askMode({ input = process.stdin, output = process.stdout } = {}) {
545
+ const rl = createInterface({ input, output });
546
+ try {
547
+ for (;;) {
548
+ let answer;
549
+ try {
550
+ answer = (await rl.question('Where will this site run?\n 1. CaraCMS Cloud\n 2. Self-hosted (its own GitHub access, buckets and imgproxy)\nChoose 1 or 2: ')).trim().toLowerCase();
551
+ } catch {
552
+ return null;
553
+ }
554
+ if (answer === '1' || answer === 'cloud' || answer === 'caracms cloud') return 'cloud';
555
+ if (answer === '2' || answer === 'self-hosted' || answer === 'self hosted') return 'self-hosted';
556
+ }
557
+ } finally {
558
+ rl.close();
559
+ }
560
+ }
561
+
330
562
  // ── The changes ─────────────────────────────────────────────────────────────────
331
563
 
332
564
  /**
@@ -334,7 +566,7 @@ function listFiles(dir) {
334
566
  * 'unlink' | 'schemas', file, before?, after?, target? }), notes (printed, nothing written),
335
567
  * and what's already done.
336
568
  */
337
- function changesOf(site, { railway = false } = {}) {
569
+ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
338
570
  const { root } = site;
339
571
  const changes = [];
340
572
  const notes = [];
@@ -373,7 +605,11 @@ function changesOf(site, { railway = false } = {}) {
373
605
  else notes.push(result.note);
374
606
  }
375
607
 
376
- add('caracms.config.ts', template('caracms.config.ts.txt'));
608
+ // The config: a new site's from the template, with where it runs (M5 Decision 7); a set-up site's
609
+ // field written when the mode is new or moves (plan() has checked withServices can).
610
+ if (!site.setUp) add('caracms.config.ts', mode ? withServices(template('caracms.config.ts.txt'), mode).text : template('caracms.config.ts.txt'));
611
+ else if (mode && mode !== field) edit('caracms.config.ts', site.caracmsConfigText, withServices(site.caracmsConfigText, mode).text);
612
+ else if (mode) done.push(`caracms.config.ts (services: '${mode}')`);
377
613
  add('src/components/caracms/Page.astro', template('Page.astro.txt'));
378
614
  add('src/components/caracms/TextBlock.astro', template('TextBlock.astro.txt'));
379
615
  add(`${posix(site.srcDir)}/pages/[...path].astro`, template('catch-all.astro.txt'));
@@ -413,7 +649,8 @@ function changesOf(site, { railway = false } = {}) {
413
649
  const repo = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote)?.[1] ?? null;
414
650
  const repoName = basename(git(root, ['rev-parse', '--show-toplevel']).out.trim() || root);
415
651
  const run = site.pm === 'npm' ? 'npm run' : site.pm;
416
- const text = template('railway.ts.txt')
652
+ // M5 Decision 10: on the cloud, the site and Redis only.
653
+ const text = template(mode === 'cloud' ? 'railway-cloud.ts.txt' : 'railway.ts.txt')
417
654
  .replaceAll('{{SITE}}', repoName)
418
655
  .replaceAll('{{GITHUB_REPO}}', repo ?? 'OWNER/REPO')
419
656
  .replaceAll('{{BUILD}}', `${run} build`)
@@ -422,7 +659,7 @@ function changesOf(site, { railway = false } = {}) {
422
659
  if (!repo) notes.push(`${railwayFile}: the git remote isn't on GitHub, so GITHUB_REPO is "OWNER/REPO". Set it to the site's repository.`);
423
660
  } else if (!read(join(root, railwayFile))?.includes('(written by `caracms init` from the')) {
424
661
  // One init wrote is set up; a site's own (every Tina site has one) gets caracms's needs printed.
425
- notes.push(railwayNeeds(railwayFile));
662
+ notes.push(railwayNeeds(railwayFile, mode));
426
663
  } else done.push(railwayFile);
427
664
 
428
665
  // Skills (Decision 12), linked from the installed package.
@@ -450,7 +687,7 @@ function changesOf(site, { railway = false } = {}) {
450
687
  const link = lstat(join(root, 'CLAUDE.md'))?.isSymbolicLink() ? readlinkSync(join(root, 'CLAUDE.md')) : null;
451
688
  const file = link ? posix(relative(root, resolve(root, link))) : 'CLAUDE.md';
452
689
  const before = read(join(root, file));
453
- const block = claudeBlock(skills);
690
+ const block = claudeBlock(skills, mode);
454
691
  const after = withBlock(before, block);
455
692
  if (after === null) notes.push(`${file} has a ${MARK_START} line with no ${MARK_END} after it, so init can't tell where its block ends and leaves the file alone. Replace the old block with this one by hand, then run init again:\n${block.split('\n').map((l) => ` ${l}`).join('\n')}`);
456
693
  else edit(file, before, after, `${file} (the caracms block)`);
@@ -553,7 +790,15 @@ function astroConfigEdit(site) {
553
790
  return { file, edit: out };
554
791
  }
555
792
 
556
- function railwayNeeds(file) {
793
+ function railwayNeeds(file, mode = null) {
794
+ if (mode === 'cloud') {
795
+ return [
796
+ `${file} exists, so init doesn't edit it (every Tina site has one). On CaraCMS Cloud, the project needs only these:`,
797
+ ' Redis: the editor\'s drafts and sessions. The site gets REDIS_URL.',
798
+ ' The site: NODE_ENV=production, SITE_URL (its public address), start command `caracms start`, healthcheck /health; set by hand: CARACMS_CLOUD_KEY, CARACMS_SECRET, CARACMS_ALLOW, CARACMS_BACKUP_USER, CARACMS_BACKUP_PASSWORD, CARACMS_RAILWAY_TOKEN.',
799
+ ' The whole of it, as init writes it for a new cloud site: node_modules/caracms/bin/templates/railway-cloud.ts.txt.',
800
+ ].join('\n');
801
+ }
557
802
  return [
558
803
  `${file} exists, so init doesn't edit it (every Tina site has one). Add what caracms needs to it by hand:`,
559
804
  ' Redis: the editor\'s drafts and sessions. The site gets REDIS_URL.',
@@ -572,11 +817,17 @@ export function skillNames() {
572
817
  return readdirSync(dir).filter((n) => !n.startsWith('.') && isDir(join(dir, n))).sort();
573
818
  }
574
819
 
575
- export function claudeBlock(skills) {
820
+ /**
821
+ * @param {string[]} skills
822
+ * @param {string | null} [mode] the config's `services`; null: not set
823
+ */
824
+ export function claudeBlock(skills, mode = null) {
576
825
  const lines = [
577
826
  MARK_START,
578
827
  '## caracms',
579
828
  '',
829
+ MODE_LINES[mode ?? 'none'],
830
+ '',
580
831
  "This site's content is edited with caracms (the `caracms` package). Its skills are linked from the installed package into `.claude/skills/`, so they always match the installed version:",
581
832
  '',
582
833
  ...skills.map((name) => `- \`${name}\`${SKILL_BLURBS[name] ? `: ${SKILL_BLURBS[name]}.` : ''}`),
@@ -651,7 +902,8 @@ function describe(change) {
651
902
  }
652
903
  }
653
904
 
654
- function report({ changes, notes, done, hostVariables }, { write, pm }) {
905
+ function report({ changes, notes, done, hostVariables, mode, where, site }, { write, pm }) {
906
+ const setUp = site.setUp;
655
907
  const parts = [];
656
908
  if (changes.length === 0) {
657
909
  parts.push('caracms init: nothing to do. caracms is set up here.');
@@ -665,8 +917,12 @@ function report({ changes, notes, done, hostVariables }, { write, pm }) {
665
917
  }
666
918
  if (done.length && changes.length) parts.push(`Already done: ${done.join('; ')}.`);
667
919
  if (notes.length) parts.push(`Notes:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
668
- if (hostVariables) parts.push(hostVariablesText());
669
- if (changes.length) {
920
+ // M5: where the site runs, the key's check, and the move or the way back.
921
+ parts.push(...where);
922
+ if (hostVariables) parts.push(hostVariablesText(mode));
923
+ if (changes.length && setUp) {
924
+ parts.push(write ? 'Next: commit this as one change.' : 'Then, after --write: commit it as one change.');
925
+ } else if (changes.length) {
670
926
  const install = `${pm} install`;
671
927
  parts.push(
672
928
  write
@@ -679,32 +935,68 @@ function report({ changes, notes, done, hostVariables }, { write, pm }) {
679
935
 
680
936
  // ── Schemas ─────────────────────────────────────────────────────────────────────
681
937
 
682
- /** The schema and `$schema` changes, from the site's config through its Vite. A fresh site's are the starting config's. */
938
+ /**
939
+ * The schema and `$schema` changes, and the config's `services` (M5 Decision 7), from the
940
+ * site's config through its Vite, in one load. A fresh site's are the starting config's, and no field.
941
+ */
683
942
  async function schemaChanges(site) {
684
- if (!site.setUp) return STARTING_SCHEMAS.map((file) => ({ kind: 'schemas', file, before: null, after: '', generated: true }));
943
+ if (!site.setUp) return { schemas: STARTING_SCHEMAS.map((file) => ({ kind: 'schemas', file, before: null, after: '', generated: true })), services: null };
685
944
  const { withPackageModule } = await import('./site-runner.mjs');
686
945
  return withPackageModule(site.root, 'schemas', async (m) => {
687
946
  const loaded = await m.loadSite(site.root);
688
947
  const files = m.schemaFileChanges(loaded).map((c) => ({ kind: 'schemas', file: c.file, before: c.before, after: c.text, generated: true }));
689
948
  const stamps = (await m.stampChanges(loaded)).map((c) => ({ kind: 'schemas', file: c.file, before: c.before, after: c.text }));
690
- return [...files, ...stamps];
949
+ return { schemas: [...files, ...stamps], services: loaded.services ?? null };
691
950
  });
692
951
  }
693
952
 
694
953
  // ── The command ─────────────────────────────────────────────────────────────────
695
954
 
696
- /** The plan: problems (nothing happens when there are any), or the changes. */
697
- export async function plan(root, { write = false, railway = false } = {}) {
955
+ /**
956
+ * The plan: problems (nothing happens when there are any), or the changes. `mode` is the
957
+ * flag's (`'cloud'`, `'self-hosted'`, `'both'` for both flags, or null); `choose()` asks a
958
+ * person where the site runs, only called with no flag and no field (Decision 5).
959
+ * @param {string} root
960
+ * @param {{ write?: boolean, railway?: boolean, mode?: string | null, choose?: (() => Promise<string | null>) | null }} [options]
961
+ */
962
+ export async function plan(root, { write = false, railway = false, mode: flag = null, choose = null } = {}) {
698
963
  const site = inspect(root);
964
+ /** @param {string[]} problems */
965
+ const nothing = (problems) => ({ site, problems, changes: /** @type {any[]} */ ([]), notes: /** @type {string[]} */ ([]), done: /** @type {string[]} */ ([]), hostVariables: false, mode: null, field: null, where: /** @type {string[]} */ ([]), checkKey: false });
699
966
  const problems = problemsOf(site, write);
700
- if (problems.length) return { site, problems, changes: [], notes: [], done: [], hostVariables: false };
701
- const { changes, notes, done, hostVariables } = changesOf(site, { railway });
967
+ if (flag === 'both') problems.push(BOTH_MODES_PROBLEM);
968
+ if (problems.length) return nothing(problems);
969
+ let loaded;
702
970
  try {
703
- changes.push(...(await schemaChanges(site)));
971
+ loaded = await schemaChanges(site);
704
972
  } catch (error) {
705
- return { site, problems: [`The schemas couldn't be made from caracms.config.ts: ${error.message}`], changes: [], notes: [], done: [], hostVariables: false };
973
+ return nothing([`The schemas couldn't be made from caracms.config.ts: ${error.message}`]);
974
+ }
975
+ // M5 Decisions 5–6: the flag, else the field, else a person's answer.
976
+ const field = loaded.services;
977
+ let mode = flag ?? field;
978
+ let asked = false;
979
+ if (!mode && choose) {
980
+ mode = await choose();
981
+ asked = Boolean(mode);
982
+ }
983
+ if (!mode && !site.setUp) return nothing([NO_MODE_PROBLEM]);
984
+ if (mode && mode !== field && site.setUp) {
985
+ const edited = withServices(site.caracmsConfigText, mode);
986
+ if (edited.problem) return nothing([edited.problem]);
987
+ }
988
+ const { changes, notes, done, hostVariables } = changesOf(site, { railway, mode, field });
989
+ changes.push(...loaded.schemas);
990
+
991
+ const where = [MODE_LINES[mode ?? 'none']];
992
+ // Decision 9: --cloud on a self-hosted site (or one with no field) with self-hosted signs is the move.
993
+ if (mode === 'cloud' && field !== 'cloud') {
994
+ const signs = selfHostedSigns(root);
995
+ if (signs.any) where.push(moveList(signs, { write }));
706
996
  }
707
- return { site, problems, changes, notes, done, hostVariables };
997
+ // Decision 12: --self-hosted on a cloud site, the way back.
998
+ if (mode === 'self-hosted' && field === 'cloud') where.push(WAY_BACK);
999
+ return { site, problems, changes, notes, done, hostVariables, mode, field, where, checkKey: mode === 'cloud' && (flag === 'cloud' || asked) };
708
1000
  }
709
1001
 
710
1002
  function apply(root, changes) {
@@ -730,11 +1022,15 @@ function apply(root, changes) {
730
1022
  }
731
1023
 
732
1024
  /**
733
- * `caracms init [--write] [--railway]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
734
- * @param {{ root: string, write?: boolean, railway?: boolean, out?: { write(text: string): unknown }, err?: { write(text: string): unknown } }} options
1025
+ * `caracms init [--write] [--railway] [--cloud | --self-hosted]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
1026
+ * `cloud` and `selfHosted` are the flags. `ask` is how a person is asked where the site runs: by default only when stdin and stdout
1027
+ * are both a terminal (Decision 5), never otherwise. `env` and `fetch` are the key check's (Decision 11).
1028
+ * @param {{ root: string, write?: boolean, railway?: boolean, cloud?: boolean, selfHosted?: boolean, ask?: (() => Promise<string | null>) | null, env?: Record<string, string | undefined>, fetch?: typeof fetch, out?: { write(text: string): unknown }, err?: { write(text: string): unknown } }} options
735
1029
  */
736
- export async function run({ root, write = false, railway = false, out = process.stdout, err = process.stderr }) {
737
- const p = await plan(root, { write, railway });
1030
+ export async function run({ root, write = false, railway = false, cloud = false, selfHosted = false, ask, env = process.env, fetch: fetchImpl = fetch, out = process.stdout, err = process.stderr }) {
1031
+ const flag = cloud && selfHosted ? 'both' : cloud ? 'cloud' : selfHosted ? 'self-hosted' : null;
1032
+ const choose = ask !== undefined ? ask : process.stdin.isTTY && process.stdout.isTTY ? () => askMode() : null;
1033
+ const p = await plan(root, { write, railway, mode: flag, choose });
738
1034
  if (p.problems.length) {
739
1035
  err.write(`caracms init: ${p.problems.length === 1 ? 'one thing is' : `${p.problems.length} things are`} in the way, so nothing was ${write ? 'written' : 'planned'}:\n${p.problems.map((x) => ` - ${x}`).join('\n')}\n`);
740
1036
  return 1;
@@ -751,6 +1047,8 @@ export async function run({ root, write = false, railway = false, out = process.
751
1047
  }
752
1048
  }
753
1049
  }
1050
+ // Decision 11: the key, checked after anything is written (it reads only), never printed.
1051
+ if (p.checkKey) p.where.splice(1, 0, await keyCheck(root, { env, fetch: fetchImpl }));
754
1052
  out.write(report(p, { write, pm: p.site.pm }));
755
1053
  return 0;
756
1054
  }
package/bin/media.mjs CHANGED
@@ -41,7 +41,7 @@
41
41
  // `upload_limit` stops the run there, with its sentence), 2 for bad usage.
42
42
 
43
43
  import { createHash, createHmac } from 'node:crypto';
44
- import { readFileSync } from 'node:fs';
44
+ import { existsSync, readFileSync } from 'node:fs';
45
45
  import { readFile } from 'node:fs/promises';
46
46
  import { basename, join, resolve } from 'node:path';
47
47
 
@@ -510,16 +510,57 @@ export function readS3Env(env, cwd) {
510
510
 
511
511
  const listed = (names) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
512
512
 
513
+ /** Every variable the cloud key replaces, in the Contracts' order (src/live/services.ts `REPLACED_VARIABLES`; a test keeps the two equal). */
514
+ export const REPLACED_NAMES = [
515
+ ...OWN_MEDIA_NAMES,
516
+ 'MUX_TOKEN_ID',
517
+ 'MUX_TOKEN_SECRET',
518
+ 'SUBMISSIONS_S3_BUCKET',
519
+ 'SUBMISSIONS_S3_ACCESS_KEY_ID',
520
+ 'SUBMISSIONS_S3_SECRET_ACCESS_KEY',
521
+ 'RESEND_API_KEY',
522
+ 'FORMS_FROM',
523
+ 'CARACMS_GITHUB_TOKEN',
524
+ 'GITHUB_APP_ID',
525
+ 'GITHUB_APP_PRIVATE_KEY',
526
+ 'GITHUB_REPO',
527
+ ];
528
+
529
+ /** Decision 4's sentence (src/live/services.ts `CLOUD_KEY_MISSING_PROBLEM`). */
530
+ export const CLOUD_KEY_MISSING = "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com.";
531
+
513
532
  /**
514
533
  * The cloud key and address, as the `S3_*` are read, or null without a key; `own` names the
515
534
  * site's own media variables also set (the running site's "not both", src/media.ts).
535
+ * With the config's `services` (caracms-cloud M5 Decision 2) the other mode's variables are
536
+ * ignored instead, each named once in `ignored`: on `'cloud'` `own` is always empty, on
537
+ * `'self-hosted'` the key is never read and this is null.
516
538
  */
517
- export function readCloudEnv(env, cwd) {
539
+ export function readCloudEnv(env, cwd, services = null) {
518
540
  const get = envReader(env, cwd);
519
- const key = get('CARACMS_CLOUD_KEY').trim();
541
+ const key = services === 'self-hosted' ? '' : get('CARACMS_CLOUD_KEY').trim();
520
542
  if (!key) return null;
521
543
  const url = (get('CARACMS_CLOUD_URL').trim() || DEFAULT_CLOUD_URL).replace(/\/+$/, '');
522
- return { key, url, own: OWN_MEDIA_NAMES.filter((name) => get(name)) };
544
+ if (services === 'cloud') return { key, url, own: [], ignored: REPLACED_NAMES.filter((name) => get(name)) };
545
+ return { key, url, own: OWN_MEDIA_NAMES.filter((name) => get(name)), ignored: [] };
546
+ }
547
+
548
+ /** The line naming what a cloud or self-hosted site ignores: src/live/services.ts `ignoredMessage` without a group, word for word (a test keeps the two equal; this file can't import the TypeScript). */
549
+ export function ignoredLine(names, services) {
550
+ const one = names.length === 1;
551
+ const where = services === 'self-hosted' ? 'this site is self-hosted' : 'this site runs on CaraCMS Cloud';
552
+ const tail = services === 'self-hosted' ? `Remove ${one ? 'it' : 'them'}, or run \`caracms init --cloud\` to move the site to CaraCMS Cloud.` : `Remove ${one ? 'it' : 'them'} once the move is done.`;
553
+ return `${listed(names)} ${one ? 'is' : 'are'} set, but ${where} (caracms.config.ts): ${one ? "it's" : "they're"} ignored. ${tail}`;
554
+ }
555
+
556
+ /** The config file names, in the order they're looked for (src/cli/site.ts `CONFIG_FILES`). */
557
+ const CONFIG_FILES = ['ts', 'mts', 'mjs', 'js'].map((ext) => `caracms.config.${ext}`);
558
+
559
+ /** The config's `services` (caracms-cloud M5 Decision 1), through the site's own Vite; null without a config. */
560
+ export async function readServices(cwd) {
561
+ if (!CONFIG_FILES.some((name) => existsSync(join(cwd, name)))) return null;
562
+ const { withPackageModule } = await import('./site-runner.mjs');
563
+ return withPackageModule(cwd, 'site', async (m) => (await m.loadSite(cwd)).services);
523
564
  }
524
565
 
525
566
  export const bothMessage = (names) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
@@ -717,8 +758,21 @@ export async function run(argv, io = {}) {
717
758
  return 2;
718
759
  }
719
760
 
720
- // The cloud key, when it's set, is the one credential (caracms-cloud M3 Decisions 6 and 19).
721
- const cloud = readCloudEnv(env, cwd);
761
+ // caracms-cloud M5: the config's `services` decides; without one, the key does (M3 Decisions 6 and 19).
762
+ let services;
763
+ try {
764
+ services = io.services !== undefined ? io.services : await readServices(cwd);
765
+ } catch (error) {
766
+ say(`The site's caracms.config.ts couldn't be read, so it isn't known whether the images go to CaraCMS Cloud: ${error.message}`);
767
+ return 1;
768
+ }
769
+ const cloud = readCloudEnv(env, cwd, services);
770
+ if (services === 'cloud' && !cloud) {
771
+ say(CLOUD_KEY_MISSING);
772
+ return 1;
773
+ }
774
+ if (services === 'self-hosted' && envReader(env, cwd)('CARACMS_CLOUD_KEY').trim()) say(ignoredLine(['CARACMS_CLOUD_KEY'], services));
775
+ if (cloud?.ignored.length) say(ignoredLine(cloud.ignored, services));
722
776
  if (cloud && cloud.own.length > 0) {
723
777
  say(bothMessage(cloud.own));
724
778
  return 1;