caracms 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -19
- package/bin/caracms.mjs +11 -3
- package/bin/init.mjs +332 -34
- package/bin/media.mjs +60 -6
- package/bin/templates/railway-cloud.ts.txt +73 -0
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +30 -13
- package/skills/caracms-hosting/SKILL.md +240 -0
- package/skills/caracms-tina/SKILL.md +45 -15
- package/src/body.ts +55 -0
- package/src/cli/validate.ts +1 -1
- package/src/cloud-middleware.ts +51 -5
- package/src/config.ts +20 -1
- package/src/content.ts +15 -6
- package/src/document-problems.ts +48 -0
- package/src/editor/client/collections/CollectionGrid.tsx +2 -2
- package/src/editor/client/collections/CollectionTable.tsx +3 -3
- package/src/editor/client/documents/DocMenu.tsx +56 -1
- package/src/editor/client/documents/actions.ts +30 -1
- package/src/editor/client/documents/dialogs.tsx +61 -3
- package/src/editor/client/editor.ts +98 -2
- package/src/editor/client/fields/LockupField.tsx +4 -4
- package/src/editor/client/inbox/InboxStage.tsx +25 -1
- package/src/editor/client/live/notices.tsx +19 -0
- package/src/editor/client/live/publish.tsx +58 -2
- package/src/editor/client/nav/DocRow.tsx +4 -4
- package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
- package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
- package/src/editor/client/pane/parts.tsx +67 -4
- package/src/editor/client/pane/switcher.tsx +4 -4
- package/src/editor/client/shell/Bar.tsx +11 -0
- package/src/editor/client/state/keeper.ts +28 -4
- package/src/editor/client/state/live.ts +18 -1
- package/src/editor/routes/_live.ts +1 -1
- package/src/editor/routes/admin.astro +9 -0
- package/src/editor/routes/discard.ts +11 -0
- package/src/editor/server/admin-props.ts +18 -2
- package/src/editor/server/arrange.ts +4 -4
- package/src/editor/server/create.ts +4 -4
- package/src/editor/server/drafts-store.ts +2 -2
- package/src/editor/server/empty-site.ts +23 -0
- package/src/editor/server/media/cloud-mux.ts +4 -1
- package/src/editor/server/media/config.ts +12 -5
- package/src/editor/server/media/upload.ts +2 -41
- package/src/editor/server/mode.ts +3 -1
- package/src/editor/server/move.ts +32 -4
- package/src/editor/server/write.ts +4 -4
- package/src/editor/styles/levels/live.css +2 -0
- package/src/editor/types.ts +92 -4
- package/src/email.ts +4 -1
- package/src/forms/cloud-store.ts +4 -1
- package/src/forms/inbox.ts +10 -5
- package/src/forms/store.ts +21 -4
- package/src/index.ts +21 -6
- package/src/live/auth/login.ts +4 -3
- package/src/live/auth/views.ts +8 -1
- package/src/live/autosaves.ts +94 -6
- package/src/live/cloud.ts +64 -20
- package/src/live/drafts-store.ts +17 -6
- package/src/live/editor.ts +232 -16
- package/src/live/git.ts +492 -22
- package/src/live/github.ts +84 -17
- package/src/live/locks.ts +22 -0
- package/src/live/messages.ts +49 -2
- package/src/live/mode.ts +19 -8
- package/src/live/notices.ts +5 -1
- package/src/live/runtime.ts +2 -1
- package/src/live/services.ts +114 -0
- package/src/live/status.ts +5 -0
- package/src/live/sweep.ts +44 -1
- package/src/live/webhook.ts +8 -3
- package/src/media.ts +9 -3
package/src/live/github.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* - **Every request times out** after `timeoutMs` (20 s), its body included: a GitHub
|
|
17
17
|
* that stops answering fails the operation instead of holding it (and Publish's or
|
|
18
|
-
* the check's callers) forever.
|
|
18
|
+
* the check's callers) forever. The cloud's token call is the one exception: 35 s
|
|
19
|
+
* unless `timeoutMs` is given (cloud.ts, `CLOUD_TOKEN_TIMEOUT_MS`).
|
|
19
20
|
*
|
|
20
21
|
* - **Access problems** (Decision 5): every response is watched for a refused credential
|
|
21
22
|
* (401), a write refused for lack of a permission (403 "not accessible"), and, for a
|
|
@@ -25,13 +26,21 @@
|
|
|
25
26
|
* - **CaraCMS Cloud** (`CARACMS_CLOUD_KEY`; caracms-cloud M2 Decisions 12–14) mints the
|
|
26
27
|
* installation token instead: `POST /v1/github/token` (cloud.ts), refreshed five minutes
|
|
27
28
|
* 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
|
|
29
|
-
*
|
|
29
|
+
* carrying the cloud's sentence (and its install link), cleared by the next token.
|
|
30
|
+
* - **A rename or transfer on GitHub** (caracms-cloud adversarial review, finding 7; this
|
|
31
|
+
* retires M2 Decision 13's refusal of a token answer for another repo, whose only way out
|
|
32
|
+
* was a developer restarting the server): the settings are read again, and when they name
|
|
33
|
+
* the new repo too, the client takes that name for the rest of the process. Every path,
|
|
34
|
+
* GraphQL variable and commit asks for it from then on (the backend reads `owner`, `repo`
|
|
35
|
+
* and `nameWithOwner` on each call), a call that was waiting on the token is moved to it,
|
|
36
|
+
* one line is logged and the editor gets an information note (`renamed`) until the next
|
|
37
|
+
* restart. Settings that don't name it yet, or can't be read, fail that refresh as an
|
|
38
|
+
* access problem of kind `cloud`; the next token asks again.
|
|
30
39
|
*
|
|
31
40
|
* The private key, the site key and the tokens are never logged.
|
|
32
41
|
*/
|
|
33
42
|
import { createSign } from 'node:crypto';
|
|
34
|
-
import { CLOUD_REPO_CHANGED, CloudRefusal, isCloudRefusal, cloudToken } from './cloud.ts';
|
|
43
|
+
import { CLOUD_REPO_CHANGED, CloudRefusal, confirmCloudRepo, isCloudRefusal, cloudToken } from './cloud.ts';
|
|
35
44
|
|
|
36
45
|
const API = 'https://api.github.com';
|
|
37
46
|
|
|
@@ -61,6 +70,8 @@ export interface TokenConnection extends ConnectionBase {
|
|
|
61
70
|
export interface CloudConnection extends ConnectionBase {
|
|
62
71
|
cloudKey: string;
|
|
63
72
|
cloudUrl: string;
|
|
73
|
+
/** Finding 7's one line when the repo's new name is taken. Default: the console, as `[caracms] …`. */
|
|
74
|
+
log?: (line: string) => void;
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
export type GitHubConnection = AppConnection | TokenConnection | CloudConnection;
|
|
@@ -77,7 +88,9 @@ export type GitHubAccessProblem =
|
|
|
77
88
|
| { kind: 'permission'; credential: GitHubCredential; permission: string }
|
|
78
89
|
| { kind: 'expiring'; credential: 'token'; expires: string }
|
|
79
90
|
/** 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 }
|
|
91
|
+
| { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null }
|
|
92
|
+
/** Not a problem: information (finding 7). The repo was renamed or transferred, and work carries on under `repo`. */
|
|
93
|
+
| { kind: 'renamed'; credential: 'cloud'; repo: string; was: string };
|
|
81
94
|
|
|
82
95
|
/** A token expiring within this long shows in the editor (Decision 5). */
|
|
83
96
|
export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
|
|
@@ -115,6 +128,8 @@ export function accessProblemMessage(problem: GitHubAccessProblem): string {
|
|
|
115
128
|
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
129
|
case 'cloud':
|
|
117
130
|
return problem.message;
|
|
131
|
+
case 'renamed':
|
|
132
|
+
return `This site's GitHub repo is now ${problem.repo} (it was ${problem.was}). Changes save and publish to it as before; nothing needs doing.`;
|
|
118
133
|
case 'expiring': {
|
|
119
134
|
const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
120
135
|
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.`;
|
|
@@ -229,8 +244,6 @@ function shorten(p: string): string {
|
|
|
229
244
|
|
|
230
245
|
export class GitHubClient {
|
|
231
246
|
readonly tracker: Tracker;
|
|
232
|
-
readonly owner: string;
|
|
233
|
-
readonly repo: string;
|
|
234
247
|
/** Which credential this client signs in with. */
|
|
235
248
|
readonly credential: GitHubCredential;
|
|
236
249
|
private readonly appId: string;
|
|
@@ -239,6 +252,8 @@ export class GitHubClient {
|
|
|
239
252
|
private readonly fetcher: typeof fetch;
|
|
240
253
|
private readonly now: () => number;
|
|
241
254
|
private readonly timeoutMs: number;
|
|
255
|
+
/** The connection's own `timeoutMs` (tests), else undefined: the token call then waits its 35 s (cloud.ts). */
|
|
256
|
+
private readonly givenTimeoutMs: number | undefined;
|
|
242
257
|
private token = '';
|
|
243
258
|
private tokenExpires = 0;
|
|
244
259
|
private signingIn: Promise<void> | null = null;
|
|
@@ -248,6 +263,11 @@ export class GitHubClient {
|
|
|
248
263
|
private personalTokenExpires: number | null = null;
|
|
249
264
|
/** The cloud's last refusal, until a token comes. */
|
|
250
265
|
private cloudProblem: { message: string; installUrl: string | null } | null = null;
|
|
266
|
+
/** The repo, as the server started on it or as a rename made it since (finding 7). */
|
|
267
|
+
private name: { owner: string; repo: string };
|
|
268
|
+
/** The name before the last rename taken, for the editor's note until a restart. */
|
|
269
|
+
private renamedFrom: string | null = null;
|
|
270
|
+
private readonly log: (line: string) => void = (line) => console.log(`[caracms] ${line}`);
|
|
251
271
|
|
|
252
272
|
constructor(connection: GitHubConnection, tracker: Tracker) {
|
|
253
273
|
if ('token' in connection) {
|
|
@@ -261,16 +281,17 @@ export class GitHubClient {
|
|
|
261
281
|
this.appId = '';
|
|
262
282
|
this.key = '';
|
|
263
283
|
this.cloud = { key: connection.cloudKey, url: connection.cloudUrl };
|
|
284
|
+
if (connection.log) this.log = connection.log;
|
|
264
285
|
} else {
|
|
265
286
|
this.credential = 'app';
|
|
266
287
|
this.appId = connection.appId;
|
|
267
288
|
this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
|
|
268
289
|
}
|
|
269
|
-
this.
|
|
270
|
-
this.repo = connection.repo;
|
|
290
|
+
this.name = { owner: connection.owner, repo: connection.repo };
|
|
271
291
|
this.fetcher = connection.fetch ?? fetch;
|
|
272
292
|
this.now = connection.now ?? Date.now;
|
|
273
293
|
this.timeoutMs = connection.timeoutMs ?? GITHUB_TIMEOUT_MS;
|
|
294
|
+
this.givenTimeoutMs = connection.timeoutMs;
|
|
274
295
|
this.tracker = tracker;
|
|
275
296
|
}
|
|
276
297
|
|
|
@@ -283,6 +304,7 @@ export class GitHubClient {
|
|
|
283
304
|
if (this.credential === 'token' && expires !== null && expires - this.now() < TOKEN_EXPIRY_WARNING_MS) {
|
|
284
305
|
return { kind: 'expiring', credential: 'token', expires: new Date(expires).toISOString() };
|
|
285
306
|
}
|
|
307
|
+
if (this.renamedFrom) return { kind: 'renamed', credential: 'cloud', repo: this.nameWithOwner, was: this.renamedFrom };
|
|
286
308
|
return null;
|
|
287
309
|
}
|
|
288
310
|
|
|
@@ -306,6 +328,14 @@ export class GitHubClient {
|
|
|
306
328
|
if (notPermitted && write && !/\/deployments/.test(path)) this.missingPermission = CONTENTS_PERMISSION;
|
|
307
329
|
}
|
|
308
330
|
|
|
331
|
+
get owner() {
|
|
332
|
+
return this.name.owner;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
get repo() {
|
|
336
|
+
return this.name.repo;
|
|
337
|
+
}
|
|
338
|
+
|
|
309
339
|
get nameWithOwner() {
|
|
310
340
|
return `${this.owner}/${this.repo}`;
|
|
311
341
|
}
|
|
@@ -339,15 +369,25 @@ export class GitHubClient {
|
|
|
339
369
|
private async cloudSignIn(cloud: { key: string; url: string }): Promise<void> {
|
|
340
370
|
let answer;
|
|
341
371
|
try {
|
|
342
|
-
|
|
372
|
+
// Seam note (caracms-cloud adversarial review): the token route's worst case passes 20 s, so
|
|
373
|
+
// it gets cloud.ts's 35 s; the settings read below keeps the 20 s every other call has.
|
|
374
|
+
answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.givenTimeoutMs });
|
|
343
375
|
} catch (error) {
|
|
344
376
|
if (isCloudRefusal(error) && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
|
|
345
377
|
throw error;
|
|
346
378
|
}
|
|
347
379
|
if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
380
|
+
// Finding 7: a rename or transfer. Taken only when the settings, read again, name it too.
|
|
381
|
+
const repo = await confirmCloudRepo({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs }, answer.repo);
|
|
382
|
+
if (!repo) {
|
|
383
|
+
const refusal = new CloudRefusal('repo_changed', CLOUD_REPO_CHANGED(answer.repo));
|
|
384
|
+
this.cloudProblem = { message: refusal.message, installUrl: null };
|
|
385
|
+
throw refusal;
|
|
386
|
+
}
|
|
387
|
+
const was = this.nameWithOwner;
|
|
388
|
+
this.name = repo;
|
|
389
|
+
this.renamedFrom = was;
|
|
390
|
+
this.log(`the site's repo is now ${this.nameWithOwner} (was ${was})`);
|
|
351
391
|
}
|
|
352
392
|
this.cloudProblem = null;
|
|
353
393
|
this.token = answer.token;
|
|
@@ -372,18 +412,43 @@ export class GitHubClient {
|
|
|
372
412
|
}
|
|
373
413
|
|
|
374
414
|
async rest<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
|
375
|
-
|
|
415
|
+
const was = this.nameWithOwner;
|
|
416
|
+
const bearer = await this.auth();
|
|
417
|
+
return (await this.request(method, this.movedPath(path, was), body, bearer)) as T;
|
|
376
418
|
}
|
|
377
419
|
|
|
378
420
|
/** A GET whose body is bytes (the tarball). Follows GitHub's redirect to codeload. */
|
|
379
421
|
async bytes(path: string): Promise<Buffer> {
|
|
380
|
-
const
|
|
422
|
+
const was = this.nameWithOwner;
|
|
423
|
+
const bearer = await this.auth();
|
|
424
|
+
path = this.movedPath(path, was);
|
|
425
|
+
const res = await this.fetcher(`${API}${path}`, { headers: this.headers(bearer), redirect: 'follow', signal: this.signal() });
|
|
381
426
|
this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
|
|
382
427
|
this.observe(res, path, false, false);
|
|
383
428
|
if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
|
|
384
429
|
return Buffer.from(await res.arrayBuffer());
|
|
385
430
|
}
|
|
386
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Finding 7: a call's path was made on `was` (its caller read `repoPath()` before the
|
|
434
|
+
* token), and the token it waited for took a new name. It goes to the new name, so no
|
|
435
|
+
* call after a rename leans on GitHub's redirect from the old one.
|
|
436
|
+
*/
|
|
437
|
+
private movedPath(path: string, was: string): string {
|
|
438
|
+
const from = `/repos/${was}`;
|
|
439
|
+
return was !== this.nameWithOwner && (path === from || path.startsWith(`${from}/`) || path.startsWith(`${from}?`)) ? `/repos/${this.nameWithOwner}${path.slice(from.length)}` : path;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The same for GraphQL's variables: an `owner` and `name` pair, or a `repositoryNameWithOwner`, on `was`. */
|
|
443
|
+
private movedVariables(value: unknown, was: string): unknown {
|
|
444
|
+
if (was === this.nameWithOwner || !value || typeof value !== 'object') return value;
|
|
445
|
+
if (Array.isArray(value)) return value.map((v) => this.movedVariables(v, was));
|
|
446
|
+
const o = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.movedVariables(v, was)]));
|
|
447
|
+
if (o.repositoryNameWithOwner === was) o.repositoryNameWithOwner = this.nameWithOwner;
|
|
448
|
+
if (`${o.owner}/${o.name}` === was) Object.assign(o, { owner: this.owner, name: this.repo });
|
|
449
|
+
return o;
|
|
450
|
+
}
|
|
451
|
+
|
|
387
452
|
/** Aborts the request (and the reading of its body) after `timeoutMs`. */
|
|
388
453
|
private signal(): AbortSignal {
|
|
389
454
|
return AbortSignal.timeout(this.timeoutMs);
|
|
@@ -427,11 +492,13 @@ export class GitHubClient {
|
|
|
427
492
|
/** GraphQL as the installation (or the token). Throws GraphQLError when the answer has `errors`. */
|
|
428
493
|
async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
|
|
429
494
|
const isMutation = /^\s*mutation\b/.test(query);
|
|
495
|
+
const was = this.nameWithOwner;
|
|
496
|
+
const bearer = await this.auth();
|
|
430
497
|
const res = await this.fetcher(`${API}/graphql`, {
|
|
431
498
|
method: 'POST',
|
|
432
499
|
// updateRefs worked with this preview header in M1 (G2); whether it still needs it is untested (G6 (f)).
|
|
433
|
-
headers: { ...this.headers(
|
|
434
|
-
body: JSON.stringify({ query, variables }),
|
|
500
|
+
headers: { ...this.headers(bearer, true), accept: 'application/vnd.github.update-refs-preview+json, application/json' },
|
|
501
|
+
body: JSON.stringify({ query, variables: this.movedVariables(variables, was) }),
|
|
435
502
|
signal: this.signal(),
|
|
436
503
|
});
|
|
437
504
|
this.tracker.record({ endpoint: `graphql ${name}`, status: res.status, creating: isMutation });
|
package/src/live/locks.ts
CHANGED
|
@@ -302,6 +302,28 @@ export class Locks {
|
|
|
302
302
|
this.cache?.hash(this.presenceKey(), fields);
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/** The tabs on this document now (polled with it within their record's hold), with their people (Discard changes). */
|
|
306
|
+
async tabsOn(file: string): Promise<{ tab: string; person: Person }[]> {
|
|
307
|
+
const now = this.now();
|
|
308
|
+
const out: { tab: string; person: Person }[] = [];
|
|
309
|
+
for (const [tab, raw] of Object.entries(await this.redis.hgetall(this.presenceKey()))) {
|
|
310
|
+
try {
|
|
311
|
+
const r = JSON.parse(raw) as PresenceRecord;
|
|
312
|
+
const hold = typeof r.hold === 'number' ? r.hold : this.timings.presenceMs;
|
|
313
|
+
if (r.file === file && now - r.at <= hold) out.push({ tab, person: r.person });
|
|
314
|
+
} catch {
|
|
315
|
+
/* skip */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Discard changes: let go of a lock whoever holds it (only while it's still what was read). */
|
|
322
|
+
async drop(read: ReadLock, file: string): Promise<void> {
|
|
323
|
+
if (await landed(this.cache, this.key(file), () => this.redis.compareAndDelete(this.key(file), read.raw))) this.cache?.string(this.key(file), null);
|
|
324
|
+
else this.cache?.drop(this.key(file));
|
|
325
|
+
}
|
|
326
|
+
|
|
305
327
|
/** Who is on which document now (polled with it within the record's hold). Long-gone tabs are pruned. */
|
|
306
328
|
async presence(): Promise<PresenceView[]> {
|
|
307
329
|
return this.presenceFrom(Object.entries(await this.redis.hgetall(this.presenceKey())));
|
package/src/live/messages.ts
CHANGED
|
@@ -31,6 +31,25 @@
|
|
|
31
31
|
*
|
|
32
32
|
* Published-by: Aoife <aoife@fernhill.ie>
|
|
33
33
|
* Published-files: content/pages/index.json, content/pages/about.json
|
|
34
|
+
*
|
|
35
|
+
* Discarding a document's unpublished changes (its files put back as `main` has them):
|
|
36
|
+
*
|
|
37
|
+
* Discarded: About — by Aoife
|
|
38
|
+
*
|
|
39
|
+
* Discarded-by: Aoife <aoife@fernhill.ie>
|
|
40
|
+
* Discarded-files: content/pages/about.json
|
|
41
|
+
*
|
|
42
|
+
* Publish holding documents back (caracms-cloud adversarial review, finding 15): the one
|
|
43
|
+
* commit on `drafts`, on top of the publish, putting back what `main` didn't get. It says
|
|
44
|
+
* again who changed each file, so they're still waiting with who changed them:
|
|
45
|
+
*
|
|
46
|
+
* Held back: About
|
|
47
|
+
*
|
|
48
|
+
* Held-back-files: content/pages/about.json
|
|
49
|
+
* Edited-by: Ciarán <ciaran@fernhill.ie>: content/pages/about.json
|
|
50
|
+
*
|
|
51
|
+
* Reading back, a discard ends what the commits before it said about those files: who
|
|
52
|
+
* changed them and any `Draft-kept` (status.ts → `waitingFromCommits`).
|
|
34
53
|
*/
|
|
35
54
|
|
|
36
55
|
export interface Person {
|
|
@@ -86,6 +105,28 @@ export function mergeMessage(fromMain: string[], keptDraft: string[]): string {
|
|
|
86
105
|
return `Merge main into drafts${lines.length ? `\n\n${lines.join('\n')}` : ''}`;
|
|
87
106
|
}
|
|
88
107
|
|
|
108
|
+
/** A discard's commit on `drafts`: the document's files put back as `main` has them. */
|
|
109
|
+
export function discardMessage(titles: string[], files: string[], by: Person): string {
|
|
110
|
+
return `Discarded: ${titleList(titles)} — by ${displayName(by)}\n\nDiscarded-by: ${ident(by)}\nDiscarded-files: ${files.join(', ')}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Finding 15: Publish's commit on `drafts` putting back the files it held back. `waiting`: who changed each, newest first (status.ts → waitingFromCommits). */
|
|
114
|
+
export function heldMessage(titles: string[], files: string[], waiting: { file: string; editors: { person: Person }[]; keptDraft: boolean }[]): string {
|
|
115
|
+
const byEditor = new Map<string, { person: Person; files: string[] }>();
|
|
116
|
+
// Oldest first, so reading back puts the newest editor in front again.
|
|
117
|
+
for (const w of waiting)
|
|
118
|
+
for (const e of [...w.editors].reverse()) {
|
|
119
|
+
const key = e.person.email.toLowerCase();
|
|
120
|
+
const entry = byEditor.get(key) ?? { person: e.person, files: [] };
|
|
121
|
+
entry.files.push(w.file);
|
|
122
|
+
byEditor.set(key, entry);
|
|
123
|
+
}
|
|
124
|
+
const lines = [`Held-back-files: ${files.join(', ')}`, ...[...byEditor.values()].map(({ person, files }) => `Edited-by: ${ident(person)}: ${files.join(', ')}`)];
|
|
125
|
+
const kept = waiting.filter((w) => w.keptDraft).map((w) => w.file);
|
|
126
|
+
if (kept.length) lines.push(`Draft-kept: ${kept.join(', ')}`);
|
|
127
|
+
return `Held back: ${titleList(titles)}\n\n${lines.join('\n')}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
89
130
|
export function publishMessage(titles: string[], files: string[], by: Person): string {
|
|
90
131
|
return `Published: ${titleList(titles)} — by ${displayName(by)}\n\nPublished-by: ${ident(by)}\nPublished-files: ${files.join(', ')}`;
|
|
91
132
|
}
|
|
@@ -93,7 +134,7 @@ export function publishMessage(titles: string[], files: string[], by: Person): s
|
|
|
93
134
|
// ── Reading them back ─────────────────────────────────────────────────────────
|
|
94
135
|
|
|
95
136
|
export interface ParsedMessage {
|
|
96
|
-
kind: 'draft' | 'merge' | 'publish' | 'other';
|
|
137
|
+
kind: 'draft' | 'merge' | 'publish' | 'discard' | 'other';
|
|
97
138
|
/** Edited-by trailers: who changed which files. */
|
|
98
139
|
edits: { person: Person; files: string[] }[];
|
|
99
140
|
/** Draft-kept: files where the draft won a conflict. */
|
|
@@ -104,6 +145,8 @@ export interface ParsedMessage {
|
|
|
104
145
|
publishedFiles: string[];
|
|
105
146
|
/** The titles in a Draft or Published headline (without "and N more"). */
|
|
106
147
|
titles: string[];
|
|
148
|
+
/** Discarded-files, for a discard: what came before about these files no longer counts. */
|
|
149
|
+
discardedFiles: string[];
|
|
107
150
|
}
|
|
108
151
|
|
|
109
152
|
const list = (value: string) =>
|
|
@@ -123,7 +166,7 @@ const parsePerson = (value: string): Person | null => {
|
|
|
123
166
|
/** Read a message written by the functions above. Anything else parses as `other` with no data. */
|
|
124
167
|
export function parseMessage(message: string): ParsedMessage {
|
|
125
168
|
const [headline = '', ...rest] = message.split('\n');
|
|
126
|
-
const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [] };
|
|
169
|
+
const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [], discardedFiles: [] };
|
|
127
170
|
const titles = (s: string) => list(s.replace(/ and \d+ more$/, ''));
|
|
128
171
|
if (headline.startsWith('Draft: ')) {
|
|
129
172
|
out.kind = 'draft';
|
|
@@ -132,6 +175,9 @@ export function parseMessage(message: string): ParsedMessage {
|
|
|
132
175
|
else if (headline.startsWith('Published: ')) {
|
|
133
176
|
out.kind = 'publish';
|
|
134
177
|
out.titles = titles(headline.slice('Published: '.length).replace(/ — by .*$/, ''));
|
|
178
|
+
} else if (headline.startsWith('Discarded: ')) {
|
|
179
|
+
out.kind = 'discard';
|
|
180
|
+
out.titles = titles(headline.slice('Discarded: '.length).replace(/ — by .*$/, ''));
|
|
135
181
|
}
|
|
136
182
|
for (const line of rest) {
|
|
137
183
|
const m = /^([A-Za-z-]+): (.*)$/.exec(line.trim());
|
|
@@ -146,6 +192,7 @@ export function parseMessage(message: string): ParsedMessage {
|
|
|
146
192
|
} else if (key === 'Draft-kept') out.keptDraft.push(...list(value!));
|
|
147
193
|
else if (key === 'Published-by') out.publishedBy = parsePerson(value!);
|
|
148
194
|
else if (key === 'Published-files') out.publishedFiles = list(value!);
|
|
195
|
+
else if (key === 'Discarded-files' && out.kind === 'discard') out.discardedFiles = list(value!);
|
|
149
196
|
}
|
|
150
197
|
return out;
|
|
151
198
|
}
|
package/src/live/mode.ts
CHANGED
|
@@ -17,12 +17,20 @@
|
|
|
17
17
|
* browser runs, never against the site's own repo. It's live mode with no App and
|
|
18
18
|
* no repo, and it's **refused in production**.
|
|
19
19
|
*
|
|
20
|
+
* - **The config's `services`** (caracms-cloud M5, live/services.ts, baked in as `Baked.services`):
|
|
21
|
+
* with it, the other mode's variables are removed before anything here reads them, so the
|
|
22
|
+
* "not both" sentences below only ever apply to a config without the field (Decision 3);
|
|
23
|
+
* `'cloud'` without the key is `CLOUD_KEY_MISSING_PROBLEM` on a production server (Decision 4).
|
|
24
|
+
*
|
|
20
25
|
* This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
|
|
21
26
|
* Phase 3 guards with it).
|
|
22
27
|
*/
|
|
23
28
|
import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
|
|
24
29
|
import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
|
|
25
30
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
31
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES, servicesEnv, type Services } from './services.ts';
|
|
32
|
+
|
|
33
|
+
export { OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES };
|
|
26
34
|
|
|
27
35
|
export interface LiveSettings {
|
|
28
36
|
/** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
|
|
@@ -81,6 +89,8 @@ export interface Baked {
|
|
|
81
89
|
site?: string | null;
|
|
82
90
|
/** The commit the build was made from (runs-anywhere Decision 9). */
|
|
83
91
|
commit?: string | null;
|
|
92
|
+
/** The config's `services` (caracms-cloud M5 Decision 1; live/services.ts). Null or missing: the key decides (Decision 3). */
|
|
93
|
+
services?: Services | null;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
// The sentences the configuration problems print, kept here so the docs can quote them.
|
|
@@ -97,15 +107,9 @@ export const CLOUD_KEY_PROBLEM =
|
|
|
97
107
|
"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
108
|
export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
|
|
99
109
|
`Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
|
|
100
|
-
/** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
|
|
101
|
-
export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
|
|
102
110
|
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
103
111
|
export const CLOUD_AND_MEDIA_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
|
|
104
|
-
/** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
|
|
105
|
-
export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
|
|
106
112
|
export const CLOUD_AND_MUX_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove ${listed(names)}.`;
|
|
107
|
-
/** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
|
|
108
|
-
export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
|
|
109
113
|
export const CLOUD_AND_FORMS_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove ${listed(names)}.`;
|
|
110
114
|
|
|
111
115
|
/** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
|
|
@@ -121,7 +125,8 @@ export function cloudFormsConflict(env: Env): string | null {
|
|
|
121
125
|
* email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
|
|
122
126
|
* the cloud is *used* is the caller's (only on a production server; the Core framing).
|
|
123
127
|
*/
|
|
124
|
-
export function cloudForms(env: Env): { connection: { key: string; url: string }; problems: string[] } | null {
|
|
128
|
+
export function cloudForms(env: Env, services: Services | null = bakedServices()): { connection: { key: string; url: string }; problems: string[] } | null {
|
|
129
|
+
env = servicesEnv(env, services);
|
|
125
130
|
const get = (name: string) => env[name]?.trim() || null;
|
|
126
131
|
const key = get('CARACMS_CLOUD_KEY');
|
|
127
132
|
if (!key) return null;
|
|
@@ -192,6 +197,9 @@ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.lo
|
|
|
192
197
|
* `baked` is what the build baked in (the address and the commit).
|
|
193
198
|
*/
|
|
194
199
|
export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
|
|
200
|
+
// caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
|
|
201
|
+
const services = baked.services ?? null;
|
|
202
|
+
env = servicesEnv(env, services);
|
|
195
203
|
const get = (name: string) => {
|
|
196
204
|
const v = env[name]?.trim();
|
|
197
205
|
return v ? v : null;
|
|
@@ -215,7 +223,10 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
215
223
|
if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
|
|
216
224
|
|
|
217
225
|
let github: LiveSettings['github'] = { kind: 'memory' };
|
|
218
|
-
if (cloudKey && (!memory || production)) {
|
|
226
|
+
if (services === 'cloud' && !cloudKey && (!memory || production)) {
|
|
227
|
+
// M5 Decision 4: never the self-hosted variables instead (they were removed above).
|
|
228
|
+
problems.push(CLOUD_KEY_MISSING_PROBLEM);
|
|
229
|
+
} else if (cloudKey && (!memory || production)) {
|
|
219
230
|
// The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
|
|
220
231
|
if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
|
|
221
232
|
if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
|
package/src/live/notices.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - **"Published as it stood"** (`caracms:notices`, a hash by notice id): Publish leaves
|
|
6
6
|
* one for each person who had a published document open; dismissing it deletes it.
|
|
7
|
+
* - **"Discarded"** (Discard changes), in the same hash: one for each person who had edits in
|
|
8
|
+
* the document or had it open, but didn't discard it; dismissing it deletes it.
|
|
7
9
|
* - **Dismissed "draft kept" notices** (`caracms:dismissed`, a set of ids): those are
|
|
8
10
|
* shared, worked out from git (`git.keptDraftNotices()`), so a dismissal is remembered
|
|
9
11
|
* here; a new conflict has a new id and shows again.
|
|
@@ -25,6 +27,8 @@ export interface StoodNotice {
|
|
|
25
27
|
title: string;
|
|
26
28
|
by: Person;
|
|
27
29
|
at: string;
|
|
30
|
+
/** Absent: "published as it stood". `discarded`: "Aoife discarded the unpublished changes to About". */
|
|
31
|
+
kind?: 'discarded';
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export class Notices {
|
|
@@ -90,7 +94,7 @@ export class Notices {
|
|
|
90
94
|
for (const raw of stood) {
|
|
91
95
|
try {
|
|
92
96
|
const n = JSON.parse(raw) as StoodNotice;
|
|
93
|
-
if (n.for === email) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
|
|
97
|
+
if (n.for === email) out.push({ id: n.id, kind: n.kind === 'discarded' ? 'discarded' : 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
|
|
94
98
|
} catch {
|
|
95
99
|
/* skip */
|
|
96
100
|
}
|
package/src/live/runtime.ts
CHANGED
|
@@ -93,8 +93,9 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
93
93
|
if (gh.kind === 'cloud') {
|
|
94
94
|
// caracms-cloud M2 Decision 13: the repo from the cloud's settings, then the first
|
|
95
95
|
// token, so a refusal of either stops the start (the sign-in guard shows its sentence).
|
|
96
|
+
// A rename on GitHub after this is taken by the client (adversarial review, finding 7).
|
|
96
97
|
const { owner, repo } = await cloudRepoForStart({ key: gh.key, url: gh.url, fetch });
|
|
97
|
-
client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch }, tracker);
|
|
98
|
+
client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch, log }, tracker);
|
|
98
99
|
await client.ready();
|
|
99
100
|
log(`live mode on ${owner}/${repo} through CaraCMS Cloud`);
|
|
100
101
|
} else {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the site's services come from (caracms-cloud M5): the config's `services` field,
|
|
3
|
+
* not whichever variables happen to be set.
|
|
4
|
+
*
|
|
5
|
+
* - **`'cloud'`**: `CARACMS_CLOUD_KEY` is the one credential. The self-hosted variables
|
|
6
|
+
* (`REPLACED_VARIABLES`) are **removed from the environment the readers see**, so none is
|
|
7
|
+
* ever read, not even as a fallback (Decision 2, check 5), and each is named once per
|
|
8
|
+
* process as ignored.
|
|
9
|
+
* - **`'self-hosted'`**: the site's own variables; `CARACMS_CLOUD_KEY` is removed and named
|
|
10
|
+
* as ignored the same way.
|
|
11
|
+
* - **Unset**: the environment as it is, so the key decides and the "not both" refusals
|
|
12
|
+
* stand (Decision 3).
|
|
13
|
+
*
|
|
14
|
+
* Every key-or-own reader (live/mode.ts `readMode` and `cloudForms`, media.ts, the editor's
|
|
15
|
+
* media config, forms/store.ts, cloud-middleware.ts, and `media import` in bin/media.mjs)
|
|
16
|
+
* starts from `servicesEnv`. The running site's value is baked in at build
|
|
17
|
+
* (`__CARACMS_SERVICES__`, a Vite define from the config: `bakedServices`), since the readers
|
|
18
|
+
* are synchronous and have no config in hand.
|
|
19
|
+
*/
|
|
20
|
+
import type { Services } from '../config.ts';
|
|
21
|
+
|
|
22
|
+
export type { Services };
|
|
23
|
+
type Env = Record<string, string | undefined>;
|
|
24
|
+
|
|
25
|
+
/** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
|
|
26
|
+
export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
|
|
27
|
+
/** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
|
|
28
|
+
export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
|
|
29
|
+
/** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
|
|
30
|
+
export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
|
|
31
|
+
|
|
32
|
+
/** The site's own GitHub variables, which the cloud key replaces (caracms-cloud M2 Decision 11). `GITHUB_WEBHOOK_SECRET` is harmless and not here. */
|
|
33
|
+
export const OWN_GITHUB_VARIABLES = ['CARACMS_GITHUB_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_REPO'] as const;
|
|
34
|
+
|
|
35
|
+
/** Every variable the cloud replaces, by group, in the move list's order of retiring (M5 Contracts). */
|
|
36
|
+
export const REPLACED_VARIABLES = {
|
|
37
|
+
media: OWN_MEDIA_VARIABLES,
|
|
38
|
+
video: OWN_MUX_VARIABLES,
|
|
39
|
+
forms: OWN_FORMS_VARIABLES,
|
|
40
|
+
github: OWN_GITHUB_VARIABLES,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** What each group's ignored line ends with: when it can go. */
|
|
44
|
+
const RETIRE: Record<keyof typeof REPLACED_VARIABLES, string> = {
|
|
45
|
+
media: 'Remove them once the old bucket and imgproxy are retired.',
|
|
46
|
+
video: 'Remove them once every clip is uploaded again in the editor.',
|
|
47
|
+
forms: 'Remove them once the old submissions are exported or no longer wanted.',
|
|
48
|
+
github: 'Remove them: the cloud gives the site its repo and access.',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The sentence Decision 4 fails a production build and `/admin` with. */
|
|
52
|
+
export const CLOUD_KEY_MISSING_PROBLEM = "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com.";
|
|
53
|
+
|
|
54
|
+
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
55
|
+
|
|
56
|
+
/** The ignored line for some of one group's variables (cloud), or for the key (self-hosted). */
|
|
57
|
+
export function ignoredMessage(names: readonly string[], services: Services, group?: keyof typeof REPLACED_VARIABLES): string {
|
|
58
|
+
const one = names.length === 1;
|
|
59
|
+
const set = `${listed(names)} ${one ? 'is' : 'are'} set`;
|
|
60
|
+
if (services === 'self-hosted') return `${set}, but this site is self-hosted (caracms.config.ts): ${one ? "it's" : "they're"} ignored. Remove ${one ? 'it' : 'them'}, or run \`caracms init --cloud\` to move the site to CaraCMS Cloud.`;
|
|
61
|
+
const tail = group ? RETIRE[group] : 'Remove them once the move is done.';
|
|
62
|
+
return `${set}, but this site runs on CaraCMS Cloud (caracms.config.ts): ${one ? "it's" : "they're"} ignored. ${one ? tail.replace(/them/g, 'it') : tail}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const WARNED = Symbol.for('caracms.ignoredVariables');
|
|
66
|
+
type Shared = { [WARNED]?: Set<string> };
|
|
67
|
+
const warned = () => ((globalThis as Shared)[WARNED] ??= new Set());
|
|
68
|
+
|
|
69
|
+
/** Forget which variables were named (tests). */
|
|
70
|
+
export function resetIgnoredWarnings(): void {
|
|
71
|
+
delete (globalThis as Shared)[WARNED];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const set = (env: Env, name: string) => Boolean(env[name]?.trim());
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The environment the readers see for this `services` (null: the environment as it is),
|
|
78
|
+
* with the other mode's variables removed. Each removed variable is named once per process
|
|
79
|
+
* (shared on globalThis, so the build's own module and its bundled chunks warn once between them).
|
|
80
|
+
*/
|
|
81
|
+
export function servicesEnv(env: Env, services: Services | null | undefined, log: (line: string) => void = (line) => console.warn(line)): Env {
|
|
82
|
+
if (!services) return env;
|
|
83
|
+
const seen = warned();
|
|
84
|
+
const out: Env = { ...env };
|
|
85
|
+
if (services === 'self-hosted') {
|
|
86
|
+
if (!set(env, 'CARACMS_CLOUD_KEY')) return env;
|
|
87
|
+
delete out.CARACMS_CLOUD_KEY;
|
|
88
|
+
if (!seen.has('CARACMS_CLOUD_KEY')) log(`[caracms] ${ignoredMessage(['CARACMS_CLOUD_KEY'], services)}`);
|
|
89
|
+
seen.add('CARACMS_CLOUD_KEY');
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
for (const [group, names] of Object.entries(REPLACED_VARIABLES) as [keyof typeof REPLACED_VARIABLES, readonly string[]][]) {
|
|
93
|
+
const found = names.filter((name) => set(env, name));
|
|
94
|
+
for (const name of found) delete out[name];
|
|
95
|
+
const fresh = found.filter((name) => !seen.has(name));
|
|
96
|
+
if (fresh.length) log(`[caracms] ${ignoredMessage(fresh, services, group)}`);
|
|
97
|
+
for (const name of fresh) seen.add(name);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The names `servicesEnv` would ignore in `env` for `services` (init's move list, tests). */
|
|
103
|
+
export function ignoredVariables(env: Env, services: Services | null | undefined): string[] {
|
|
104
|
+
if (!services) return [];
|
|
105
|
+
if (services === 'self-hosted') return set(env, 'CARACMS_CLOUD_KEY') ? ['CARACMS_CLOUD_KEY'] : [];
|
|
106
|
+
return Object.values(REPLACED_VARIABLES).flatMap((names) => names.filter((name) => set(env, name)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
declare const __CARACMS_SERVICES__: Services | null | undefined;
|
|
110
|
+
|
|
111
|
+
/** The config's `services` as the build baked it in; null in plain Node (tests) and for a config without one. */
|
|
112
|
+
export function bakedServices(): Services | null {
|
|
113
|
+
return typeof __CARACMS_SERVICES__ === 'string' ? __CARACMS_SERVICES__ : null;
|
|
114
|
+
}
|
package/src/live/status.ts
CHANGED
|
@@ -29,6 +29,11 @@ export function waitingFromCommits(files: Iterable<string>, commits: CommitSumma
|
|
|
29
29
|
for (const file of files) out.set(file, { file, editors: [], changedAt: null, keptDraft: false });
|
|
30
30
|
for (const commit of commits) {
|
|
31
31
|
const parsed = parseMessage(commit.message);
|
|
32
|
+
// A discard: what came before about its files no longer counts (they matched `main` after it).
|
|
33
|
+
for (const file of parsed.discardedFiles) {
|
|
34
|
+
const w = out.get(file);
|
|
35
|
+
if (w) Object.assign(w, { editors: [], changedAt: null, keptDraft: false });
|
|
36
|
+
}
|
|
32
37
|
for (const edit of parsed.edits) {
|
|
33
38
|
for (const file of edit.files) {
|
|
34
39
|
const w = out.get(file);
|