caracms 0.3.3 → 0.4.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 +59 -3
- package/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- 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 +51 -10
- 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 +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- 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/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -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 +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -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 +122 -87
- package/src/live/editor.ts +22 -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/redirect-middleware.ts +3 -0
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- package/src/slug.ts +15 -4
package/src/live/drafts-store.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { join, relative, sep } from 'node:path';
|
|
|
35
35
|
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
|
|
36
36
|
import { filesSource, listDocuments, readDocument, resolveDocumentFile } from '../content.ts';
|
|
37
37
|
import { planCreate, planDelete, refused } from '../editor/server/create.ts';
|
|
38
|
-
import {
|
|
38
|
+
import { movesChanges, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest, type MovePlan } from '../editor/server/move.ts';
|
|
39
39
|
import { replaceChanges, replaceResponse } from '../editor/server/replace.ts';
|
|
40
40
|
import { parseAnyDocument } from '../document.ts';
|
|
41
41
|
import type { DraftsStore } from '../editor/server/drafts-store.ts';
|
|
@@ -268,6 +268,122 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
268
268
|
if (!(before && before.lock.tab === tab)) await locks.release(SITE_SETTINGS_FILE, tab, person);
|
|
269
269
|
};
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The many-move engine (collections under pages, Contracts; pre-M7 item 8's move, for any
|
|
273
|
+
* number of documents): the plan is checked as a whole against the listing with every
|
|
274
|
+
* autosave on top (move.ts → `planMoves`), then:
|
|
275
|
+
*
|
|
276
|
+
* - **Every file it writes is this tab's before anything is written** (`takeAll`): each
|
|
277
|
+
* moved document's old and new file, every linking file, Site Settings. Someone else's
|
|
278
|
+
* lock on any one refuses the whole move (nothing is changed), naming them and the
|
|
279
|
+
* document; the mover's own lock in another tab moves to this one. A held document whose
|
|
280
|
+
* file stays (its holder moved) is written, and locked, only when its own links change.
|
|
281
|
+
* - **It lands whole or leaves nothing behind.** Each autosave it stores is noted with the
|
|
282
|
+
* one it replaced; any failure puts them back newest first, compare-and-set against what
|
|
283
|
+
* the move wrote, and releases every lock it took. The new documents first, then the
|
|
284
|
+
* linking files, a file another move vacates written over (a swap), the old files gone,
|
|
285
|
+
* and Site Settings (the one autosave the sweep commits at once) last, so a sweep tick
|
|
286
|
+
* can't commit a redirect for a move that then fails.
|
|
287
|
+
* - **A document that was never live gets no redirect**: one made since the last Publish,
|
|
288
|
+
* or one already moved since then (its live address already redirects, and follows).
|
|
289
|
+
* Each refiled document's move is recorded for Publish ("Moved from"), and every changed
|
|
290
|
+
* address's live one (autosaves.ts → `recordAddresses`), so a held document whose file
|
|
291
|
+
* stays is named too.
|
|
292
|
+
*/
|
|
293
|
+
const moveMany = (request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse> =>
|
|
294
|
+
guard(async () => {
|
|
295
|
+
const { person, tab } = who(context);
|
|
296
|
+
const documents = await listed();
|
|
297
|
+
const plans = planMoves(site, request.moves, documents);
|
|
298
|
+
const source = overlay((await autosaves.all()).map((r) => r.value));
|
|
299
|
+
const addresses = await autosaves.addresses();
|
|
300
|
+
const onMain = (file: string) => git.changeOf(file, '') === 'changed';
|
|
301
|
+
const wasLive = (p: MovePlan) => onMain(p.from.file) && !addresses.has(p.from.file);
|
|
302
|
+
const changes = await movesChanges(site, plans, documents, (file) => source.readText(join(site.root, file)), (p) => request.redirect !== false && wasLive(p), request.editSettings);
|
|
303
|
+
const refiled = plans.filter((p) => p.refiled);
|
|
304
|
+
const vacated = new Set(refiled.map((p) => p.from.file));
|
|
305
|
+
const targets = new Set(refiled.map((p) => p.to.file));
|
|
306
|
+
const files = [...new Set([...refiled.flatMap((p) => [p.from.file, p.to.file]), ...changes.relinked.map((r) => r.file), ...(changes.settings ? [SITE_SETTINGS_FILE] : [])])];
|
|
307
|
+
const root = plans[0]!;
|
|
308
|
+
const movingWith = new Map(plans.filter((p) => !p.refiled).map((p) => [p.from.file, p]));
|
|
309
|
+
|
|
310
|
+
const took: string[] = [];
|
|
311
|
+
const written: Written[] = [];
|
|
312
|
+
try {
|
|
313
|
+
await takeAll(files, tab, person, took, (file, who, mine) => {
|
|
314
|
+
const elsewhere = mine ? ' in another tab' : '';
|
|
315
|
+
const until = `so the address can't change until ${mine ? 'that tab is closed' : "they're done"}.`;
|
|
316
|
+
const from = refiled.find((p) => p.from.file === file);
|
|
317
|
+
if (from) return `${who} editing ${from.title}${elsewhere}, so ${from === root ? 'its' : `${root.title}'s`} address can't change yet.`;
|
|
318
|
+
const to = refiled.find((p) => p.to.file === file);
|
|
319
|
+
if (to) return `${who} working on ${to.to.url} right now, so ${to.title} can't move there yet.`;
|
|
320
|
+
if (file === SITE_SETTINGS_FILE) return `${who} editing Site Settings${elsewhere}, which holds the redirects, ${until}`;
|
|
321
|
+
const name = changes.relinked.find((r) => r.file === file)?.title ?? file;
|
|
322
|
+
return movingWith.has(file) ? `${who} editing ${name}${elsewhere}, which moves with ${root.title}, ${until}` : `${who} editing ${name}${elsewhere}, which links to ${plans.length === 1 ? root.from.url : 'an address that changes'}, ${until}`;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const t = now();
|
|
326
|
+
const at = new Date(t).toISOString();
|
|
327
|
+
// The new documents first, as a create makes one: an autosave with no base in drafts.
|
|
328
|
+
// Made there since the listing was read: it exists. A pending deletion there is replaced.
|
|
329
|
+
for (const { plan, text } of changes.moved) {
|
|
330
|
+
if (vacated.has(plan.to.file)) continue;
|
|
331
|
+
for (let attempt = 1; ; attempt++) {
|
|
332
|
+
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the move.');
|
|
333
|
+
const existing = await autosaves.get(plan.to.file);
|
|
334
|
+
const over = git.read(plan.to.file);
|
|
335
|
+
if ((existing && !existing.value.gone) || (!existing && over !== null)) throw refused(409, 'address-taken', `There's already a page at ${plan.to.url}.`);
|
|
336
|
+
const next: Autosave = { ...saved(plan.to.file, text, plan.to.collection, person, tab, t)(null, null), base: text, created: { over } };
|
|
337
|
+
if (await autosaves.putIf(existing, next)) {
|
|
338
|
+
written.push({ prior: existing, wrote: { raw: JSON.stringify(next), value: next } });
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const r of changes.relinked) written.push(await stash(r.file, tab, saved(r.file, r.text, r.collection, person, tab, t)));
|
|
344
|
+
// A file another move in the plan vacates (a swap) is written over, as an edit.
|
|
345
|
+
for (const { plan, text } of changes.moved) if (vacated.has(plan.to.file)) written.push(await stash(plan.to.file, tab, saved(plan.to.file, text, plan.to.collection, person, tab, t)));
|
|
346
|
+
// Then the old ones go, as a delete removes one.
|
|
347
|
+
for (const plan of refiled) {
|
|
348
|
+
if (targets.has(plan.from.file)) continue;
|
|
349
|
+
const oldTitle = titleOf(plan.from.file, git.read(plan.from.file) ?? (await source.readText(join(site.root, plan.from.file))));
|
|
350
|
+
written.push(
|
|
351
|
+
await stash(plan.from.file, tab, (prior, current) => ({
|
|
352
|
+
file: plan.from.file,
|
|
353
|
+
base: prior ? prior.base : (current ?? ''),
|
|
354
|
+
...carried(prior),
|
|
355
|
+
document: null,
|
|
356
|
+
text: null,
|
|
357
|
+
editor: person,
|
|
358
|
+
tab,
|
|
359
|
+
firstAt: prior?.firstAt ?? t,
|
|
360
|
+
updatedAt: t,
|
|
361
|
+
seq: null,
|
|
362
|
+
...(prior?.created ? { created: prior.created } : {}),
|
|
363
|
+
gone: { title: oldTitle },
|
|
364
|
+
})),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
// The redirects last (see above).
|
|
368
|
+
if (changes.settings) written.push(await stash(SITE_SETTINGS_FILE, tab, saved(SITE_SETTINGS_FILE, changes.settings.after, 'settings', person, tab, t)));
|
|
369
|
+
|
|
370
|
+
const texts: [string, string][] = [...changes.moved.map((m): [string, string] => [m.plan.to.file, m.text]), ...changes.relinked.map((r): [string, string] => [r.file, r.text]), ...(changes.settings ? [[SITE_SETTINGS_FILE, changes.settings.after] as [string, string]] : [])];
|
|
371
|
+
for (const [file, text] of texts) await autosaves.setChanged(file, { by: person, version: fileVersion(text), at });
|
|
372
|
+
for (const plan of refiled) if (!targets.has(plan.from.file)) await autosaves.setChanged(plan.from.file, { by: person, version: '', at, gone: true });
|
|
373
|
+
// The moves themselves, kept until they're published: the waiting list and Publish read them, not the redirects.
|
|
374
|
+
await autosaves.recordMoves(refiled.map((plan) => ({ from: plan.from.file, to: plan.to.file, wasLive: onMain(plan.from.file) })));
|
|
375
|
+
await autosaves.recordAddresses(plans.map((p) => ({ from: p.from.file, to: p.to.file, fromUrl: p.from.url, toUrl: p.to.url, wasLive: onMain(p.from.file) })));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
await undo(written, took, tab, person);
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
// The tab moves on to the asked-for document's new address, keeping its lock there; the others go once committed.
|
|
381
|
+
for (const plan of refiled) if (!targets.has(plan.from.file)) await locks.release(plan.from.file, tab, person, { deleted: true });
|
|
382
|
+
for (const f of took) if (f !== root.to.file && !(vacated.has(f) && !targets.has(f))) await locks.release(f, tab, person);
|
|
383
|
+
sweep.poke();
|
|
384
|
+
return moveResponse(plans, changes);
|
|
385
|
+
});
|
|
386
|
+
|
|
271
387
|
return {
|
|
272
388
|
source: () =>
|
|
273
389
|
guard(async () => overlay((await autosaves.all()).map((r) => r.value))),
|
|
@@ -414,93 +530,12 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
414
530
|
return { ok: true, collection: plan.collection, path: plan.path, file: plan.file, url: plan.url, version };
|
|
415
531
|
}),
|
|
416
532
|
|
|
417
|
-
move: (request, context): Promise<MoveResponse> =>
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const plan = planMove(site, request, documents);
|
|
422
|
-
const source = overlay((await autosaves.all()).map((r) => r.value));
|
|
423
|
-
// A document made since the last Publish was never live, so nobody has its old address: no redirect.
|
|
424
|
-
const wasLive = git.changeOf(plan.from.file, '') === 'changed';
|
|
425
|
-
const changes = await moveChanges(site, plan, documents, (file) => source.readText(join(site.root, file)), wasLive);
|
|
426
|
-
const relinked = changes.relinked.map((r) => r.file);
|
|
427
|
-
const files = [plan.from.file, plan.to.file, ...relinked, ...(changes.settings ? [SITE_SETTINGS_FILE] : [])];
|
|
428
|
-
|
|
429
|
-
// Every file the move writes is this tab's before anything is written. Someone else's
|
|
430
|
-
// lock on one refuses the whole move (nothing is changed), naming them and the document;
|
|
431
|
-
// the mover's own lock in another tab moves to this one (that tab goes read-only, as after Edit here).
|
|
432
|
-
//
|
|
433
|
-
// **A move lands whole or leaves nothing behind.** Each autosave it stores is noted with the
|
|
434
|
-
// one it replaced; any failure after the first (Redis gone, an autosave that kept changing)
|
|
435
|
-
// puts them back newest first, compare-and-set against what the move wrote, and releases
|
|
436
|
-
// every lock the move took, so a retry finds the document where it was. Site Settings (the
|
|
437
|
-
// one autosave the sweep commits at once) is stored last, so a sweep tick can't commit a
|
|
438
|
-
// redirect for a move that then fails.
|
|
439
|
-
const took: string[] = [];
|
|
440
|
-
const written: Written[] = [];
|
|
441
|
-
try {
|
|
442
|
-
await takeAll(files, tab, person, took, (file, who, mine) => {
|
|
443
|
-
const name = file === plan.from.file ? plan.title : file === SITE_SETTINGS_FILE ? 'Site Settings' : (changes.relinked.find((r) => r.file === file)?.title ?? plan.to.url);
|
|
444
|
-
return file === plan.from.file
|
|
445
|
-
? `${who} editing ${name}${mine ? ' in another tab' : ''}, so its address can't change yet.`
|
|
446
|
-
: file === plan.to.file
|
|
447
|
-
? `${who} working on ${plan.to.url} right now, so ${plan.title} can't move there yet.`
|
|
448
|
-
: `${who} editing ${name}${mine ? ' in another tab' : ''}, which ${file === SITE_SETTINGS_FILE ? 'holds the redirects' : `links to ${plan.from.url}`}, so the address can't change until ${mine ? 'that tab is closed' : 'they\'re done'}.`;
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
const t = now();
|
|
452
|
-
const at = new Date(t).toISOString();
|
|
453
|
-
// The new document first, as a create makes one: its autosave, with no base in drafts.
|
|
454
|
-
// Made there since the listing was read: it exists. A pending deletion there is replaced.
|
|
455
|
-
for (let attempt = 1; ; attempt++) {
|
|
456
|
-
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the move.');
|
|
457
|
-
const existing = await autosaves.get(plan.to.file);
|
|
458
|
-
const over = git.read(plan.to.file);
|
|
459
|
-
if ((existing && !existing.value.gone) || (!existing && over !== null)) throw refused(409, 'address-taken', `There's already a page at ${plan.to.url}.`);
|
|
460
|
-
const next: Autosave = { ...saved(plan.to.file, changes.moved, plan.to.collection, person, tab, t)(null, null), base: changes.moved, created: { over } };
|
|
461
|
-
if (await autosaves.putIf(existing, next)) {
|
|
462
|
-
written.push({ prior: existing, wrote: { raw: JSON.stringify(next), value: next } });
|
|
463
|
-
break;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
for (const r of changes.relinked) written.push(await stash(r.file, tab, saved(r.file, r.text, r.collection, person, tab, t)));
|
|
467
|
-
// Then the old one goes, as a delete removes one.
|
|
468
|
-
const oldTitle = titleOf(plan.from.file, git.read(plan.from.file) ?? (await source.readText(join(site.root, plan.from.file))));
|
|
469
|
-
written.push(
|
|
470
|
-
await stash(plan.from.file, tab, (prior, current) => ({
|
|
471
|
-
file: plan.from.file,
|
|
472
|
-
base: prior ? prior.base : (current ?? ''),
|
|
473
|
-
...carried(prior),
|
|
474
|
-
document: null,
|
|
475
|
-
text: null,
|
|
476
|
-
editor: person,
|
|
477
|
-
tab,
|
|
478
|
-
firstAt: prior?.firstAt ?? t,
|
|
479
|
-
updatedAt: t,
|
|
480
|
-
seq: null,
|
|
481
|
-
...(prior?.created ? { created: prior.created } : {}),
|
|
482
|
-
gone: { title: oldTitle },
|
|
483
|
-
})),
|
|
484
|
-
);
|
|
485
|
-
// The redirect last (see above).
|
|
486
|
-
if (changes.settings) written.push(await stash(SITE_SETTINGS_FILE, tab, saved(SITE_SETTINGS_FILE, changes.settings.after, 'settings', person, tab, t)));
|
|
533
|
+
move: async (request, context): Promise<MoveResponse> => {
|
|
534
|
+
const moves = movesUnder(site, request, await listed());
|
|
535
|
+
return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}) }, context);
|
|
536
|
+
},
|
|
487
537
|
|
|
488
|
-
|
|
489
|
-
await autosaves.setChanged(file, { by: person, version: fileVersion(text), at });
|
|
490
|
-
}
|
|
491
|
-
await autosaves.setChanged(plan.from.file, { by: person, version: '', at, gone: true });
|
|
492
|
-
// The move itself, kept until it's published: the waiting list and Publish read it, not the redirect.
|
|
493
|
-
await autosaves.recordMove(plan.from.file, plan.to.file, wasLive);
|
|
494
|
-
} catch (error) {
|
|
495
|
-
await undo(written, took, tab, person);
|
|
496
|
-
throw error;
|
|
497
|
-
}
|
|
498
|
-
// The tab moves on to the new address, keeping its lock there; the others go once committed.
|
|
499
|
-
await locks.release(plan.from.file, tab, person, { deleted: true });
|
|
500
|
-
for (const f of took) if (f !== plan.to.file && f !== plan.from.file) await locks.release(f, tab, person);
|
|
501
|
-
sweep.poke();
|
|
502
|
-
return moveResponse(site, plan, changes);
|
|
503
|
-
}),
|
|
538
|
+
moveMany: (request, context) => moveMany(request, context),
|
|
504
539
|
|
|
505
540
|
/**
|
|
506
541
|
* M6c G4 (Decision 9): Replace… on a file. Every document with a link to the old file
|
package/src/live/editor.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* is the sweep's "am I ticking" and git.ts's caches.
|
|
25
25
|
*/
|
|
26
26
|
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
|
|
27
|
+
import { filesSource, heldBasesOf } from '../content.ts';
|
|
27
28
|
import { brandFrom, editorBrand } from '../editor/brand.ts';
|
|
28
29
|
import type { DraftsStore } from '../editor/server/drafts-store.ts';
|
|
29
30
|
import { handleCreate, handleDelete } from '../editor/server/create.ts';
|
|
@@ -42,7 +43,7 @@ import type {
|
|
|
42
43
|
TryAgainResponse,
|
|
43
44
|
WaitingView,
|
|
44
45
|
} from '../editor/types.ts';
|
|
45
|
-
import { Autosaves } from './autosaves.ts';
|
|
46
|
+
import { Autosaves, splitMoves } from './autosaves.ts';
|
|
46
47
|
import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
|
|
47
48
|
import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
|
|
48
49
|
import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
|
|
@@ -63,6 +64,7 @@ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
|
|
|
63
64
|
refused: 'GitHub refused this site',
|
|
64
65
|
permission: 'GitHub refused a change',
|
|
65
66
|
expiring: "This site's GitHub token expires soon",
|
|
67
|
+
cloud: 'No GitHub access through CaraCMS Cloud',
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
export interface LiveEditor {
|
|
@@ -321,10 +323,24 @@ export async function createLiveEditor(
|
|
|
321
323
|
// Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
|
|
322
324
|
// Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
|
|
323
325
|
const readNow = (f: string) => (textNow.has(f) ? (textNow.get(f) ?? null) : git.read(f));
|
|
324
|
-
|
|
326
|
+
// The copy with every autosave on top: where a holder is now, moved or not committed yet.
|
|
327
|
+
const nowSource = () => {
|
|
328
|
+
const files = new Map(git.files());
|
|
329
|
+
for (const a of saves) {
|
|
330
|
+
if (a.text === null) files.delete(a.file);
|
|
331
|
+
else files.set(a.file, a.text);
|
|
332
|
+
}
|
|
333
|
+
return filesSource(site.root, () => files);
|
|
334
|
+
};
|
|
335
|
+
const { records, addresses } = splitMoves(snap.hash(autosaves.movesKey()));
|
|
336
|
+
// Collections under pages: a held document's addresses come from its holder (read only when something moved).
|
|
337
|
+
const moved = records.size || addresses.size;
|
|
338
|
+
const moves = movesIn(site, [...waiting.keys()], readNow, records, moved ? await heldBasesOf(site, nowSource()) : {}, addresses);
|
|
325
339
|
const redirects = moves.from.size ? redirectsIn(readNow(SITE_SETTINGS_FILE)) : [];
|
|
326
340
|
for (const [file, from] of moves.from) {
|
|
327
|
-
|
|
341
|
+
// A held document whose holder moved keeps its file, unchanged: it waits as a move all the same.
|
|
342
|
+
const w = waiting.get(file) ?? { file, change: 'changed' as const, ...docOf(site, file), title: titleOf(file, readNow(file)), editors: [], changedAt: null, keptDraft: false, uncommitted: false, editingNow: editingNow(file), missingAlt: [] };
|
|
343
|
+
waiting.set(file, w);
|
|
328
344
|
w.movedFrom = from;
|
|
329
345
|
w.movedRedirect = redirects.some((r) => r.from === from);
|
|
330
346
|
}
|
|
@@ -357,7 +373,9 @@ export async function createLiveEditor(
|
|
|
357
373
|
notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
|
|
358
374
|
publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
|
|
359
375
|
// Runs-anywhere Decision 5: while it holds, not dismissable.
|
|
360
|
-
githubAccess: access
|
|
376
|
+
githubAccess: access
|
|
377
|
+
? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access), link: access.kind === 'cloud' ? access.installUrl : null }
|
|
378
|
+
: null,
|
|
361
379
|
// M5a Decision 10: from the committed copy, never an autosave.
|
|
362
380
|
brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
|
|
363
381
|
};
|
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;
|