canopycms 0.7.1 → 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.
Files changed (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. package/package.json +2 -1
@@ -114,10 +114,9 @@ function isRateLimitSignal403(err) {
114
114
  }
115
115
  // Payload validation helpers — fail fast with clear errors instead of silent `as` casts
116
116
  /**
117
- * Whether `branch` carries the reserved settings-branch prefix. Matching on the prefix
118
- * alone, never on a configured name, is what keeps a content branch out: branch creation
119
- * rejects the prefix, while a configured name the worker and API disagree on could be a
120
- * content branch's.
117
+ * Whether `branch` carries the reserved settings-branch prefix, which routes a submit to a push
118
+ * with no pull request. A configured settings name without the prefix is not matched here; the
119
+ * gateway refuses its pull request (`BranchPolicy.assertPullRequestHeadAllowed`).
121
120
  */
122
121
  function isSettingsBranch(branch) {
123
122
  return branch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
@@ -290,7 +289,7 @@ export async function executeTask(ctx, task, signal) {
290
289
  const pr = await ctx.github().createPullRequest({
291
290
  head: branch,
292
291
  base: optionalString(payload, 'baseBranch', ctx.baseBranch),
293
- title: optionalString(payload, 'title', `Submit ${branch}`),
292
+ title: optionalString(payload, 'title', `Submit ${editorBranchOf(payload, branch)}`),
294
293
  body: optionalString(payload, 'body', ''),
295
294
  }, signal);
296
295
  workerLog(`Created PR #${pr.number} for ${branch}`);
@@ -301,7 +300,7 @@ export async function executeTask(ctx, task, signal) {
301
300
  const prNumber = requireNumber(payload, 'pullRequestNumber');
302
301
  await ctx.pushBranchToGitHub(branch, signal);
303
302
  await ctx.github().updatePullRequest(prNumber, {
304
- title: optionalString(payload, 'title', `Submit ${branch}`),
303
+ title: optionalString(payload, 'title', `Submit ${editorBranchOf(payload, branch)}`),
305
304
  body: optionalString(payload, 'body', ''),
306
305
  }, signal);
307
306
  workerLog(`Updated PR #${prNumber} for ${branch}`);
@@ -337,7 +336,7 @@ export async function executeTask(ctx, task, signal) {
337
336
  const body = mergeSectionIntoBody
338
337
  ? await refreshCommentsBlock({
339
338
  branchRoot: ctx.branchWorkspacePath(branch),
340
- branch,
339
+ branch: editorBranchOf(payload, branch),
341
340
  editorUrl: typeof payload.editorUrl === 'string' ? payload.editorUrl : undefined,
342
341
  section: queuedBody,
343
342
  warn: workerLogWarn,
@@ -348,7 +347,7 @@ export async function executeTask(ctx, task, signal) {
348
347
  result = await ctx.github().createOrUpdatePullRequest({
349
348
  head: branch,
350
349
  base,
351
- title: optionalString(payload, 'title', `Submit ${branch}`),
350
+ title: optionalString(payload, 'title', `Submit ${editorBranchOf(payload, branch)}`),
352
351
  body,
353
352
  // Content submits (api/github-sync.ts) set both.
354
353
  markReadyIfDraft: payload.markReadyIfDraft === true,
@@ -447,27 +446,39 @@ export async function executeTask(ctx, task, signal) {
447
446
  const pr = await ctx.github().getPullRequest(prNumber, signal);
448
447
  if (pr.state !== 'open')
449
448
  return { updated: false, skipped: 'not-open' };
450
- // A comments.json that does not parse stays so until a comment write rewrites it.
449
+ // Withheld: GitHub reports a head the gateway would not update the pull request of
450
+ // (`LocalGitHubGateway.getPullRequest`): a settings branch, one outside the namespace and not a
451
+ // `legacyBranches` entry, a protected one, or one in another repository.
452
+ if (pr.body === null) {
453
+ workerLog(`Not updating PR #${prNumber} for ${branch}: the gateway withholds its body`);
454
+ return { updated: false, skipped: 'body-withheld' };
455
+ }
456
+ // A corrupt file leaves its block as it is and the other block still updates; any other read
457
+ // error throws, so the task is retried.
451
458
  const body = await renderPrCommentsBody({
452
459
  branchRoot: branchPath,
453
- branch,
460
+ branch: editorBranchOf(payload, branch),
454
461
  editorUrl,
455
462
  body: pr.body,
456
- }).catch((err) => {
457
- if (err instanceof SyntaxError)
458
- throw new PermanentTaskError(getErrorMessage(err));
459
- throw err;
463
+ warn: workerLogWarn,
460
464
  });
461
465
  if (body === undefined)
462
466
  return { updated: false, skipped: 'unchanged' };
463
467
  await ctx.github().updatePullRequest(prNumber, { body }, signal);
464
- workerLog(`Updated the editor comments on PR #${prNumber} for ${branch}`);
468
+ workerLog(`Updated the comment and review blocks of PR #${prNumber} for ${branch}`);
465
469
  return { updated: true };
466
470
  }
467
471
  default:
468
472
  throw new PermanentTaskError(`Unknown task action: ${action}`);
469
473
  }
470
474
  }
475
+ /**
476
+ * The name links and a fallback title give the branch: the short name the API queued
477
+ * (branch-param.ts), else the ref, which the editor resolves too.
478
+ */
479
+ function editorBranchOf(payload, branch) {
480
+ return optionalString(payload, 'editorBranch', branch);
481
+ }
471
482
  /**
472
483
  * The branch whose metadata records a task's outcome, or null when none does. A
473
484
  * delete-remote-branch task names a branch already deleted here, so a workspace under that
@@ -479,7 +490,17 @@ function metadataBranchOf(task) {
479
490
  return typeof task.payload.branch === 'string' ? task.payload.branch : null;
480
491
  }
481
492
  /**
482
- * Update branch metadata after successful task completion.
493
+ * Logs an outcome write skipped because `branch`'s workspace has no branch.json whose `name` is
494
+ * `branch`: none at all, or another branch's that shares its key.
495
+ */
496
+ function noMetadataToRecord(branch, what) {
497
+ workerLogWarn(`Not recording the ${what} for ${branch}: its workspace has no branch.json for it`);
498
+ }
499
+ /**
500
+ * Update branch metadata after successful task completion. Like every outcome write here, it
501
+ * updates only a branch.json whose `name` is the task's ref: a workspace without one is not a
502
+ * branch whose name could be recorded, and one naming another ref is that branch's
503
+ * (`noMetadataToRecord`).
483
504
  */
484
505
  export async function updateBranchMetadata(ctx, task, result) {
485
506
  const branch = metadataBranchOf(task);
@@ -494,32 +515,36 @@ export async function updateBranchMetadata(ctx, task, result) {
494
515
  }
495
516
  try {
496
517
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
497
- const updates = {
498
- name: branch,
499
- syncStatus: 'synced',
500
- // save()'s merge only overwrites keys present in this update, so a prior
501
- // syncFailureReason would survive this successful sync forever unless
502
- // cleared explicitly (same pattern as rebaseFailure elsewhere).
503
- syncFailureReason: undefined,
504
- };
505
- if (result.prUrl)
506
- updates.pullRequestUrl = result.prUrl;
507
- if (result.prNumber) {
508
- updates.pullRequestNumber = result.prNumber;
509
- // As soon as the PR exists the field reads 'open', rather than staying
510
- // absent until the next poll cycle observes it. Exception: the git-sync
511
- // loop may have archived this branch (PR merged/closed) while the task
512
- // was in flight, and a late completion must not downgrade that terminal
513
- // state back to 'open'.
514
- const current = await BranchMetadataFileManager.loadOnly(branchPath);
515
- const terminal = current?.branch.status === 'archived' ||
516
- current?.branch.pullRequestState === 'merged' ||
517
- current?.branch.pullRequestState === 'closed';
518
- if (!terminal) {
519
- updates.pullRequestState = 'open';
518
+ const saved = await meta.update((existing) => {
519
+ if (existing?.branch.name !== branch)
520
+ return null;
521
+ const updates = {
522
+ syncStatus: 'synced',
523
+ // save()'s merge only overwrites keys present in this update, so a prior
524
+ // syncFailureReason would survive this successful sync forever unless
525
+ // cleared explicitly (same pattern as rebaseFailure elsewhere).
526
+ syncFailureReason: undefined,
527
+ };
528
+ if (result.prUrl)
529
+ updates.pullRequestUrl = result.prUrl;
530
+ if (result.prNumber) {
531
+ updates.pullRequestNumber = result.prNumber;
532
+ // As soon as the PR exists the field reads 'open', rather than staying
533
+ // absent until the next poll cycle observes it. Exception: the git-sync
534
+ // loop may have archived this branch (PR merged/closed) while the task
535
+ // was in flight, and a late completion must not downgrade that terminal
536
+ // state back to 'open'.
537
+ const terminal = existing.branch.status === 'archived' ||
538
+ existing.branch.pullRequestState === 'merged' ||
539
+ existing.branch.pullRequestState === 'closed';
540
+ if (!terminal) {
541
+ updates.pullRequestState = 'open';
542
+ }
520
543
  }
521
- }
522
- await meta.save({ branch: updates });
544
+ return { branch: updates };
545
+ });
546
+ if (saved === null)
547
+ noMetadataToRecord(branch, `${task.action} outcome`);
523
548
  }
524
549
  catch (err) {
525
550
  workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
@@ -548,7 +573,7 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
548
573
  }
549
574
  try {
550
575
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
551
- const failed = { name: branch, syncStatus: 'sync-failed', syncFailureReason: error };
576
+ const failed = { syncStatus: 'sync-failed', syncFailureReason: error };
552
577
  const submittedAt = task.payload.submittedAt;
553
578
  const unlocked = options.unlock &&
554
579
  typeof submittedAt === 'string' &&
@@ -558,11 +583,15 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
558
583
  status: 'editing',
559
584
  syncFailureReason: `${error} The branch is unlocked for editing; save a change, then submit again.`,
560
585
  },
561
- }, (existing) => existing?.branch.status === 'submitted' &&
586
+ }, (existing) => existing?.branch.name === branch &&
587
+ existing.branch.status === 'submitted' &&
562
588
  existing.branch.pullRequestNumber === undefined &&
563
589
  existing.branch.submittedAt === submittedAt)) !== null;
564
- if (!unlocked)
565
- await meta.save({ branch: failed });
590
+ if (!unlocked) {
591
+ const saved = await meta.saveIf({ branch: failed }, (existing) => existing?.branch.name === branch);
592
+ if (saved === null)
593
+ noMetadataToRecord(branch, `${task.action} failure`);
594
+ }
566
595
  }
567
596
  catch (err) {
568
597
  workerLogError(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
@@ -708,9 +737,9 @@ async function recordPushedToGitHub(ctx, branchPath, branch) {
708
737
  return;
709
738
  }
710
739
  try {
711
- await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).save({
712
- branch: { name: branch, pushedToGitHubAt: new Date().toISOString() },
713
- });
740
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { pushedToGitHubAt: new Date().toISOString() } }, (existing) => existing?.branch.name === branch);
741
+ if (saved === null)
742
+ noMetadataToRecord(branch, 'GitHub push');
714
743
  }
715
744
  catch (err) {
716
745
  workerLogError(`Failed to record the GitHub push for ${branch}:`, getErrorMessage(err));
@@ -0,0 +1,34 @@
1
+ import type { UnpublishableBranchesReport, WorkerStatusReport } from '../types.js';
2
+ import { type BranchPolicyDescription } from './branch-policy.js';
3
+ import type { WorkerContext } from './worker-context.js';
4
+ /**
5
+ * The most branches a report lists; its `count` says how many there are.
6
+ * @internal Exported for tests.
7
+ */
8
+ export declare const MAX_UNPUBLISHABLE_LISTED = 50;
9
+ /**
10
+ * The editor branches the gateway that described its policy as `policy` would refuse to push.
11
+ * Each is the `name` its branch.json records, the git ref: the directory holding it is only the
12
+ * ref sanitized, and `cms/prod/x` and a legacy `cms-prod-x` share one. Only a directory can be a
13
+ * branch: a regular file, such as `branches.json` or a registry write's temp file, or a symlink,
14
+ * is skipped. A directory with no branch.json, or a corrupt one, is no branch. Throws when the
15
+ * branches directory or a branch directory's branch.json cannot be read, so the caller keeps the
16
+ * last report.
17
+ */
18
+ export declare function findUnpublishableBranches(ctx: Pick<WorkerContext, 'contentBranchesPath' | 'sanitizedBaseBranch'>, policy: BranchPolicyDescription): Promise<Omit<UnpublishableBranchesReport, 'at'>>;
19
+ /**
20
+ * The report in the status file a previous worker process left, so a restarted worker shows it
21
+ * until its first sync can ask the gateway. Tolerant: an unreadable or malformed file carries
22
+ * nothing.
23
+ */
24
+ export declare function readCarriedUnpublishableBranches(taskDir: string): Promise<UnpublishableBranchesReport | undefined>;
25
+ /**
26
+ * The gateway's namespace and protected names from the status file a previous worker process
27
+ * left, so branch creation, which waits for the namespace, keeps working across a restart until
28
+ * the first sync asks the gateway again. The Lambda can write the status file, so they are carried
29
+ * only in the shape git sync writes: a namespace `branchNamespaceProblem` takes, and at most one
30
+ * more name than `protectedBranches` holds, each exact and no longer than a branch name. Tolerant:
31
+ * no namespace, or one in any other shape, carries nothing, and a list in any other shape
32
+ * carries the namespace alone.
33
+ */
34
+ export declare function readCarriedGatewayPolicy(taskDir: string): Promise<Pick<WorkerStatusReport, 'gatewayBranchNamespace' | 'gatewayProtectedBranches'>>;
@@ -0,0 +1,182 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { BranchMetadataCorruptError, BranchMetadataFileManager } from '../branch-metadata.js';
4
+ import { branchNamespaceProblem } from '../operating-mode/branch-namespace.js';
5
+ import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
6
+ import { isNodeError } from '../utils/error.js';
7
+ import { MAX_BRANCH_NAME_LENGTH, MAX_PROTECTED_BRANCHES, legacyBranchProblem, publishesBranch, } from './branch-policy.js';
8
+ /**
9
+ * The most branches a report lists; its `count` says how many there are.
10
+ * @internal Exported for tests.
11
+ */
12
+ export const MAX_UNPUBLISHABLE_LISTED = 50;
13
+ /**
14
+ * Why a `legacyBranches` entry would not publish `name` in `namespace`, or undefined when one
15
+ * would. The ancestor test is the `BranchPolicy` constructor's and `namespaceBlocker`'s.
16
+ */
17
+ function reasonFor(name, namespace) {
18
+ if (namespace.startsWith(`${name}/`))
19
+ return 'blocks-namespace';
20
+ if (legacyBranchProblem(name) !== null)
21
+ return 'unlistable';
22
+ return undefined;
23
+ }
24
+ /**
25
+ * The editor branches the gateway that described its policy as `policy` would refuse to push.
26
+ * Each is the `name` its branch.json records, the git ref: the directory holding it is only the
27
+ * ref sanitized, and `cms/prod/x` and a legacy `cms-prod-x` share one. Only a directory can be a
28
+ * branch: a regular file, such as `branches.json` or a registry write's temp file, or a symlink,
29
+ * is skipped. A directory with no branch.json, or a corrupt one, is no branch. Throws when the
30
+ * branches directory or a branch directory's branch.json cannot be read, so the caller keeps the
31
+ * last report.
32
+ */
33
+ export async function findUnpublishableBranches(ctx, policy) {
34
+ let entries;
35
+ try {
36
+ entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
37
+ }
38
+ catch (err) {
39
+ // `withFileTypes` may lstat each entry, so an entry removed mid-listing rejects with its own
40
+ // ENOENT: only the branches directory's absence means no branches.
41
+ if (!isNodeError(err) || err.code !== 'ENOENT')
42
+ throw err;
43
+ if (typeof err.path !== 'string' ||
44
+ path.resolve(err.path) !== path.resolve(ctx.contentBranchesPath)) {
45
+ throw err;
46
+ }
47
+ entries = [];
48
+ }
49
+ const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
50
+ const branches = [];
51
+ for (const dir of dirs.sort()) {
52
+ // The base branch is protected and never published; the `.trash-*` directories purged
53
+ // branches wait in are not branches.
54
+ if (dir.startsWith('.') || dir === ctx.sanitizedBaseBranch)
55
+ continue;
56
+ let meta;
57
+ try {
58
+ meta = await BranchMetadataFileManager.loadOnly(path.join(ctx.contentBranchesPath, dir));
59
+ }
60
+ catch (err) {
61
+ // Corrupt metadata is no branch, and a directory removed since the listing holds none.
62
+ const notABranch = err instanceof BranchMetadataCorruptError || (isNodeError(err) && err.code === 'ENOENT');
63
+ if (!notABranch)
64
+ throw err;
65
+ meta = null;
66
+ }
67
+ const branch = meta?.branch;
68
+ if (!branch || branch.status === 'archived')
69
+ continue;
70
+ if (publishesBranch(policy, branch.name))
71
+ continue;
72
+ const reason = reasonFor(branch.name, policy.namespace);
73
+ branches.push(reason ? { name: branch.name, reason } : { name: branch.name });
74
+ }
75
+ return { branches: branches.slice(0, MAX_UNPUBLISHABLE_LISTED), count: branches.length };
76
+ }
77
+ const REASONS = new Set([
78
+ 'blocks-namespace',
79
+ 'unlistable',
80
+ ]);
81
+ function isReason(value) {
82
+ return REASONS.has(value);
83
+ }
84
+ /** The most branches a carried report may claim to hold; a real workspace has far fewer. */
85
+ const MAX_CARRIED_COUNT = 100_000;
86
+ /** The longest timestamp a carried report's `at` may be; an ISO 8601 instant is 24. */
87
+ const MAX_CARRIED_AT_LENGTH = 32;
88
+ /**
89
+ * The report `value` holds, rebuilt from the fields `findUnpublishableBranches` writes, or
90
+ * undefined when `value` is not one it could have made: at most MAX_UNPUBLISHABLE_LISTED names,
91
+ * none longer than a branch name can be, a whole `count` no smaller than the list and at most
92
+ * MAX_CARRIED_COUNT, and an `at` that parses as a date within MAX_CARRIED_AT_LENGTH characters.
93
+ * The Lambda can write the status file, so nothing else it holds is carried.
94
+ */
95
+ function carriedReport(value) {
96
+ if (typeof value !== 'object' || value === null)
97
+ return undefined;
98
+ if (!('branches' in value) || !('count' in value) || !('at' in value))
99
+ return undefined;
100
+ const { branches, count, at } = value;
101
+ if (typeof at !== 'string' ||
102
+ at.length > MAX_CARRIED_AT_LENGTH ||
103
+ !Number.isFinite(Date.parse(at)) ||
104
+ !Array.isArray(branches) ||
105
+ branches.length > MAX_UNPUBLISHABLE_LISTED ||
106
+ typeof count !== 'number' ||
107
+ !Number.isInteger(count) ||
108
+ count < branches.length ||
109
+ count > MAX_CARRIED_COUNT) {
110
+ return undefined;
111
+ }
112
+ const carried = [];
113
+ for (const entry of branches) {
114
+ if (typeof entry !== 'object' ||
115
+ entry === null ||
116
+ !('name' in entry) ||
117
+ typeof entry.name !== 'string' ||
118
+ entry.name.length > MAX_BRANCH_NAME_LENGTH) {
119
+ return undefined;
120
+ }
121
+ if (!('reason' in entry)) {
122
+ carried.push({ name: entry.name });
123
+ }
124
+ else if (isReason(entry.reason)) {
125
+ carried.push({ name: entry.name, reason: entry.reason });
126
+ }
127
+ else {
128
+ return undefined;
129
+ }
130
+ }
131
+ return { branches: carried, count, at };
132
+ }
133
+ /**
134
+ * The report in the status file a previous worker process left, so a restarted worker shows it
135
+ * until its first sync can ask the gateway. Tolerant: an unreadable or malformed file carries
136
+ * nothing.
137
+ */
138
+ export async function readCarriedUnpublishableBranches(taskDir) {
139
+ const parsed = await readStatusObject(taskDir);
140
+ if (parsed === undefined || !('unpublishableBranches' in parsed))
141
+ return undefined;
142
+ return carriedReport(parsed.unpublishableBranches);
143
+ }
144
+ /**
145
+ * The gateway's namespace and protected names from the status file a previous worker process
146
+ * left, so branch creation, which waits for the namespace, keeps working across a restart until
147
+ * the first sync asks the gateway again. The Lambda can write the status file, so they are carried
148
+ * only in the shape git sync writes: a namespace `branchNamespaceProblem` takes, and at most one
149
+ * more name than `protectedBranches` holds, each exact and no longer than a branch name. Tolerant:
150
+ * no namespace, or one in any other shape, carries nothing, and a list in any other shape
151
+ * carries the namespace alone.
152
+ */
153
+ export async function readCarriedGatewayPolicy(taskDir) {
154
+ const parsed = await readStatusObject(taskDir);
155
+ if (parsed === undefined || !('gatewayBranchNamespace' in parsed))
156
+ return {};
157
+ const namespace = parsed.gatewayBranchNamespace;
158
+ if (typeof namespace !== 'string' || branchNamespaceProblem(namespace) !== null)
159
+ return {};
160
+ const names = 'gatewayProtectedBranches' in parsed ? parsed.gatewayProtectedBranches : undefined;
161
+ return isExactNameList(names)
162
+ ? { gatewayBranchNamespace: namespace, gatewayProtectedBranches: names }
163
+ : { gatewayBranchNamespace: namespace };
164
+ }
165
+ function isExactNameList(value) {
166
+ return (Array.isArray(value) &&
167
+ value.length <= MAX_PROTECTED_BRANCHES + 1 &&
168
+ value.every((name) => typeof name === 'string' &&
169
+ name.length > 0 &&
170
+ name.length <= MAX_BRANCH_NAME_LENGTH &&
171
+ !name.includes('*')));
172
+ }
173
+ /** The status file's top-level object, or undefined when it is missing, unreadable or not one. */
174
+ async function readStatusObject(taskDir) {
175
+ try {
176
+ const parsed = JSON.parse(await fs.readFile(path.join(taskDir, WORKER_STATUS_FILE), 'utf-8'));
177
+ return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.7.1",
4
+ "version": "0.8.1",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -126,6 +126,7 @@
126
126
  "content-disposition": "^2.0.1",
127
127
  "dataloader": "^2.2.3",
128
128
  "dayjs": "1.11.21",
129
+ "diff": "9.0.0",
129
130
  "fast-deep-equal": "^3.1.3",
130
131
  "file-type": "^21.3.2",
131
132
  "gray-matter": "^4.0.3",