caracms 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -17
- package/bin/caracms.mjs +11 -3
- package/bin/init.mjs +332 -34
- package/bin/media.mjs +60 -6
- package/bin/templates/railway-cloud.ts.txt +73 -0
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +30 -13
- package/skills/caracms-hosting/SKILL.md +240 -0
- package/skills/caracms-tina/SKILL.md +45 -15
- package/src/cloud-middleware.ts +13 -3
- package/src/config.ts +20 -1
- package/src/editor/server/media/config.ts +12 -5
- package/src/editor/server/mode.ts +3 -1
- package/src/email.ts +4 -1
- package/src/forms/store.ts +7 -2
- package/src/index.ts +3 -1
- package/src/live/mode.ts +19 -8
- package/src/live/services.ts +114 -0
- package/src/media.ts +9 -3
package/bin/init.mjs
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
// `caracms init [--write] [--railway]` (M8 Decisions 7–13
|
|
2
|
-
// existing Astro site, and
|
|
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
|
-
//
|
|
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
|
|
19
|
-
// Vite through site-runner.mjs), so it runs on Node
|
|
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
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
697
|
-
|
|
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 (
|
|
701
|
-
|
|
967
|
+
if (flag === 'both') problems.push(BOTH_MODES_PROBLEM);
|
|
968
|
+
if (problems.length) return nothing(problems);
|
|
969
|
+
let loaded;
|
|
702
970
|
try {
|
|
703
|
-
|
|
971
|
+
loaded = await schemaChanges(site);
|
|
704
972
|
} catch (error) {
|
|
705
|
-
return
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
721
|
-
|
|
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;
|