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.
Files changed (55) hide show
  1. package/README.md +59 -3
  2. package/bin/caracms.mjs +30 -6
  3. package/bin/gate.mjs +157 -6
  4. package/bin/templates/catch-all.astro.txt +10 -9
  5. package/package.json +1 -1
  6. package/skills/caracms-adopt/SKILL.md +21 -4
  7. package/skills/caracms-blocks/SKILL.md +36 -13
  8. package/skills/caracms-content/SKILL.md +27 -6
  9. package/skills/caracms-tina/SKILL.md +53 -11
  10. package/skills/caracms-tina/field-kinds.md +2 -0
  11. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  12. package/src/cli/validate.ts +51 -10
  13. package/src/code-routes.ts +26 -0
  14. package/src/components/Head.astro +5 -5
  15. package/src/components/RenderDocument.astro +3 -3
  16. package/src/components/sitemap.xml.ts +5 -4
  17. package/src/config.ts +57 -11
  18. package/src/content-schema.ts +5 -2
  19. package/src/content.ts +148 -8
  20. package/src/document.ts +8 -0
  21. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  22. package/src/editor/client/documents/actions.ts +61 -16
  23. package/src/editor/client/documents/dialogs.tsx +104 -15
  24. package/src/editor/client/editor.ts +22 -1
  25. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  26. package/src/editor/client/nav/DocRow.tsx +8 -1
  27. package/src/editor/client/nav/tree.ts +26 -1
  28. package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
  29. package/src/editor/client/pane/parts.tsx +14 -1
  30. package/src/editor/routes/move.ts +1 -1
  31. package/src/editor/server/admin-props.ts +19 -1
  32. package/src/editor/server/create.ts +13 -2
  33. package/src/editor/server/drafts-store.ts +24 -9
  34. package/src/editor/server/move.ts +266 -81
  35. package/src/editor/styles/editor.css +5 -0
  36. package/src/editor/styles/levels/collections.css +2 -0
  37. package/src/editor/styles/levels/m5a-documents.css +15 -0
  38. package/src/editor/types.ts +31 -5
  39. package/src/held.ts +116 -0
  40. package/src/index.ts +4 -2
  41. package/src/live/README.md +3 -2
  42. package/src/live/auth/middleware.ts +11 -0
  43. package/src/live/autosaves.ts +88 -1
  44. package/src/live/cloud.ts +131 -0
  45. package/src/live/drafts-store.ts +122 -87
  46. package/src/live/editor.ts +22 -4
  47. package/src/live/git.ts +63 -12
  48. package/src/live/github.ts +69 -7
  49. package/src/live/mode.ts +42 -6
  50. package/src/live/runtime.ts +18 -2
  51. package/src/redirect-middleware.ts +3 -0
  52. package/src/redirects.ts +32 -1
  53. package/src/relink.ts +40 -9
  54. package/src/serialize.ts +1 -1
  55. package/src/slug.ts +15 -4
@@ -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 { moveChanges, moveResponse, planMove, settingsWithoutRedirectFrom } from '../editor/server/move.ts';
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
- guard(async () => {
419
- const { person, tab } = who(context);
420
- const documents = await listed();
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
- for (const [file, text] of [[plan.to.file, changes.moved], ...changes.relinked.map((r) => [r.file, r.text]), ...(changes.settings ? [[SITE_SETTINGS_FILE, changes.settings.after]] : [])] as [string, string][]) {
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
@@ -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
- const moves = movesIn(site, [...waiting.keys()], readNow, new Map(snap.hash(autosaves.movesKey())));
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
- const w = waiting.get(file)!;
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 ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access) } : null,
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.moveRecords();
725
- const moves = this.movesIn(files, records);
725
+ const { records, addresses } = splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
726
+ // Collections under pages: a held document's addresses come from its holder's (only `holds` is read, so no blocks are needed).
727
+ const bases = await heldBasesOf({ ...this.site, blocks: {} }, this.contentSource());
728
+ const moves = this.movesIn(files, records, bases, addresses);
729
+ // A held document whose holder moved keeps its file, unchanged: still one moved item in the headline.
730
+ const readdressed = [...moves.from.keys()].filter((f) => !files.includes(f));
726
731
  // Each file's own title (a "stood" notice names the document by it), then the headline's items.
727
732
  const fileTitles = files.map((f) => {
728
733
  const text = this.read(f);
@@ -734,7 +739,11 @@ export class LiveGit {
734
739
  });
735
740
  // Pre-M7 item 8: a moved document is one item in the headline, "Visit (moved from /studio/visit)",
736
741
  // listed first; its old file is part of that item, not one of its own.
737
- const titles = [...items.filter((_, i) => moves.from.has(files[i]!)), ...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!))];
742
+ const titles = [
743
+ ...items.filter((_, i) => moves.from.has(files[i]!)),
744
+ ...readdressed.map((f) => `${titleOf(f, this.read(f))} (moved from ${moves.from.get(f)})`),
745
+ ...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!)),
746
+ ];
738
747
  const commit = await this.backend.createCommit(heads.drafts.tree, [heads.main.commit], publishMessage(titles, files, by));
739
748
  const moved = await this.moveRefs([
740
749
  // main: a fast-forward onto the squash commit. drafts: not a fast-forward, so
@@ -758,6 +767,23 @@ export class LiveGit {
758
767
  if (cleared) this.statusCache.hash(moves, {}, published);
759
768
  else this.statusCache.drop(moves);
760
769
  }
770
+ // Collections under pages: an address this publish took live is done with; one that
771
+ // changed since (still its live address in what was published) stays waiting.
772
+ const live = withHeldBases(this.site, bases);
773
+ const done = [...addresses].filter(([file, was]) => {
774
+ if (this.read(file) === null) return false;
775
+ const url = this.urlOf(live, file);
776
+ return url !== null && url !== was;
777
+ }).map(([file]) => `${ADDRESS_FIELD}${file}`);
778
+ if (done.length) {
779
+ const key = movesKey(this.keyPrefix);
780
+ const cleared = await this.redis.hdel(key, ...done).then(
781
+ () => true,
782
+ () => false,
783
+ );
784
+ if (cleared) this.statusCache.hash(key, {}, done);
785
+ else this.statusCache.drop(key);
786
+ }
761
787
  const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
762
788
  await this.setPublishRecord(record);
763
789
  this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
@@ -817,13 +843,18 @@ export class LiveGit {
817
843
  }
818
844
 
819
845
  /** Pre-M7 item 8: the documents a publish moves (`movesIn`), over `drafts`' copy. */
820
- private movesIn(files: string[], records: ReadonlyMap<string, string>): Moves {
821
- return movesIn(this.site, files, (f) => this.read(f), records);
846
+ private movesIn(files: string[], records: ReadonlyMap<string, string>, bases: Readonly<Record<string, HeldBase>>, addresses: ReadonlyMap<string, string>): Moves {
847
+ return movesIn(this.site, files, (f) => this.read(f), records, bases, addresses);
822
848
  }
823
849
 
824
- /** Pre-M7 item 8: the moves recorded as they were made (autosaves.ts → `moves`), old file → new file. */
825
- private async moveRecords(): Promise<Map<string, string>> {
826
- return new Map(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
850
+ /** A file's document address on a site with its held bases resolved; null when it isn't a document with one. */
851
+ private urlOf(site: Pick<ResolvedSite, 'root' | 'collections'>, file: string): string | null {
852
+ try {
853
+ const c = confinePath(site as ResolvedSite, file);
854
+ return documentUrl(site.collections[c.collection]!, c.path);
855
+ } catch {
856
+ return null;
857
+ }
827
858
  }
828
859
 
829
860
  /** A file's title for the waiting list and notices: the copy's, else the title it had when it was deleted, else its name. */
@@ -1060,7 +1091,22 @@ export interface Moves {
1060
1091
  * old one gone (`read` null) and the new one there. Publish's headline and the waiting
1061
1092
  * list read it ("Visit (moved from /studio/visit)").
1062
1093
  */
1063
- export function movesIn(site: Pick<ResolvedSite, 'root' | 'collections'>, files: readonly string[], read: (file: string) => string | null, records: ReadonlyMap<string, string>): Moves {
1094
+ export function movesIn(
1095
+ site: Pick<ResolvedSite, 'root' | 'collections'>,
1096
+ files: readonly string[],
1097
+ read: (file: string) => string | null,
1098
+ records: ReadonlyMap<string, string>,
1099
+ /** Collections under pages: the held collections' resolved bases (held.ts), so a held document's move has addresses. */
1100
+ bases: Readonly<Record<string, HeldBase>> = {},
1101
+ /**
1102
+ * Collections under pages: each moved document's live address, by its file now
1103
+ * (autosaves.ts → `recordAddresses`). It wins over one worked out from the old file
1104
+ * (whose holder may have moved too), and names a held document whose holder moved,
1105
+ * whose file stays as it is: that one counts whether or not its file is among `files`.
1106
+ */
1107
+ addresses: ReadonlyMap<string, string> = new Map(),
1108
+ ): Moves {
1109
+ site = withHeldBases(site, bases);
1064
1110
  const from = new Map<string, string>();
1065
1111
  const to = new Map<string, string>();
1066
1112
  const urlOf = (file: string) => {
@@ -1075,12 +1121,17 @@ export function movesIn(site: Pick<ResolvedSite, 'root' | 'collections'>, files:
1075
1121
  for (const [old, moved] of records) {
1076
1122
  if (old === moved || !changed.has(old) || !changed.has(moved) || from.has(moved) || to.has(old)) continue;
1077
1123
  if (read(old) !== null || read(moved) === null) continue;
1078
- const fromUrl = urlOf(old);
1124
+ const fromUrl = addresses.get(moved) ?? urlOf(old);
1079
1125
  const toUrl = urlOf(moved);
1080
1126
  if (!fromUrl || !toUrl) continue;
1081
1127
  from.set(moved, fromUrl);
1082
1128
  to.set(old, toUrl);
1083
1129
  }
1130
+ for (const [file, live] of addresses) {
1131
+ if (from.has(file) || to.has(file) || read(file) === null) continue;
1132
+ const url = urlOf(file);
1133
+ if (url && url !== live) from.set(file, live);
1134
+ }
1084
1135
  return { from, to };
1085
1136
  }
1086
1137
 
@@ -22,9 +22,16 @@
22
22
  * token, an expiry within 14 days. `accessProblem()` says which holds now; a later
23
23
  * success clears the first two.
24
24
  *
25
- * The private key and the token are never logged.
25
+ * - **CaraCMS Cloud** (`CARACMS_CLOUD_KEY`; caracms-cloud M2 Decisions 12–14) mints the
26
+ * installation token instead: `POST /v1/github/token` (cloud.ts), refreshed five minutes
27
+ * early like the App's. A refusal from the cloud is an access problem of kind `cloud`
28
+ * carrying the cloud's sentence (and its install link), cleared by the next token; so is
29
+ * a token answer for another repo than the one the server started on.
30
+ *
31
+ * The private key, the site key and the tokens are never logged.
26
32
  */
27
33
  import { createSign } from 'node:crypto';
34
+ import { CLOUD_REPO_CHANGED, CloudRefusal, cloudToken } from './cloud.ts';
28
35
 
29
36
  const API = 'https://api.github.com';
30
37
 
@@ -50,16 +57,27 @@ export interface TokenConnection extends ConnectionBase {
50
57
  token: string;
51
58
  }
52
59
 
53
- export type GitHubConnection = AppConnection | TokenConnection;
60
+ /** CaraCMS Cloud mints the token (caracms-cloud M2 Decision 12); `fetch` reaches the cloud as well as GitHub. */
61
+ export interface CloudConnection extends ConnectionBase {
62
+ cloudKey: string;
63
+ cloudUrl: string;
64
+ }
65
+
66
+ export type GitHubConnection = AppConnection | TokenConnection | CloudConnection;
67
+
68
+ /** Which credential a client signs in with. */
69
+ export type GitHubCredential = 'app' | 'token' | 'cloud';
54
70
 
55
71
  /**
56
72
  * What's wrong with GitHub access right now (Decision 5), shown in the editor while it holds.
57
73
  * `credential` says which one the site uses, so the sentence names the right fix.
58
74
  */
59
75
  export type GitHubAccessProblem =
60
- | { kind: 'refused'; credential: 'app' | 'token' }
61
- | { kind: 'permission'; credential: 'app' | 'token'; permission: string }
62
- | { kind: 'expiring'; credential: 'token'; expires: string };
76
+ | { kind: 'refused'; credential: GitHubCredential }
77
+ | { kind: 'permission'; credential: GitHubCredential; permission: string }
78
+ | { kind: 'expiring'; credential: 'token'; expires: string }
79
+ /** CaraCMS Cloud refused a token (caracms-cloud M2 Decision 14): its sentence as it came, and its install link. */
80
+ | { kind: 'cloud'; credential: 'cloud'; message: string; installUrl: string | null };
63
81
 
64
82
  /** A token expiring within this long shows in the editor (Decision 5). */
65
83
  export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
@@ -84,11 +102,19 @@ export function accessProblemMessage(problem: GitHubAccessProblem): string {
84
102
  const what = problem.credential === 'token' ? 'GitHub token (CARACMS_GITHUB_TOKEN)' : 'GitHub App';
85
103
  switch (problem.kind) {
86
104
  case 'refused':
105
+ if (problem.credential === 'cloud') {
106
+ return "GitHub refused the token CaraCMS Cloud gave this site: the caracms App may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until it's installed again; ask CaraCMS to check the site.";
107
+ }
87
108
  return problem.credential === 'token'
88
109
  ? `GitHub refused this site's ${what}: it may have expired or been revoked. Changes can't be saved to GitHub until a developer makes a new fine-grained token and sets it.`
89
110
  : `GitHub refused this site's ${what}: it may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until a developer reinstalls it.`;
90
111
  case 'permission':
112
+ if (problem.credential === 'cloud') {
113
+ return `GitHub refused a change because the caracms App, through CaraCMS Cloud, lacks the "${problem.permission}" permission on this site's repo. Ask CaraCMS to check the App.`;
114
+ }
91
115
  return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
116
+ case 'cloud':
117
+ return problem.message;
92
118
  case 'expiring': {
93
119
  const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
94
120
  return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
@@ -206,9 +232,10 @@ export class GitHubClient {
206
232
  readonly owner: string;
207
233
  readonly repo: string;
208
234
  /** Which credential this client signs in with. */
209
- readonly credential: 'app' | 'token';
235
+ readonly credential: GitHubCredential;
210
236
  private readonly appId: string;
211
237
  private readonly key: string;
238
+ private readonly cloud: { key: string; url: string } | null = null;
212
239
  private readonly fetcher: typeof fetch;
213
240
  private readonly now: () => number;
214
241
  private readonly timeoutMs: number;
@@ -219,6 +246,8 @@ export class GitHubClient {
219
246
  private refused = false;
220
247
  private missingPermission: string | null = null;
221
248
  private personalTokenExpires: number | null = null;
249
+ /** The cloud's last refusal, until a token comes. */
250
+ private cloudProblem: { message: string; installUrl: string | null } | null = null;
222
251
 
223
252
  constructor(connection: GitHubConnection, tracker: Tracker) {
224
253
  if ('token' in connection) {
@@ -227,6 +256,11 @@ export class GitHubClient {
227
256
  this.key = '';
228
257
  this.token = connection.token;
229
258
  this.tokenExpires = Number.POSITIVE_INFINITY;
259
+ } else if ('cloudKey' in connection) {
260
+ this.credential = 'cloud';
261
+ this.appId = '';
262
+ this.key = '';
263
+ this.cloud = { key: connection.cloudKey, url: connection.cloudUrl };
230
264
  } else {
231
265
  this.credential = 'app';
232
266
  this.appId = connection.appId;
@@ -242,6 +276,7 @@ export class GitHubClient {
242
276
 
243
277
  /** What's wrong with GitHub access now, the most serious first; null when nothing is (Decision 5). */
244
278
  accessProblem(): GitHubAccessProblem | null {
279
+ if (this.cloudProblem) return { kind: 'cloud', credential: 'cloud', ...this.cloudProblem };
245
280
  if (this.refused) return { kind: 'refused', credential: this.credential };
246
281
  if (this.missingPermission) return { kind: 'permission', credential: this.credential, permission: this.missingPermission };
247
282
  const expires = this.personalTokenExpires;
@@ -297,11 +332,38 @@ export class GitHubClient {
297
332
  this.tokenExpires = Date.parse(tok.expires_at);
298
333
  }
299
334
 
335
+ /**
336
+ * Ask CaraCMS Cloud for a token. A refusal is kept as the access problem (and thrown); a
337
+ * cloud that can't be reached only fails this call, as an unreachable GitHub does.
338
+ */
339
+ private async cloudSignIn(cloud: { key: string; url: string }): Promise<void> {
340
+ let answer;
341
+ try {
342
+ answer = await cloudToken({ ...cloud, fetch: this.fetcher, timeoutMs: this.timeoutMs });
343
+ } catch (error) {
344
+ if (error instanceof CloudRefusal && error.error !== 'unreachable') this.cloudProblem = { message: error.message, installUrl: error.installUrl };
345
+ throw error;
346
+ }
347
+ if (answer.repo.toLowerCase() !== this.nameWithOwner.toLowerCase()) {
348
+ const refusal = new CloudRefusal('repo_changed', CLOUD_REPO_CHANGED(answer.repo));
349
+ this.cloudProblem = { message: refusal.message, installUrl: null };
350
+ throw refusal;
351
+ }
352
+ this.cloudProblem = null;
353
+ this.token = answer.token;
354
+ this.tokenExpires = answer.expiresAt;
355
+ }
356
+
357
+ /** Sign in now (the cloud's first token at start, so a refusal stops the start). */
358
+ async ready(): Promise<void> {
359
+ await this.auth();
360
+ }
361
+
300
362
  /** The installation token, refreshed five minutes before it expires; a personal token as it is. */
301
363
  private async auth(): Promise<string> {
302
364
  if (this.credential === 'token') return this.token;
303
365
  if (!this.token || this.now() > this.tokenExpires - 5 * 60_000) {
304
- this.signingIn ??= this.signIn().finally(() => {
366
+ this.signingIn ??= (this.cloud ? this.cloudSignIn(this.cloud) : this.signIn()).finally(() => {
305
367
  this.signingIn = null;
306
368
  });
307
369
  await this.signingIn;