caracms 0.2.0 → 0.3.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.
Files changed (50) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +64 -20
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +5 -3
  8. package/skills/caracms-adopt/look-same.mjs +8 -4
  9. package/skills/caracms-blocks/SKILL.md +20 -2
  10. package/skills/caracms-tina/SKILL.md +14 -10
  11. package/src/components/Head.astro +3 -2
  12. package/src/content.ts +2 -0
  13. package/src/editor/client/editor.ts +24 -6
  14. package/src/editor/client/live/publish.tsx +20 -10
  15. package/src/editor/client/pane/parts.tsx +34 -2
  16. package/src/editor/client/state/keeper.ts +51 -6
  17. package/src/editor/client/state/live.ts +45 -6
  18. package/src/editor/middleware.ts +3 -2
  19. package/src/editor/routes/try-again.ts +2 -1
  20. package/src/editor/server/admin-props.ts +2 -1
  21. package/src/editor/server/mode.ts +13 -3
  22. package/src/editor/styles/levels/live.css +1 -1
  23. package/src/editor/types.ts +25 -6
  24. package/src/files-route.ts +9 -4
  25. package/src/forms/endpoint.ts +11 -16
  26. package/src/head.ts +10 -1
  27. package/src/index.ts +53 -7
  28. package/src/live/README.md +4 -2
  29. package/src/live/auth/auth.ts +4 -1
  30. package/src/live/auth/guard.ts +3 -2
  31. package/src/live/auth/index.ts +6 -7
  32. package/src/live/auth/login.ts +17 -8
  33. package/src/live/auth/middleware.ts +7 -3
  34. package/src/live/auth/request.ts +92 -11
  35. package/src/live/auth/views.ts +9 -4
  36. package/src/live/autosaves.ts +59 -16
  37. package/src/live/backend.ts +13 -1
  38. package/src/live/drafts-store.ts +4 -3
  39. package/src/live/editor.ts +92 -67
  40. package/src/live/git.ts +112 -34
  41. package/src/live/github.ts +128 -13
  42. package/src/live/locks.ts +92 -26
  43. package/src/live/mode.ts +131 -18
  44. package/src/live/notices.ts +100 -0
  45. package/src/live/redis.ts +161 -39
  46. package/src/live/runtime.ts +11 -3
  47. package/src/live/status-cache.ts +219 -0
  48. package/src/live/status.ts +14 -2
  49. package/src/live/timings.ts +24 -0
  50. package/src/media.ts +26 -1
package/bin/gate.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  // with no flags, so nothing here may import the package's TypeScript.
5
5
  //
6
6
  // Its order, per request:
7
+ // 0. The visitor's IP, from the one source `CARACMS_CLIENT_IP_HEADER` names (or the
8
+ // host's default), set as `x-caracms-client-ip` on every request, replacing any
9
+ // copy the request brought (runs-anywhere spec, Decision 8 and Contracts). The
10
+ // header is trusted only in a process `caracms start` marked (`GATED_MARK`).
7
11
  // 1. `/robots.txt` is answered here, before the password.
8
12
  // 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
9
13
  // API, `/_astro/…` and `/health`.
@@ -11,6 +15,10 @@
11
15
  // 4. The adapter's own handler (static files first, then Astro).
12
16
 
13
17
  import { createHash, timingSafeEqual } from 'node:crypto';
18
+ import { readFileSync } from 'node:fs';
19
+ import { isIP } from 'node:net';
20
+ import { join } from 'node:path';
21
+ import { CLOUDFLARE_IPV4, CLOUDFLARE_IPV6 } from './cloudflare-ips.mjs';
14
22
 
15
23
  /** The header the gate owns. */
16
24
  const ROBOTS_HEADER = 'X-Robots-Tag';
@@ -123,6 +131,60 @@ export function parsePrelaunch(raw) {
123
131
  return { user: value.slice(0, colon) };
124
132
  }
125
133
 
134
+ /**
135
+ * The file the build writes beside the server's entry (`BUILD_INFO_FILE` in the package's
136
+ * src/index, runs-anywhere Decisions 7 and 9): `{ "site": "https://…" | null, "commit": … }`,
137
+ * `site` being Astro's `site` as built. Read by path, since this file imports only built-ins.
138
+ */
139
+ export const BUILD_INFO_FILE = 'caracms-build.json';
140
+
141
+ /**
142
+ * `dist/server/caracms-build.json` under `root`, or null when it's missing, unreadable or
143
+ * not an object (a site built before the file existed): the address chain then starts at
144
+ * `SITE_URL`.
145
+ * @returns {{ site: string | null, commit: string | null } | null}
146
+ */
147
+ export function readBuildInfo(root) {
148
+ let info;
149
+ try {
150
+ info = JSON.parse(readFileSync(join(root, 'dist/server', BUILD_INFO_FILE), 'utf8'));
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (!info || typeof info !== 'object' || Array.isArray(info)) return null;
155
+ const text = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null);
156
+ return { site: text(info.site), commit: text(info.commit) };
157
+ }
158
+
159
+ /**
160
+ * The site's public address at run time, by Decision 7's whole chain: Astro's `site` as
161
+ * built (from `caracms-build.json`: the config's `site`, else `SITE_URL` at build), then
162
+ * `SITE_URL`, then Railway's `RAILWAY_PUBLIC_DOMAIN`. The same order as `addressFrom()` in
163
+ * the package's src/live/mode, which takes the built `site` through a define instead. A
164
+ * built `site` that isn't an http(s) URL is passed over, as `addressFrom()` does.
165
+ * @param {{ site?: string | null } | null} [built] what `readBuildInfo()` read
166
+ * @returns {{ value: string, from: "Astro's site" | 'SITE_URL' | 'RAILWAY_PUBLIC_DOMAIN' } | null}
167
+ */
168
+ export function publicAddress(env = process.env, built = null) {
169
+ const astroSite = typeof built?.site === 'string' ? built.site.trim() : '';
170
+ if (astroSite && parseSiteUrl(astroSite)) return { value: astroSite, from: "Astro's site" };
171
+ const siteUrl = typeof env.SITE_URL === 'string' ? env.SITE_URL.trim() : '';
172
+ if (siteUrl) return { value: siteUrl, from: 'SITE_URL' };
173
+ const railway = typeof env.RAILWAY_PUBLIC_DOMAIN === 'string' ? env.RAILWAY_PUBLIC_DOMAIN.trim() : '';
174
+ if (railway) return { value: `https://${railway}`, from: 'RAILWAY_PUBLIC_DOMAIN' };
175
+ return null;
176
+ }
177
+
178
+ /** Hosts' own addresses, never indexable whatever `SITE_URL` says (Decision 13): the domain itself or any name under it. */
179
+ const PLATFORM_DOMAINS = ['localhost', 'up.railway.app', 'onrender.com', 'fly.dev'];
180
+
181
+ /** True for a hostname a search engine should never index: localhost, a host's platform address, or a bare IP. */
182
+ export function isNeverIndexableHostname(hostname) {
183
+ const name = String(hostname).toLowerCase().replace(/\.+$/, '');
184
+ if (name === '' || isIP(name.replace(/^\[|\]$/g, '')) !== 0) return true;
185
+ return PLATFORM_DOMAINS.some((domain) => name === domain || name.endsWith('.' + domain));
186
+ }
187
+
126
188
  /** `SITE_URL`'s host and origin, or null when it's unset or not an http(s) URL. */
127
189
  export function parseSiteUrl(value) {
128
190
  if (typeof value !== 'string' || value.trim() === '') return null;
@@ -133,19 +195,222 @@ export function parseSiteUrl(value) {
133
195
  return null;
134
196
  }
135
197
  if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
136
- const hostname = url.hostname.toLowerCase();
137
- const indexable = hostname !== 'localhost' && !hostname.endsWith('.localhost') && !hostname.endsWith('.up.railway.app');
198
+ const indexable = !isNeverIndexableHostname(url.hostname);
138
199
  return { host: url.host.toLowerCase(), origin: url.origin, indexable };
139
200
  }
140
201
 
202
+ // ── The visitor's IP (Decision 8) ───────────────────────────────────────────────
203
+
204
+ /** The header the gate sets on every request; `clientIp()` (src/live/auth/request.ts) reads only this in production. */
205
+ export const CLIENT_IP_HEADER = 'x-caracms-client-ip';
206
+
207
+ /**
208
+ * The mark that says this process is behind the gate: `globalThis[Symbol.for('caracms.gated')] === true`.
209
+ * `caracms start` sets it (`markProcessGated()`) before it imports the built site, which
210
+ * runs in this same process, and `clientIp()` (src/live/auth/request.ts, `GATED_MARK`)
211
+ * trusts `x-caracms-client-ip` in production only when it's there. Without it, a server
212
+ * started some other way (`node dist/server/entry.mjs`) has nothing replacing the header,
213
+ * so a visitor could send their own copy and pick their IP for the limits; with the mark
214
+ * off, that server answers 503 instead, whatever the request carries. A request can't set
215
+ * it: it lives in the process, not on the wire.
216
+ */
217
+ export const GATED_MARK = Symbol.for('caracms.gated');
218
+
219
+ /** Marks this process as gated. Only `caracms start` calls it, and only once the adapter's own server is off, so every request reaches the site through the gate. */
220
+ export function markProcessGated() {
221
+ globalThis[GATED_MARK] = true;
222
+ }
223
+
224
+ /** The exact fix, printed when no source is known or the one set isn't allowed. `caracms init` and the hosting docs print the same. */
225
+ export const CLIENT_IP_FIX =
226
+ 'Set CARACMS_CLIENT_IP_HEADER to the header your proxy sets: x-real-ip for nginx with `proxy_set_header X-Real-IP $remote_addr`, x-forwarded-for for Caddy, or socket if nothing sits in front of this server.';
227
+
228
+ /**
229
+ * Where the IP may come from. `socket`: the connection's own address. `x-forwarded-for`:
230
+ * its last entry, the one the nearest proxy added. Any other: a header the nearest proxy
231
+ * sets and overwrites. (Render documents no such header, so it has none here.)
232
+ */
233
+ export const CLIENT_IP_SOURCES = ['socket', 'x-forwarded-for', 'x-real-ip', 'fly-client-ip', 'cf-connecting-ip'];
234
+
235
+ /**
236
+ * Hosts detected from a variable they set, with their default source.
237
+ * - Railway: its edge overwrites X-Real-IP (relied on since M4).
238
+ * - Fly: its proxy sets Fly-Client-IP (docs.fly.io/networking/request-headers) and
239
+ * replaces a forged one (checked against debug.fly.dev, 2026-10-24). Its
240
+ * X-Forwarded-For can't be used: Fly's docs say the last entry is the app's own address.
241
+ * - Render: no overwritten header is documented, so the spec's fallback: X-Forwarded-For's last entry.
242
+ */
243
+ export const HOST_DEFAULTS = [
244
+ { variable: 'RAILWAY_ENVIRONMENT_ID', host: 'Railway', source: 'x-real-ip' },
245
+ { variable: 'FLY_APP_NAME', host: 'Fly', source: 'fly-client-ip' },
246
+ { variable: 'RENDER', host: 'Render', source: 'x-forwarded-for' },
247
+ ];
248
+
249
+ const isSet = (value) => typeof value === 'string' && value.trim() !== '';
250
+
251
+ /**
252
+ * Reads `CARACMS_CLIENT_IP_HEADER`, or the detected host's default. A set value always
253
+ * wins. `problem` is the sentence `caracms start` exits with.
254
+ * @returns {{ source: string, from: string, problem: null } | { source: null, from: null, problem: string }}
255
+ */
256
+ export function readClientIpSource(env = process.env) {
257
+ const raw = env.CARACMS_CLIENT_IP_HEADER;
258
+ if (raw !== undefined && raw.trim() !== '') {
259
+ const source = raw.trim().toLowerCase();
260
+ if (!CLIENT_IP_SOURCES.includes(source)) {
261
+ return { source: null, from: null, problem: `CARACMS_CLIENT_IP_HEADER is "${raw.trim()}", which isn't one caracms can use. ${CLIENT_IP_FIX}` };
262
+ }
263
+ return { source, from: 'CARACMS_CLIENT_IP_HEADER', problem: null };
264
+ }
265
+ const detected = HOST_DEFAULTS.find((h) => isSet(env[h.variable]));
266
+ if (detected) return { source: detected.source, from: `${detected.host}'s default, from ${detected.variable}`, problem: null };
267
+ return { source: null, from: null, problem: `no host was detected, so caracms can't tell where a visitor's IP comes from. ${CLIENT_IP_FIX}` };
268
+ }
269
+
270
+ /** The line `caracms start` prints. */
271
+ export function describeClientIpSource({ source, from }) {
272
+ const where = source === 'socket' ? 'the socket' : source === 'x-forwarded-for' ? "X-Forwarded-For's last entry" : headerName(source);
273
+ return source === 'cf-connecting-ip' ? `${where} (${from})` : `${where} (${from}); CF-Connecting-IP when that address is Cloudflare's, on the site's own domain`;
274
+ }
275
+
276
+ const headerName = (lower) => lower.replace(/(^|-)([a-z])/g, (_, dash, c) => dash + c.toUpperCase()).replace(/\bIp\b/, 'IP').replace(/^Cf-/, 'CF-');
277
+
278
+ /** An address in canonical form (IPv4-mapped IPv6 as IPv4, IPv6 lowercased), or null when it isn't one. Brackets and an IPv4 port are dropped. */
279
+ export function normaliseIp(value) {
280
+ if (typeof value !== 'string') return null;
281
+ let text = value.trim();
282
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(text);
283
+ if (bracketed) text = bracketed[1];
284
+ else if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(text)) text = text.slice(0, text.lastIndexOf(':'));
285
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(text);
286
+ if (mapped) text = mapped[1];
287
+ const kind = isIP(text);
288
+ if (kind === 4) return text;
289
+ if (kind === 6) return text.toLowerCase();
290
+ return null;
291
+ }
292
+
293
+ function ipv4ToInt(ip) {
294
+ return ip.split('.').reduce((n, part) => n * 256 + Number(part), 0);
295
+ }
296
+
297
+ function ipv6ToBigInt(ip) {
298
+ let text = ip;
299
+ // A trailing dotted quad (`::1.2.3.4`) as two groups.
300
+ const quad = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
301
+ if (quad) {
302
+ const n = ipv4ToInt(quad[1]);
303
+ text = text.slice(0, -quad[1].length) + ((n >>> 16) & 0xffff).toString(16) + ':' + (n & 0xffff).toString(16);
304
+ }
305
+ const [head, tail] = text.split('::');
306
+ const headParts = head ? head.split(':') : [];
307
+ const tailParts = tail === undefined ? [] : tail ? tail.split(':') : [];
308
+ const groups = tail === undefined ? headParts : [...headParts, ...Array(8 - headParts.length - tailParts.length).fill('0'), ...tailParts];
309
+ return groups.reduce((n, g) => (n << 16n) + BigInt(parseInt(g, 16)), 0n);
310
+ }
311
+
312
+ const V4_RANGES = CLOUDFLARE_IPV4.map((cidr) => {
313
+ const [base, bits] = cidr.split('/');
314
+ const size = 2 ** (32 - Number(bits));
315
+ const start = Math.floor(ipv4ToInt(base) / size) * size;
316
+ return [start, start + size];
317
+ });
318
+ const V6_RANGES = CLOUDFLARE_IPV6.map((cidr) => {
319
+ const [base, bits] = cidr.split('/');
320
+ const shift = BigInt(128 - Number(bits));
321
+ return [ipv6ToBigInt(base) >> shift, shift];
322
+ });
323
+
324
+ /** True when a normalised address is in Cloudflare's published ranges (cloudflare-ips.mjs). */
325
+ export function isCloudflareIp(ip) {
326
+ const kind = isIP(ip);
327
+ if (kind === 4) {
328
+ const n = ipv4ToInt(ip);
329
+ return V4_RANGES.some(([start, end]) => n >= start && n < end);
330
+ }
331
+ if (kind === 6) {
332
+ const n = ipv6ToBigInt(ip);
333
+ return V6_RANGES.some(([prefix, shift]) => n >> shift === prefix);
334
+ }
335
+ return false;
336
+ }
337
+
338
+ /** The last comma-separated entry of a header (Node joins repeated lines with ", "), normalised. */
339
+ function lastEntry(value) {
340
+ if (Array.isArray(value)) value = value.join(',');
341
+ if (typeof value !== 'string') return null;
342
+ const entries = value.split(',');
343
+ return normaliseIp(entries[entries.length - 1]);
344
+ }
345
+
346
+ /**
347
+ * True when the request's Host is a host's own address (Decision 13's list: localhost,
348
+ * `*.up.railway.app`, `*.onrender.com`, `*.fly.dev`, a bare IP), or missing or unreadable.
349
+ */
350
+ export function isPlatformHost(host) {
351
+ if (typeof host !== 'string' || host.trim() === '') return true;
352
+ let hostname;
353
+ try {
354
+ hostname = new URL(`http://${host.trim()}`).hostname;
355
+ } catch {
356
+ return true;
357
+ }
358
+ return isNeverIndexableHostname(hostname);
359
+ }
360
+
361
+ /**
362
+ * The visitor's IP for one request: from `source`; then, when that address is
363
+ * Cloudflare's and the request's Host isn't a host's own address, from CF-Connecting-IP
364
+ * (kept as the Cloudflare address if that's missing or not an address). `missing` is
365
+ * true when the source header was missing or held no address, so the socket's address
366
+ * was used instead.
367
+ *
368
+ * Why the Host matters: a Cloudflare Worker's fetch to an origin outside Cloudflare
369
+ * leaves from Cloudflare's ranges and may set CF-Connecting-IP to anything
370
+ * (developers.cloudflare.com/fundamentals/reference/http-headers/). So anyone's Worker
371
+ * calling `x.up.railway.app` or `x.fly.dev` directly arrives with a Cloudflare address in
372
+ * the host's header and a CF-Connecting-IP of its choosing. A site behind Cloudflare's
373
+ * proxy is reached at its own domain, where the request passes through the site's zone
374
+ * and Cloudflare sets CF-Connecting-IP itself, a Worker's fetch included. On a host's own
375
+ * address the Cloudflare address is kept: those requests share one bucket, which is
376
+ * what a Worker hammering it deserves.
377
+ * @param {{ headers: Record<string, string | string[] | undefined>, socket?: { remoteAddress?: string } }} req
378
+ */
379
+ export function resolveClientIp(req, source) {
380
+ const socketIp = normaliseIp(req.socket?.remoteAddress) ?? 'unknown';
381
+ let ip = source === 'socket' ? socketIp : lastEntry(req.headers[source]);
382
+ const missing = ip === null;
383
+ if (missing) ip = socketIp;
384
+ if (source !== 'cf-connecting-ip' && isCloudflareIp(ip) && !isPlatformHost(req.headers.host)) {
385
+ const viaCloudflare = lastEntry(req.headers['cf-connecting-ip']);
386
+ if (viaCloudflare) ip = viaCloudflare;
387
+ }
388
+ return { ip, missing };
389
+ }
390
+
391
+ /** Puts the IP on the request as the only `x-caracms-client-ip`, in `headers` and `rawHeaders` alike. */
392
+ function setClientIp(req, ip) {
393
+ req.headers[CLIENT_IP_HEADER] = ip;
394
+ const raw = req.rawHeaders;
395
+ if (Array.isArray(raw)) {
396
+ const kept = [];
397
+ for (let i = 0; i < raw.length; i += 2) if (String(raw[i]).toLowerCase() !== CLIENT_IP_HEADER) kept.push(raw[i], raw[i + 1]);
398
+ kept.push(CLIENT_IP_HEADER, ip);
399
+ raw.splice(0, raw.length, ...kept);
400
+ }
401
+ }
402
+
141
403
  const sha256 = (buf) => createHash('sha256').update(buf).digest();
142
404
 
143
405
  /**
144
406
  * The gate, as `(req, res) => void` around the adapter's `handler`.
145
407
  * @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
146
- * siteUrl?: string, prelaunch?: string, brandName?: string }} options
408
+ * clientIpSource: string, siteUrl?: string, prelaunch?: string, brandName?: string,
409
+ * log?: (line: string) => void }} options
147
410
  */
148
- export function createGate({ handler, siteUrl, prelaunch, brandName }) {
411
+ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, log = (line) => console.error(line) }) {
412
+ if (!CLIENT_IP_SOURCES.includes(clientIpSource)) throw new Error(`createGate: clientIpSource must be one of ${CLIENT_IP_SOURCES.join(', ')}.`);
413
+ let warnedMissing = false;
149
414
  const site = parseSiteUrl(siteUrl);
150
415
  const password = parsePrelaunch(prelaunch) ? sha256(Buffer.from(trimPrelaunch(prelaunch), 'utf8')) : null;
151
416
  const realm = `${cleanRealmName(brandName) || NEUTRAL_REALM_NAME} (not launched yet)`;
@@ -170,6 +435,14 @@ export function createGate({ handler, siteUrl, prelaunch, brandName }) {
170
435
  };
171
436
 
172
437
  const gate = (req, res) => {
438
+ // 0. The visitor's IP, before anything can answer.
439
+ const { ip, missing } = resolveClientIp(req, clientIpSource);
440
+ setClientIp(req, ip);
441
+ if (missing && !warnedMissing) {
442
+ warnedMissing = true;
443
+ log(`caracms start: a request came without ${headerName(clientIpSource)}, so its socket's address was used. If every request says so, CARACMS_CLIENT_IP_HEADER doesn't match the proxy in front. (Said once.)`);
444
+ }
445
+
173
446
  const views = pathViews(req.url);
174
447
  const indexable = isIndexableHost(req);
175
448
 
package/bin/init.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // `caracms init [--write]` (M8 Decisions 7–13): add caracms's plumbing to an existing
2
- // Astro site, and nothing about its design.
1
+ // `caracms init [--write] [--railway]` (M8 Decisions 7–13): add caracms's plumbing to an
2
+ // existing Astro site, and nothing about its design.
3
3
  //
4
4
  // - Run plainly, it prints every file it would add and every edit as a diff, and writes
5
5
  // nothing. `--write` applies exactly that, then writes the schemas (`caracms schemas`).
@@ -8,6 +8,10 @@
8
8
  // writes nothing. One run reports every problem, the git checks included.
9
9
  // - An existing file it can't edit safely (an astro.config it can't follow, an existing
10
10
  // `.railway/railway.ts`) gets the lines to add printed instead, and is left alone.
11
+ // - `.railway/railway.ts` and the `railway` dependency come only with `--railway`, or are
12
+ // left as they are on a site that already has the file (runs-anywhere Decision 12).
13
+ // Otherwise init writes no host file and prints the variables any host needs
14
+ // (HOST_VARIABLES), on the dry run and on `--write`.
11
15
  // - Run again, it re-links the skills, refreshes the CLAUDE.md block and the schemas, and
12
16
  // reports the rest as already done.
13
17
  //
@@ -25,6 +29,41 @@ const templates = fileURLToPath(new URL('./templates/', import.meta.url));
25
29
  const template = (name) => readFileSync(join(templates, name), 'utf8');
26
30
  const ownPackage = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
27
31
 
32
+ /**
33
+ * The variables any host needs (runs-anywhere Decision 12): the one list, printed by init
34
+ * without `--railway`. The package README's Hosting section matches it. Names as
35
+ * `src/live/mode.ts` reads them, and the gate's `CARACMS_CLIENT_IP_HEADER`.
36
+ */
37
+ export const HOST_VARIABLES = [
38
+ { name: 'GITHUB_REPO', need: 'required', what: "The site's repository, as owner/name." },
39
+ { name: 'CARACMS_GITHUB_TOKEN', need: 'required, or the App', what: 'A fine-grained token (github_pat_…) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never GITHUB_TOKEN.' },
40
+ { name: 'GITHUB_APP_ID', need: 'instead of the token', what: "The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both." },
41
+ { name: 'GITHUB_APP_PRIVATE_KEY', need: 'with GITHUB_APP_ID', what: "The App's private key." },
42
+ { name: 'SITE_URL', need: 'required', what: "The site's public address, https://. The same value at build and at run time." },
43
+ { name: 'REDIS_URL', need: 'required', what: "The editor's drafts and sessions: redis:// or rediss:// (Upstash works, in the site's region)." },
44
+ { name: 'CARACMS_SECRET', need: 'required', what: "Signs the editors' sessions. A long random string." },
45
+ { name: 'CARACMS_CLIENT_IP_HEADER', need: 'required off Railway, Fly and Render', what: "Where the visitor's IP comes from: x-real-ip for nginx with `proxy_set_header X-Real-IP $remote_addr`, x-forwarded-for for Caddy, or socket if nothing sits in front of this server. `caracms start` won't start without it." },
46
+ { name: 'CARACMS_ALLOW', need: 'for email sign-in', what: 'Who may sign in, comma-separated: addresses (aoife@fernhill.ie) or whole domains (@fernhill.ie). The client adds their own people in Site Settings.' },
47
+ { name: 'RESEND_API_KEY', need: 'for email sign-in', what: 'Sends the sign-in links (and form notifications) through Resend.' },
48
+ { name: 'FORMS_FROM', need: 'for email sign-in', what: 'The address those emails come from, on a domain verified in Resend.' },
49
+ { name: 'CARACMS_BACKUP_USER', need: 'for the backup sign-in', what: "The backup sign-in, for when email sign-in links can't arrive. Both or neither." },
50
+ { name: 'CARACMS_BACKUP_PASSWORD', need: 'for the backup sign-in', what: "The backup sign-in's password." },
51
+ { name: 'CARACMS_RUNNING_COMMIT', need: 'optional', what: "The commit this server runs, where the host doesn't say (Railway and Render do)." },
52
+ { name: 'CARACMS_DEPLOY_HOOK_URL', need: 'optional', what: "Try again on a publish POSTs here to redeploy the site (the host's deploy hook)." },
53
+ { 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
+ ];
55
+
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];
59
+ const w1 = Math.max(...rows.map((r) => r.name.length));
60
+ const w2 = Math.max(...rows.map((r) => r.need.length));
61
+ 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.",
63
+ ...rows.map((r) => ` ${r.name.padEnd(w1)} ${r.need.padEnd(w2)} ${r.what}`),
64
+ ].join('\n');
65
+ }
66
+
28
67
  /** What init adds to a site's package.json (Decisions 7 and 11). */
29
68
  export const VERSIONS = { node: '^11.1.7', railway: '^3.16.0' };
30
69
 
@@ -295,7 +334,7 @@ function listFiles(dir) {
295
334
  * 'unlink' | 'schemas', file, before?, after?, target? }), notes (printed, nothing written),
296
335
  * and what's already done.
297
336
  */
298
- function changesOf(site) {
337
+ function changesOf(site, { railway = false } = {}) {
299
338
  const { root } = site;
300
339
  const changes = [];
301
340
  const notes = [];
@@ -309,9 +348,10 @@ function changesOf(site) {
309
348
  else changes.push({ kind: before === null ? 'add' : 'edit', file, before, after });
310
349
  };
311
350
 
312
- // package.json: @astrojs/node, `start`, and railway when its file is made.
351
+ // package.json: @astrojs/node, `start`, and railway when its file is made (only with --railway).
313
352
  const railwayFile = '.railway/railway.ts';
314
- const makeRailway = !existsSync(join(root, railwayFile));
353
+ const hasRailway = existsSync(join(root, railwayFile));
354
+ const makeRailway = railway && !hasRailway;
315
355
  {
316
356
  const pkg = structuredClone(site.pkg);
317
357
  pkg.dependencies ??= {};
@@ -341,7 +381,7 @@ function changesOf(site) {
341
381
  const healthFile = `${posix(site.srcDir)}/pages/health.ts`;
342
382
  if (!health) changes.push({ kind: 'add', file: healthFile, after: template('health.ts.txt') });
343
383
  else if (health.file === healthFile && read(join(root, healthFile)) === template('health.ts.txt')) done.push(healthFile);
344
- else notes.push(`The site already has a /health route (${health.file}), so init leaves it alone. Railway's healthcheck asks /health for a 200.`);
384
+ else notes.push(`The site already has a /health route (${health.file}), so init leaves it alone. Hosts' healthchecks (Railway's, for one) ask /health for a 200.`);
345
385
 
346
386
  {
347
387
  const name = titleCase(site.pkg.name ?? basename(root)) || 'Site';
@@ -364,8 +404,11 @@ function changesOf(site) {
364
404
  else edit('tsconfig.json', before, after, 'tsconfig.json (allowImportingTsExtensions)');
365
405
  }
366
406
 
367
- // Railway (Decision 11).
368
- if (makeRailway) {
407
+ // Railway (Decision 11): with --railway, or the site's existing file. Otherwise no host file
408
+ // and report() prints HOST_VARIABLES (runs-anywhere Decision 12).
409
+ if (!railway && !hasRailway) {
410
+ // nothing to write
411
+ } else if (makeRailway) {
369
412
  const remote = git(root, ['remote', 'get-url', 'origin']).out.trim();
370
413
  const repo = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote)?.[1] ?? null;
371
414
  const repoName = basename(git(root, ['rev-parse', '--show-toplevel']).out.trim() || root);
@@ -415,7 +458,7 @@ function changesOf(site) {
415
458
  // MEC trial item 1: what Tina left behind, untracked (tinaOf).
416
459
  const { leftover } = tinaOf(site);
417
460
  if (leftover) notes.push(`${leftover.length ? leftover.join(', ') : 'tina/'} ${leftover.length > 1 ? 'are' : 'is'} left over from Tina (nothing under tina/ is tracked): delete ${leftover.length > 1 ? 'them' : 'it'}.`);
418
- return { changes, notes, done };
461
+ return { changes, notes, done, hostVariables: !railway && !hasRailway };
419
462
  }
420
463
 
421
464
  /** `object` with `key` added, in alphabetical order when the object already is. */
@@ -517,7 +560,7 @@ function railwayNeeds(file) {
517
560
  ' A media bucket: uploaded images. The site gets S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.',
518
561
  ' A submissions bucket: form submissions, apart from the media imgproxy can read. The site gets SUBMISSIONS_S3_BUCKET, SUBMISSIONS_S3_ACCESS_KEY_ID and SUBMISSIONS_S3_SECRET_ACCESS_KEY.',
519
562
  ' imgproxy (ghcr.io/imgproxy/imgproxy), reading only the media bucket: it serves the images, signed. The site gets CARACMS_MEDIA_URL (imgproxy\'s public address), IMGPROXY_KEY and IMGPROXY_SALT (the same as imgproxy\'s).',
520
- ' The site: NODE_ENV=production, SITE_URL (its public address), GITHUB_REPO (owner/name), start command `caracms start`, healthcheck /health; set by hand: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, CARACMS_SECRET, CARACMS_ALLOW, CARACMS_BACKUP_USER, CARACMS_BACKUP_PASSWORD, CARACMS_RAILWAY_TOKEN, MUX_TOKEN_ID, MUX_TOKEN_SECRET, RESEND_API_KEY, FORMS_FROM.',
563
+ ' The site: NODE_ENV=production, SITE_URL (its public address), GITHUB_REPO (owner/name), start command `caracms start`, healthcheck /health; set by hand: CARACMS_GITHUB_TOKEN (a fine-grained token on that one repo; or, instead, the App: GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY), GITHUB_WEBHOOK_SECRET, CARACMS_SECRET, CARACMS_ALLOW, CARACMS_BACKUP_USER, CARACMS_BACKUP_PASSWORD, CARACMS_RAILWAY_TOKEN, MUX_TOKEN_ID, MUX_TOKEN_SECRET, RESEND_API_KEY, FORMS_FROM.',
521
564
  ' The whole of it, as caracms\'s starter writes it: node_modules/caracms/bin/templates/railway.ts.txt.',
522
565
  ].join('\n');
523
566
  }
@@ -608,7 +651,7 @@ function describe(change) {
608
651
  }
609
652
  }
610
653
 
611
- function report({ changes, notes, done }, { write, pm }) {
654
+ function report({ changes, notes, done, hostVariables }, { write, pm }) {
612
655
  const parts = [];
613
656
  if (changes.length === 0) {
614
657
  parts.push('caracms init: nothing to do. caracms is set up here.');
@@ -622,6 +665,7 @@ function report({ changes, notes, done }, { write, pm }) {
622
665
  }
623
666
  if (done.length && changes.length) parts.push(`Already done: ${done.join('; ')}.`);
624
667
  if (notes.length) parts.push(`Notes:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
668
+ if (hostVariables) parts.push(hostVariablesText());
625
669
  if (changes.length) {
626
670
  const install = `${pm} install`;
627
671
  parts.push(
@@ -650,17 +694,17 @@ async function schemaChanges(site) {
650
694
  // ── The command ─────────────────────────────────────────────────────────────────
651
695
 
652
696
  /** The plan: problems (nothing happens when there are any), or the changes. */
653
- export async function plan(root, { write = false } = {}) {
697
+ export async function plan(root, { write = false, railway = false } = {}) {
654
698
  const site = inspect(root);
655
699
  const problems = problemsOf(site, write);
656
- if (problems.length) return { site, problems, changes: [], notes: [], done: [] };
657
- const { changes, notes, done } = changesOf(site);
700
+ if (problems.length) return { site, problems, changes: [], notes: [], done: [], hostVariables: false };
701
+ const { changes, notes, done, hostVariables } = changesOf(site, { railway });
658
702
  try {
659
703
  changes.push(...(await schemaChanges(site)));
660
704
  } catch (error) {
661
- return { site, problems: [`The schemas couldn't be made from caracms.config.ts: ${error.message}`], changes: [], notes: [], done: [] };
705
+ return { site, problems: [`The schemas couldn't be made from caracms.config.ts: ${error.message}`], changes: [], notes: [], done: [], hostVariables: false };
662
706
  }
663
- return { site, problems, changes, notes, done };
707
+ return { site, problems, changes, notes, done, hostVariables };
664
708
  }
665
709
 
666
710
  function apply(root, changes) {
@@ -686,11 +730,11 @@ function apply(root, changes) {
686
730
  }
687
731
 
688
732
  /**
689
- * `caracms init [--write]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
690
- * @param {{ root: string, write?: boolean, out?: { write(text: string): unknown }, err?: { write(text: string): unknown } }} options
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
691
735
  */
692
- export async function run({ root, write = false, out = process.stdout, err = process.stderr }) {
693
- const p = await plan(root, { write });
736
+ export async function run({ root, write = false, railway = false, out = process.stdout, err = process.stderr }) {
737
+ const p = await plan(root, { write, railway });
694
738
  if (p.problems.length) {
695
739
  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`);
696
740
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -69,10 +69,12 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
69
69
  it), and `schemas/`;
70
70
  - `astro.config` edits: the Node adapter (standalone), `integrations: [caracms(site)]`,
71
71
  `devToolbar: { enabled: false }`;
72
- - `package.json`: `@astrojs/node`, `railway` (dev), `"start": "caracms start"`;
72
+ - `package.json`: `@astrojs/node`, `"start": "caracms start"`;
73
73
  `.gitignore`: `.caracms/`; the tsconfig's `allowImportingTsExtensions`;
74
- - `.railway/railway.ts` (when the site had none), the skill links and a block in
75
- `CLAUDE.md`.
74
+ - the skill links and a block in `CLAUDE.md`;
75
+ - with `--railway` only, `.railway/railway.ts` (when the site had none) and `railway`
76
+ (dev). Without it, `init` prints the variables any host needs instead. Where and how
77
+ the site runs (Railway, Render, Fly, a VPS) is the caracms README's Hosting section.
76
78
 
77
79
  Then `astro build`: every existing page must still build. The pages stay prerendered, but
78
80
  the output moves to `dist/client/` and `dist/server/`, and `npm start` is now
@@ -110,7 +110,7 @@
110
110
  // Exit codes: 0 when the report is written (whatever the scores, statuses or image caps), 1 for
111
111
  // a usage error, 2 when the report is written but some page couldn't be fetched at all.
112
112
 
113
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
113
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
114
114
  import { createRequire } from 'node:module';
115
115
  import { join, resolve } from 'node:path';
116
116
  import { pathToFileURL } from 'node:url';
@@ -142,7 +142,9 @@ function parseArgs(argv) {
142
142
  }
143
143
 
144
144
  function readPages(spec) {
145
- const lines = existsSync(spec) ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
145
+ // A list file only when it's a file: `--pages /` is the home page, not the disk's root.
146
+ const isFile = existsSync(spec) && statSync(spec).isFile();
147
+ const lines = isFile ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
146
148
  return lines
147
149
  .map((line) => line.replace(/#.*/, '').trim())
148
150
  .filter(Boolean)
@@ -337,11 +339,13 @@ async function shoot(browser, url, width, imageWaitMs, css) {
337
339
  const bottom = document.documentElement.scrollHeight - window.innerHeight;
338
340
  if (y >= bottom) break;
339
341
  y = Math.min(y + Math.round(window.innerHeight * 0.75), bottom);
340
- window.scrollTo(0, y);
342
+ // Instant, whatever the page's own `scroll-behavior`: a smooth scroll still moving at
343
+ // the shot puts a sticky header somewhere else each run (Urban Return port).
344
+ window.scrollTo({ top: y, behavior: 'instant' });
341
345
  await pause(120);
342
346
  }
343
347
  await pause(300);
344
- window.scrollTo(0, 0);
348
+ window.scrollTo({ top: 0, behavior: 'instant' });
345
349
  });
346
350
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
347
351
  await page.evaluate(() => document.fonts.ready);
@@ -272,8 +272,26 @@ editor's preview alike. It gets `PageProps` (`caracms/document`): `document`,
272
272
  collection's component).
273
273
  - `document.kind === 'shared'` is a shared block's own preview: render it without the
274
274
  header and footer.
275
- - `<Media>` needs a block's `cms`, so images in the page's frame (a logo) stay the site's
276
- own files.
275
+ - **An image in the page's frame** (a header logo, a mega-menu photo, a footer badge) is a
276
+ `media()` field in Site Settings, drawn with `settingsImageUrl` from `caracms/content`
277
+ (`<Media>` needs a block's `cms`, which the frame doesn't have):
278
+
279
+ ```astro
280
+ ---
281
+ import { settingsImageUrl, siteSettings } from 'caracms/content';
282
+ import { site } from 'virtual:caracms/config';
283
+ const settings = await siteSettings(site, Astro.locals);
284
+ const photo = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
285
+ ---
286
+ <div class="mega" data-image={photo ?? '/images/placeholder.svg'}></div>
287
+ ```
288
+
289
+ It gives one URL by `<Media>`'s rules: one side is a `fit`, both are a `fill` cropped to
290
+ the focal point, a GIF or an SVG passes through unresized, no size is a `fit` at 1920. Ask
291
+ for the size it's shown at (twice it for a sharp 2× screen); there's no `srcset`. It's
292
+ null when the field is empty or the media isn't set up, so give the frame a fallback. It
293
+ works in the build, in the editor's preview (the draft's image) and under
294
+ `CARACMS_MEDIA=memory`.
277
295
 
278
296
  ## Forms
279
297
 
@@ -55,8 +55,9 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
55
55
  before anything else changes. If the site pins its own `vite`, bump it with Astro:
56
56
  left behind, it leaves a second Vite in the tree and `astro check` fails in the Astro
57
57
  config. **Bump `.nvmrc` and `engines.node` in `package.json` with Astro** (Astro 7.4
58
- needs Node 22.23.4 or later, and so does caracms): Railway builds on the older Node
59
- and fails with exit 1 and no error text in the build log.
58
+ needs Node 22.23.4 or later, and so does caracms): a host that reads them builds on
59
+ the older Node otherwise (Railway fails with exit 1 and no error text in the build
60
+ log).
60
61
  2. **Strip Tina** (section 3): `tina/`, `server.mjs`, `server/`, Tina's routes and admin,
61
62
  **including the untracked `tina/__generated__/` and `public/admin/`** (they're in
62
63
  `.gitignore`; delete them from disk too, or `init` and the build trip on them).
@@ -65,11 +66,12 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
65
66
  `caracms start` serves `dist/server/entry.mjs` and refuses any other mode.
66
67
  5. **`npx caracms init`**, read the dry run, then **`npx caracms init --write`** and install
67
68
  again (caracms-adopt → section 1). It links the four skills, writes the config, the
68
- CLAUDE.md block, the health route and, on a site without one, `.railway/railway.ts`.
69
- **A template site already has a `.railway/railway.ts` that describes production: leave
70
- it alone and never apply it** (applying it can switch production's CDN off). `init`
71
- leaves an existing one untouched, which is the expected outcome. Set the branch
72
- environment up with the Railway CLI or MCP, scoped to that environment (section 9).
69
+ CLAUDE.md block and the health route. **A template site already has a
70
+ `.railway/railway.ts` that describes production: leave it alone and never apply it**
71
+ (applying it can switch production's CDN off). `init` treats a site that has one as
72
+ a Railway site and leaves the file untouched, which is the expected outcome. Set the
73
+ branch environment up with the Railway CLI or MCP, scoped to that environment
74
+ (section 9).
73
75
  6. **Copy the script and run it** (section 2) against a **built** checkout of `main`.
74
76
  First run with `--scaffold-config` for a starting `caracms.config.ts`. It names a
75
77
  component per block before any is written, so nothing builds until they exist: point
@@ -365,7 +367,9 @@ conversion: caracms's inbox reads the template's records as they are (`<id>.json
365
367
 
366
368
  ## 9. The branch environment
367
369
 
368
- The port is deployed from its branch to its own environment, **never production**:
370
+ The port is deployed from its branch to its own environment, **never production**. The
371
+ template sites run on Railway, so this section is written for it. A site on another host
372
+ needs the same pieces there (the caracms README's Hosting section has each host):
369
373
 
370
374
  - A new environment in the site's Railway project (`caracms`), with its own `web`, Redis, a
371
375
  signed imgproxy and its own submissions bucket. It reads the live media bucket (caracms
@@ -378,8 +382,8 @@ The port is deployed from its branch to its own environment, **never production*
378
382
  commits to the port branch and redeploys the environment, and the live site's `main`
379
383
  never moves. With the defaults, the first Publish would land caracms's content on the
380
384
  live site's `main`.
381
- - A GitHub App with Contents read/write on the site's repo, its variables on the
382
- environment (as for any caracms site).
385
+ - A GitHub credential on the environment, as for any caracms site: a fine-grained token
386
+ on the site's repo, or the App (the caracms README → Hosting → GitHub).
383
387
  - Convert with `--recipients <agency address>`, so test submissions never reach the client.
384
388
  - **Never `railway config apply` against production**: production's variables are set
385
389
  with `railway variables --set` (a template's Railway file can switch its CDN off).