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.
- package/README.md +73 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +103 -12
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +6 -2
- package/src/content.ts +188 -27
- package/src/document.ts +13 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +75 -19
- package/src/editor/client/documents/dialogs.tsx +137 -18
- package/src/editor/client/editor.ts +78 -6
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +24 -4
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +55 -10
- package/src/editor/server/move.ts +300 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +83 -7
- package/src/held.ts +116 -0
- package/src/index.ts +6 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +170 -77
- package/src/live/editor.ts +26 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +64 -1
- package/src/relink.ts +40 -9
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +18 -1
- 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.
|
|
725
|
-
|
|
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 = [
|
|
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
|
-
/**
|
|
825
|
-
private
|
|
826
|
-
|
|
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(
|
|
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
|
|
package/src/live/github.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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:
|
|
61
|
-
| { kind: 'permission'; credential:
|
|
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:
|
|
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 (`
|
|
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),
|
|
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
|
-
|
|
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('
|
|
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();
|
package/src/live/runtime.ts
CHANGED
|
@@ -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
|
|
76
|
-
|
|
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;
|