caracms 0.3.4 → 0.5.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 (66) hide show
  1. package/README.md +73 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +103 -12
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +6 -2
  17. package/src/content.ts +188 -27
  18. package/src/document.ts +13 -2
  19. package/src/editor/README.md +3 -0
  20. package/src/editor/addresses.ts +6 -1
  21. package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
  22. package/src/editor/client/arrange/model.ts +659 -0
  23. package/src/editor/client/arrange/store.ts +335 -0
  24. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  25. package/src/editor/client/documents/actions.ts +75 -19
  26. package/src/editor/client/documents/dialogs.tsx +137 -18
  27. package/src/editor/client/editor.ts +78 -6
  28. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  29. package/src/editor/client/nav/DocRow.tsx +8 -1
  30. package/src/editor/client/nav/tree.ts +26 -1
  31. package/src/editor/client/pane/Pane.tsx +3 -0
  32. package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
  33. package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
  34. package/src/editor/client/pane/parts.tsx +14 -1
  35. package/src/editor/client/shell/Stage.tsx +8 -4
  36. package/src/editor/routes/admin.astro +1 -1
  37. package/src/editor/routes/arrange.ts +17 -0
  38. package/src/editor/routes/move.ts +1 -1
  39. package/src/editor/server/admin-props.ts +24 -4
  40. package/src/editor/server/arrange.ts +245 -0
  41. package/src/editor/server/create.ts +13 -2
  42. package/src/editor/server/drafts-store.ts +55 -10
  43. package/src/editor/server/move.ts +300 -81
  44. package/src/editor/styles/editor.css +5 -0
  45. package/src/editor/styles/levels/arrange.css +152 -0
  46. package/src/editor/styles/levels/collections.css +2 -0
  47. package/src/editor/styles/levels/m5a-documents.css +15 -0
  48. package/src/editor/types.ts +83 -7
  49. package/src/held.ts +116 -0
  50. package/src/index.ts +6 -2
  51. package/src/live/README.md +3 -2
  52. package/src/live/auth/middleware.ts +11 -0
  53. package/src/live/autosaves.ts +88 -1
  54. package/src/live/cloud.ts +131 -0
  55. package/src/live/drafts-store.ts +170 -77
  56. package/src/live/editor.ts +26 -4
  57. package/src/live/git.ts +63 -12
  58. package/src/live/github.ts +69 -7
  59. package/src/live/mode.ts +42 -6
  60. package/src/live/runtime.ts +18 -2
  61. package/src/page-order.ts +253 -0
  62. package/src/redirects.ts +64 -1
  63. package/src/relink.ts +40 -9
  64. package/src/reserved.ts +3 -2
  65. package/src/serialize.ts +18 -1
  66. package/src/slug.ts +15 -4
package/src/live/git.ts CHANGED
@@ -36,9 +36,10 @@
36
36
  import { randomBytes } from 'node:crypto';
37
37
  import { relative, sep } from 'node:path';
38
38
  import { repoPrefix, SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
39
- import { documentUrl, filesSource, type ContentSource } from '../content.ts';
39
+ import { documentUrl, filesSource, heldBasesOf, type ContentSource } from '../content.ts';
40
+ import { withHeldBases, type HeldBase } from '../held.ts';
40
41
  import { confinePath } from '../editor/server/write.ts';
41
- import { movesKey } from './autosaves.ts';
42
+ import { ADDRESS_FIELD, movesKey, splitMoves } from './autosaves.ts';
42
43
  import type { WaitingChange } from '../editor/types.ts';
43
44
  import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, type Comparison, type GitBackend, type Head, type Heads, type TreeChange } from './backend.ts';
44
45
  import { HttpError } from './github.ts';
@@ -721,8 +722,12 @@ export class LiveGit {
721
722
  // A file drafts no longer has is deleted: "Contact (deleted)", its title from main's copy first (M5a Decision 12).
722
723
  const gone = files.filter((f) => this.read(f) === null);
723
724
  const goneTitles = await this.titlesBeforeRemoval(gone, heads.main.commit);
724
- const records = await this.moveRecords();
725
- const moves = this.movesIn(files, records);
725
+ const { records, addresses } = splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
726
+ // Collections under pages: a held document's addresses come from its holder's (only `holds` is read, so no blocks are needed).
727
+ const bases = await heldBasesOf({ ...this.site, blocks: {} }, this.contentSource());
728
+ const moves = this.movesIn(files, records, bases, addresses);
729
+ // A held document whose holder moved keeps its file, unchanged: still one moved item in the headline.
730
+ const readdressed = [...moves.from.keys()].filter((f) => !files.includes(f));
726
731
  // Each file's own title (a "stood" notice names the document by it), then the headline's items.
727
732
  const fileTitles = files.map((f) => {
728
733
  const text = this.read(f);
@@ -734,7 +739,11 @@ export class LiveGit {
734
739
  });
735
740
  // Pre-M7 item 8: a moved document is one item in the headline, "Visit (moved from /studio/visit)",
736
741
  // listed first; its old file is part of that item, not one of its own.
737
- const titles = [...items.filter((_, i) => moves.from.has(files[i]!)), ...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!))];
742
+ const titles = [
743
+ ...items.filter((_, i) => moves.from.has(files[i]!)),
744
+ ...readdressed.map((f) => `${titleOf(f, this.read(f))} (moved from ${moves.from.get(f)})`),
745
+ ...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!)),
746
+ ];
738
747
  const commit = await this.backend.createCommit(heads.drafts.tree, [heads.main.commit], publishMessage(titles, files, by));
739
748
  const moved = await this.moveRefs([
740
749
  // main: a fast-forward onto the squash commit. drafts: not a fast-forward, so
@@ -758,6 +767,23 @@ export class LiveGit {
758
767
  if (cleared) this.statusCache.hash(moves, {}, published);
759
768
  else this.statusCache.drop(moves);
760
769
  }
770
+ // Collections under pages: an address this publish took live is done with; one that
771
+ // changed since (still its live address in what was published) stays waiting.
772
+ const live = withHeldBases(this.site, bases);
773
+ const done = [...addresses].filter(([file, was]) => {
774
+ if (this.read(file) === null) return false;
775
+ const url = this.urlOf(live, file);
776
+ return url !== null && url !== was;
777
+ }).map(([file]) => `${ADDRESS_FIELD}${file}`);
778
+ if (done.length) {
779
+ const key = movesKey(this.keyPrefix);
780
+ const cleared = await this.redis.hdel(key, ...done).then(
781
+ () => true,
782
+ () => false,
783
+ );
784
+ if (cleared) this.statusCache.hash(key, {}, done);
785
+ else this.statusCache.drop(key);
786
+ }
761
787
  const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
762
788
  await this.setPublishRecord(record);
763
789
  this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
@@ -817,13 +843,18 @@ export class LiveGit {
817
843
  }
818
844
 
819
845
  /** Pre-M7 item 8: the documents a publish moves (`movesIn`), over `drafts`' copy. */
820
- private movesIn(files: string[], records: ReadonlyMap<string, string>): Moves {
821
- return movesIn(this.site, files, (f) => this.read(f), records);
846
+ private movesIn(files: string[], records: ReadonlyMap<string, string>, bases: Readonly<Record<string, HeldBase>>, addresses: ReadonlyMap<string, string>): Moves {
847
+ return movesIn(this.site, files, (f) => this.read(f), records, bases, addresses);
822
848
  }
823
849
 
824
- /** Pre-M7 item 8: the moves recorded as they were made (autosaves.ts → `moves`), old file → new file. */
825
- private async moveRecords(): Promise<Map<string, string>> {
826
- return new Map(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
850
+ /** A file's document address on a site with its held bases resolved; null when it isn't a document with one. */
851
+ private urlOf(site: Pick<ResolvedSite, 'root' | 'collections'>, file: string): string | null {
852
+ try {
853
+ const c = confinePath(site as ResolvedSite, file);
854
+ return documentUrl(site.collections[c.collection]!, c.path);
855
+ } catch {
856
+ return null;
857
+ }
827
858
  }
828
859
 
829
860
  /** A file's title for the waiting list and notices: the copy's, else the title it had when it was deleted, else its name. */
@@ -1060,7 +1091,22 @@ export interface Moves {
1060
1091
  * old one gone (`read` null) and the new one there. Publish's headline and the waiting
1061
1092
  * list read it ("Visit (moved from /studio/visit)").
1062
1093
  */
1063
- export function movesIn(site: Pick<ResolvedSite, 'root' | 'collections'>, files: readonly string[], read: (file: string) => string | null, records: ReadonlyMap<string, string>): Moves {
1094
+ export function movesIn(
1095
+ site: Pick<ResolvedSite, 'root' | 'collections'>,
1096
+ files: readonly string[],
1097
+ read: (file: string) => string | null,
1098
+ records: ReadonlyMap<string, string>,
1099
+ /** Collections under pages: the held collections' resolved bases (held.ts), so a held document's move has addresses. */
1100
+ bases: Readonly<Record<string, HeldBase>> = {},
1101
+ /**
1102
+ * Collections under pages: each moved document's live address, by its file now
1103
+ * (autosaves.ts → `recordAddresses`). It wins over one worked out from the old file
1104
+ * (whose holder may have moved too), and names a held document whose holder moved,
1105
+ * whose file stays as it is: that one counts whether or not its file is among `files`.
1106
+ */
1107
+ addresses: ReadonlyMap<string, string> = new Map(),
1108
+ ): Moves {
1109
+ site = withHeldBases(site, bases);
1064
1110
  const from = new Map<string, string>();
1065
1111
  const to = new Map<string, string>();
1066
1112
  const urlOf = (file: string) => {
@@ -1075,12 +1121,17 @@ export function movesIn(site: Pick<ResolvedSite, 'root' | 'collections'>, files:
1075
1121
  for (const [old, moved] of records) {
1076
1122
  if (old === moved || !changed.has(old) || !changed.has(moved) || from.has(moved) || to.has(old)) continue;
1077
1123
  if (read(old) !== null || read(moved) === null) continue;
1078
- const fromUrl = urlOf(old);
1124
+ const fromUrl = addresses.get(moved) ?? urlOf(old);
1079
1125
  const toUrl = urlOf(moved);
1080
1126
  if (!fromUrl || !toUrl) continue;
1081
1127
  from.set(moved, fromUrl);
1082
1128
  to.set(old, toUrl);
1083
1129
  }
1130
+ for (const [file, live] of addresses) {
1131
+ if (from.has(file) || to.has(file) || read(file) === null) continue;
1132
+ const url = urlOf(file);
1133
+ if (url && url !== live) from.set(file, live);
1134
+ }
1084
1135
  return { from, to };
1085
1136
  }
1086
1137
 
@@ -22,9 +22,16 @@
22
22
  * token, an expiry within 14 days. `accessProblem()` says which holds now; a later
23
23
  * success clears the first two.
24
24
  *
25
- * The private key and the token are never logged.
25
+ * - **CaraCMS Cloud** (`CARACMS_CLOUD_KEY`; caracms-cloud M2 Decisions 12–14) mints the
26
+ * installation token instead: `POST /v1/github/token` (cloud.ts), refreshed five minutes
27
+ * early like the App's. A refusal from the cloud is an access problem of kind `cloud`
28
+ * carrying the cloud's sentence (and its install link), cleared by the next token; so is
29
+ * a token answer for another repo than the one the server started on.
30
+ *
31
+ * The private key, the site key and the tokens are never logged.
26
32
  */
27
33
  import { createSign } from 'node:crypto';
34
+ import { CLOUD_REPO_CHANGED, CloudRefusal, cloudToken } from './cloud.ts';
28
35
 
29
36
  const API = 'https://api.github.com';
30
37
 
@@ -50,16 +57,27 @@ export interface TokenConnection extends ConnectionBase {
50
57
  token: string;
51
58
  }
52
59
 
53
- export type GitHubConnection = AppConnection | TokenConnection;
60
+ /** CaraCMS Cloud mints the token (caracms-cloud M2 Decision 12); `fetch` reaches the cloud as well as GitHub. */
61
+ export interface CloudConnection extends ConnectionBase {
62
+ cloudKey: string;
63
+ cloudUrl: string;
64
+ }
65
+
66
+ export type GitHubConnection = AppConnection | TokenConnection | CloudConnection;
67
+
68
+ /** Which credential a client signs in with. */
69
+ export type GitHubCredential = 'app' | 'token' | 'cloud';
54
70
 
55
71
  /**
56
72
  * What's wrong with GitHub access right now (Decision 5), shown in the editor while it holds.
57
73
  * `credential` says which one the site uses, so the sentence names the right fix.
58
74
  */
59
75
  export type GitHubAccessProblem =
60
- | { kind: 'refused'; credential: 'app' | 'token' }
61
- | { kind: 'permission'; credential: 'app' | 'token'; permission: string }
62
- | { kind: 'expiring'; credential: 'token'; expires: string };
76
+ | { kind: 'refused'; credential: GitHubCredential }
77
+ | { kind: 'permission'; credential: GitHubCredential; permission: string }
78
+ | { kind: 'expiring'; credential: 'token'; expires: string }
79
+ /** CaraCMS Cloud refused a token (caracms-cloud M2 Decision 14): its sentence as it came, and its install link. */
80
+ | { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null };
63
81
 
64
82
  /** A token expiring within this long shows in the editor (Decision 5). */
65
83
  export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
@@ -84,11 +102,19 @@ export function accessProblemMessage(problem: GitHubAccessProblem): string {
84
102
  const what = problem.credential === 'token' ? 'GitHub token (CARACMS_GITHUB_TOKEN)' : 'GitHub App';
85
103
  switch (problem.kind) {
86
104
  case 'refused':
105
+ if (problem.credential === 'cloud') {
106
+ return "GitHub refused the token CaraCMS Cloud gave this site: the caracms App may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until it's installed again; ask CaraCMS to check the site.";
107
+ }
87
108
  return problem.credential === 'token'
88
109
  ? `GitHub refused this site's ${what}: it may have expired or been revoked. Changes can't be saved to GitHub until a developer makes a new fine-grained token and sets it.`
89
110
  : `GitHub refused this site's ${what}: it may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until a developer reinstalls it.`;
90
111
  case 'permission':
112
+ if (problem.credential === 'cloud') {
113
+ return `GitHub refused a change because the caracms App, through CaraCMS Cloud, lacks the "${problem.permission}" permission on this site's repo. Ask CaraCMS to check the App.`;
114
+ }
91
115
  return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
116
+ case 'cloud':
117
+ return problem.message;
92
118
  case 'expiring': {
93
119
  const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
94
120
  return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
@@ -206,9 +232,10 @@ export class GitHubClient {
206
232
  readonly owner: string;
207
233
  readonly repo: string;
208
234
  /** Which credential this client signs in with. */
209
- readonly credential: 'app' | 'token';
235
+ readonly credential: GitHubCredential;
210
236
  private readonly appId: string;
211
237
  private readonly key: string;
238
+ private readonly cloud: { key: string; url: string } | null = null;
212
239
  private readonly fetcher: typeof fetch;
213
240
  private readonly now: () => number;
214
241
  private readonly timeoutMs: number;
@@ -219,6 +246,8 @@ export class GitHubClient {
219
246
  private refused = false;
220
247
  private missingPermission: string | null = null;
221
248
  private personalTokenExpires: number | null = null;
249
+ /** The cloud's last refusal, until a token comes. */
250
+ private cloudProblem: { message: string; installUrl: string | null } | null = null;
222
251
 
223
252
  constructor(connection: GitHubConnection, tracker: Tracker) {
224
253
  if ('token' in connection) {
@@ -227,6 +256,11 @@ export class GitHubClient {
227
256
  this.key = '';
228
257
  this.token = connection.token;
229
258
  this.tokenExpires = Number.POSITIVE_INFINITY;
259
+ } else if ('cloudKey' in connection) {
260
+ this.credential = 'cloud';
261
+ this.appId = '';
262
+ this.key = '';
263
+ this.cloud = { key: connection.cloudKey, url: connection.cloudUrl };
230
264
  } else {
231
265
  this.credential = 'app';
232
266
  this.appId = connection.appId;
@@ -242,6 +276,7 @@ export class GitHubClient {
242
276
 
243
277
  /** What's wrong with GitHub access now, the most serious first; null when nothing is (Decision 5). */
244
278
  accessProblem(): GitHubAccessProblem | null {
279
+ if (this.cloudProblem) return { kind: 'cloud', credential: 'cloud', ...this.cloudProblem };
245
280
  if (this.refused) return { kind: 'refused', credential: this.credential };
246
281
  if (this.missingPermission) return { kind: 'permission', credential: this.credential, permission: this.missingPermission };
247
282
  const expires = this.personalTokenExpires;
@@ -297,11 +332,38 @@ export class GitHubClient {
297
332
  this.tokenExpires = Date.parse(tok.expires_at);
298
333
  }
299
334
 
335
+ /**
336
+ * Ask CaraCMS Cloud for a token. A refusal is kept as the access problem (and thrown); a
337
+ * cloud that can't be reached only fails this call, as an unreachable GitHub does.
338
+ */
339
+ private async cloudSignIn(cloud: { key: string; url: string }): Promise<void> {
340
+ let answer;
341
+ try {
342
+ answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs });
343
+ } catch (error) {
344
+ if (error instanceof CloudRefusal && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
345
+ throw error;
346
+ }
347
+ if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {
348
+ const refusal = new CloudRefusal('repo_changed', CLOUD_REPO_CHANGED(answer.repo));
349
+ this.cloudProblem = { message: refusal.message, installUrl: null };
350
+ throw refusal;
351
+ }
352
+ this.cloudProblem = null;
353
+ this.token = answer.token;
354
+ this.tokenExpires = answer.expiresAt;
355
+ }
356
+
357
+ /** Sign in now (the cloud's first token at start, so a refusal stops the start). */
358
+ async ready(): Promise<void> {
359
+ await this.auth();
360
+ }
361
+
300
362
  /** The installation token, refreshed five minutes before it expires; a personal token as it is. */
301
363
  private async auth(): Promise<string> {
302
364
  if (this.credential === 'token') return this.token;
303
365
  if (!this.token || this.now() > this.tokenExpires - 5 * 60_000) {
304
- this.signingIn ??= this.signIn().finally(() => {
366
+ this.signingIn ??= (this.cloud ? this.cloudSignIn(this.cloud) : this.signIn()).finally(() => {
305
367
  this.signingIn = null;
306
368
  });
307
369
  await this.signingIn;
package/src/live/mode.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Which mode the editor runs in, read from the environment (M4 Decision 1).
3
3
  *
4
- * - **Local mode**: `astro dev` with no GitHub credentials (`GITHUB_APP_ID` or
5
- * `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
4
+ * - **Local mode**: `astro dev` with no GitHub credentials (`CARACMS_CLOUD_KEY`,
5
+ * `GITHUB_APP_ID` or `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
6
6
  * - **Live mode**: whenever a credential is configured, and **always on a production
7
7
  * server**. It needs one GitHub credential (a fine-grained token, or the App's
8
8
  * `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`; runs-anywhere Decisions 3–4), the repo
9
9
  * (`GITHUB_REPO=owner/name`, or Railway's `RAILWAY_GIT_REPO_OWNER` and
10
- * `RAILWAY_GIT_REPO_NAME`; Decision 6), `CARACMS_SECRET`, `REDIS_URL` and the site's
10
+ * `RAILWAY_GIT_REPO_NAME`; Decision 6), or CaraCMS Cloud's site key instead of both
11
+ * (`CARACMS_CLOUD_KEY`, with `CARACMS_CLOUD_URL` for another deployment than
12
+ * production; caracms-cloud M2 Decision 11: the repo comes from the cloud at start), `CARACMS_SECRET`, `REDIS_URL` and the site's
11
13
  * `https://` address in production (dev may use the stand-in Redis). A production
12
14
  * server missing any of them still serves the public site; `/admin` answers 503
13
15
  * naming them (Phase 2 builds that from `missing`).
@@ -18,6 +20,7 @@
18
20
  * This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
19
21
  * Phase 3 guards with it).
20
22
  */
23
+ import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
21
24
  import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
22
25
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
23
26
 
@@ -26,6 +29,8 @@ export interface LiveSettings {
26
29
  github:
27
30
  | { kind: 'app'; appId: string; privateKey: string; owner: string; repo: string }
28
31
  | { kind: 'token'; token: string; owner: string; repo: string }
32
+ /** CARACMS_CLOUD_KEY (caracms-cloud M2 Decision 11): the repo comes from the cloud when the runtime starts. */
33
+ | { kind: 'cloud'; key: string; url: string }
29
34
  | { kind: 'memory' };
30
35
  /** Null in dev without REDIS_URL: the in-memory stand-in. */
31
36
  redisUrl: string | null;
@@ -88,6 +93,13 @@ export const NO_ADDRESS_PROBLEM =
88
93
  "The site's public address isn't known: set SITE_URL to it, like https://fernhill.ie, both when the site is built and when it runs. Without it, sign-in links can't be sent and the editor's saves are refused.";
89
94
  export const HTTP_ADDRESS_PROBLEM = (address: string) =>
90
95
  `The site's address (${address}) is http://, but signing in needs https://: the session cookie is only ever sent over https. Set SITE_URL to the https:// address (http:// works for localhost only).`;
96
+ export const CLOUD_KEY_PROBLEM =
97
+ "CARACMS_CLOUD_KEY isn't a CaraCMS Cloud site key: a key is cck_ followed by 43 letters, digits, - and _. Copy it again from app.caracms.com.";
98
+ export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
99
+ `Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
100
+ export const CLOUD_REPO_PROBLEM = 'CARACMS_CLOUD_KEY is set, so the repo comes from CaraCMS Cloud; remove GITHUB_REPO.';
101
+ export const CLOUD_URL_PROBLEM = (url: string) =>
102
+ `CARACMS_CLOUD_URL must be the cloud's https:// address (http:// works for localhost only; got "${url}"). Leave it unset for CaraCMS Cloud itself.`;
91
103
  export const BOTH_REDEPLOYERS_PROBLEM =
92
104
  'Both CARACMS_DEPLOY_HOOK_URL and CARACMS_RAILWAY_TOKEN are set: Try again redeploys through one of them, so set only one.';
93
105
 
@@ -153,7 +165,8 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
153
165
  const memory = get('CARACMS_GITHUB') === 'memory';
154
166
  // Never GITHUB_TOKEN (Decision 4): the gh CLI and GitHub Actions export it.
155
167
  const ghToken = get('CARACMS_GITHUB_TOKEN');
156
- if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken) return { kind: 'local' };
168
+ const cloudKey = get('CARACMS_CLOUD_KEY');
169
+ if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken && !cloudKey) return { kind: 'local' };
157
170
 
158
171
  const missing: string[] = [];
159
172
  const problems: string[] = [];
@@ -168,7 +181,14 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
168
181
  if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
169
182
 
170
183
  let github: LiveSettings['github'] = { kind: 'memory' };
171
- if (!memory || production) {
184
+ if (cloudKey && (!memory || production)) {
185
+ // The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
186
+ if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
187
+ if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
188
+ if (get('GITHUB_APP_ID')) problems.push(CLOUD_AND_GITHUB_PROBLEM('GITHUB_APP_ID'));
189
+ if (get('GITHUB_REPO')) problems.push(CLOUD_REPO_PROBLEM);
190
+ github = { kind: 'cloud', key: cloudKey, url: readCloudUrl(get('CARACMS_CLOUD_URL'), problems) };
191
+ } else if (!memory || production) {
172
192
  const appId = get('GITHUB_APP_ID');
173
193
  const repo = readRepo(env, problems);
174
194
  const owner = repo?.owner ?? '';
@@ -181,7 +201,7 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
181
201
  } else if (appId) {
182
202
  github = { kind: 'app', appId, privateKey: need('GITHUB_APP_PRIVATE_KEY'), owner, repo: name };
183
203
  } else {
184
- missing.push('CARACMS_GITHUB_TOKEN (or GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY)');
204
+ missing.push('CARACMS_CLOUD_KEY (or CARACMS_GITHUB_TOKEN, or GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY)');
185
205
  }
186
206
  if (!repo && !problems.some((p) => p.startsWith('GITHUB_REPO'))) {
187
207
  missing.push(onRailway(env) ? 'RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)' : 'GITHUB_REPO');
@@ -277,6 +297,22 @@ function isBranchName(name: string): boolean {
277
297
  );
278
298
  }
279
299
 
300
+ /** CARACMS_CLOUD_URL, without a trailing slash: https://, or http:// on localhost. Unset, the production address. */
301
+ function readCloudUrl(raw: string | null, problems: string[]): string {
302
+ if (!raw) return DEFAULT_CLOUD_URL;
303
+ let url: URL | null = null;
304
+ try {
305
+ url = new URL(raw);
306
+ } catch {
307
+ /* a problem, below */
308
+ }
309
+ if (!url || !(url.protocol === 'https:' || (url.protocol === 'http:' && isLocalhost(url.hostname)))) {
310
+ problems.push(CLOUD_URL_PROBLEM(raw));
311
+ return raw;
312
+ }
313
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
314
+ }
315
+
280
316
  /** The repo: GITHUB_REPO overrides Railway's own variables (M4 Decision 3; runs-anywhere Decision 6). */
281
317
  function readRepo(env: Env, problems: string[]): { owner: string; repo: string } | null {
282
318
  const override = env.GITHUB_REPO?.trim();
@@ -20,6 +20,7 @@ import { join, relative, sep } from 'node:path';
20
20
  import { repoPrefix, type ResolvedSite } from '../config.ts';
21
21
  import { GitHubBackend, MemoryBackend, type FileWrite, type GitBackend } from './backend.ts';
22
22
  import { DEFAULT_BRANCHES } from './branches.ts';
23
+ import { cloudSettings } from './cloud.ts';
23
24
  import { GitHubClient, Tracker } from './github.ts';
24
25
  import { LiveGit, SITE_SETTINGS_FILE } from './git.ts';
25
26
  import type { LiveSettings } from './mode.ts';
@@ -52,6 +53,8 @@ export interface RuntimeOptions {
52
53
  railwayFetch?: typeof fetch;
53
54
  /** Tests: the deploy hook's fetch. */
54
55
  deployHookFetch?: typeof fetch;
56
+ /** Tests: the fetch GitHub and CaraCMS Cloud are reached through. */
57
+ fetch?: typeof fetch;
55
58
  }
56
59
 
57
60
  /** Build and start a runtime (not shared: tests make their own). */
@@ -72,8 +75,21 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
72
75
  } else {
73
76
  const tracker = new Tracker(now);
74
77
  const gh = settings.github;
75
- const connection = gh.kind === 'token' ? { token: gh.token, owner: gh.owner, repo: gh.repo, now } : { appId: gh.appId, privateKey: gh.privateKey, owner: gh.owner, repo: gh.repo, now };
76
- backend = new GitHubBackend(new GitHubClient(connection, tracker), settings.branches);
78
+ const fetch = options.fetch;
79
+ let client: GitHubClient;
80
+ if (gh.kind === 'cloud') {
81
+ // caracms-cloud M2 Decision 13: the repo from the cloud's settings, then the first
82
+ // token, so a refusal of either stops the start (the sign-in guard shows its sentence).
83
+ const { owner, repo } = await cloudSettings({ key: gh.key, url: gh.url, fetch });
84
+ client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch }, tracker);
85
+ await client.ready();
86
+ log(`live mode on ${owner}/${repo} through CaraCMS Cloud`);
87
+ } else {
88
+ const connection =
89
+ gh.kind === 'token' ? { token: gh.token, owner: gh.owner, repo: gh.repo, now, fetch } : { appId: gh.appId, privateKey: gh.privateKey, owner: gh.owner, repo: gh.repo, now, fetch };
90
+ client = new GitHubClient(connection, tracker);
91
+ }
92
+ backend = new GitHubBackend(client, settings.branches);
77
93
  }
78
94
  }
79
95
  const { main, drafts } = backend.branches;