canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
package/dist/cli/cli.js CHANGED
@@ -1178,7 +1178,7 @@ CanopyCMS worker run-once (mode: ${mode}, auth: ${authMode})
1178
1178
  }
1179
1179
  console.log("\nDone");
1180
1180
  }
1181
- var __defProp2, __getOwnPropNames2, __esm2, __export2, active, init_logger, primitiveFieldTypes, fieldTypes, init_types, fieldBaseSchema, selectOptionSchema, referenceOptionSchema, primitiveFieldSchema, ASPECT_RATIO_RE, imageFieldSchema, selectFieldSchema, referenceFieldSchema, fieldHolder, blockSchema, blockFieldSchema, objectFieldSchema, inlineGroupFieldSchema, customFieldSchema, knownFieldSchema, fieldSchema, init_field, relativePathSchema, entryTypeSchema, collectionSchema, init_collection, SENTINEL_BASE, DOT_SEGMENT, init_sanitize_href, uploadTargetUrlSchema, assetMountUrlSchema, init_url, mediaSchema, init_media, defaultBranchAccessSchema, defaultPathAccessLevelSchema, defaultPathAccessLevelsSchema, defaultPathAccessSchema, defaultBaseBranchSchema, defaultRemoteNameSchema, defaultRemoteUrlSchema, gitBotAuthorNameSchema, gitBotAuthorEmailSchema, githubTokenEnvVarSchema, operatingModeSchema, deployedAsSchema, contentRootSchema, sourceRootSchema, basePathSchema, deploymentNameSchema, devContentSyncSchema, devConfigSchema, editorConfigSchema, CanopyConfigSchema, DEFAULT_PROD_WORKSPACE, init_config, permissionTargetSchema, pathPermissionSchema, init_permissions, init_normalize, init_types2, init_flatten, init_mode_env, init_validation, init_helpers, init_config2, init_error2, init_fs2, templates_exports, __dirname, TEMPLATES_DIR, SCAFFOLD_STACK_NAME, init_templates, project_detect_exports, COMMANDS, MAX_WORKSPACE_DEPTH, CDK_DEPENDENCIES, init_project_detect, task_queue_config_exports, init_task_queue_config, LOG_LEVELS, DebugLogger, testLogger, init_debug, init_atomic_write, DEFAULT_MAX_RETRIES, nullLogger, init_task_queue, cms_task_queue_exports, debugLogger, cmsTaskQueueLogger, init_cms_task_queue, ProdClientSafeStrategy, DevClientSafeStrategy, warned, VALID_DEPLOYMENT_NAME, isValidDeploymentName, ProdStrategy, DevStrategy, strategyCache;
1181
+ var __defProp2, __getOwnPropNames2, __esm2, __export2, active, init_logger, primitiveFieldTypes, fieldTypes, init_types, fieldBaseSchema, selectOptionSchema, referenceOptionSchema, primitiveFieldSchema, ASPECT_RATIO_RE, imageFieldSchema, selectFieldSchema, referenceFieldSchema, fieldHolder, blockSchema, blockFieldSchema, objectFieldSchema, inlineGroupFieldSchema, customFieldSchema, knownFieldSchema, fieldSchema, init_field, relativePathSchema, entryTypeSchema, collectionSchema, init_collection, SENTINEL_BASE, DOT_SEGMENT, init_sanitize_href, uploadTargetUrlSchema, assetMountUrlSchema, previewPrefixSchema, init_url, mediaSchema, init_media, defaultBranchAccessSchema, defaultPathAccessLevelSchema, defaultPathAccessLevelsSchema, defaultPathAccessSchema, defaultBaseBranchSchema, defaultRemoteNameSchema, defaultRemoteUrlSchema, gitBotAuthorNameSchema, gitBotAuthorEmailSchema, githubTokenEnvVarSchema, operatingModeSchema, deployedAsSchema, contentRootSchema, sourceRootSchema, basePathSchema, deploymentNameSchema, devContentSyncSchema, devConfigSchema, editorConfigSchema, CanopyConfigSchema, DEFAULT_PROD_WORKSPACE, init_config, permissionTargetSchema, pathPermissionSchema, init_permissions, init_normalize, init_types2, init_flatten, init_mode_env, init_validation, init_helpers, init_config2, init_error2, init_fs2, templates_exports, __dirname, TEMPLATES_DIR, SCAFFOLD_STACK_NAME, init_templates, project_detect_exports, COMMANDS, MAX_WORKSPACE_DEPTH, CDK_DEPENDENCIES, init_project_detect, task_queue_config_exports, init_task_queue_config, LOG_LEVELS, DebugLogger, testLogger, init_debug, init_atomic_write, DEFAULT_MAX_RETRIES, nullLogger, init_task_queue, cms_task_queue_exports, debugLogger, cmsTaskQueueLogger, init_cms_task_queue, ProdClientSafeStrategy, DevClientSafeStrategy, warned, VALID_DEPLOYMENT_NAME, isValidDeploymentName, ProdStrategy, DevStrategy, strategyCache;
1182
1182
  var init_init = __esm({
1183
1183
  "dist/cli/init.js"() {
1184
1184
  "use strict";
@@ -1285,7 +1285,8 @@ var init_init = __esm({
1285
1285
  });
1286
1286
  objectFieldSchema = fieldBaseSchema.extend({
1287
1287
  type: z.literal("object"),
1288
- fields: z.array(z.lazy(() => fieldHolder[0])).min(1)
1288
+ fields: z.array(z.lazy(() => fieldHolder[0])).min(1),
1289
+ itemTitleField: z.string().min(1).optional()
1289
1290
  });
1290
1291
  inlineGroupFieldSchema = z.object({
1291
1292
  type: z.literal("group"),
@@ -1359,6 +1360,9 @@ var init_init = __esm({
1359
1360
  assetMountUrlSchema = z3.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
1360
1361
  message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
1361
1362
  });
1363
+ previewPrefixSchema = z3.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
1364
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
1365
+ });
1362
1366
  }
1363
1367
  });
1364
1368
  init_media = __esm2({
@@ -1408,6 +1412,7 @@ var init_init = __esm({
1408
1412
  "use strict";
1409
1413
  init_collection();
1410
1414
  init_media();
1415
+ init_url();
1411
1416
  defaultBranchAccessSchema = z5.enum(["allow", "deny"]).default("deny");
1412
1417
  defaultPathAccessLevelSchema = z5.enum(["allow", "deny"]);
1413
1418
  defaultPathAccessLevelsSchema = z5.object({
@@ -1439,6 +1444,7 @@ var init_init = __esm({
1439
1444
  subtitle: z5.string().optional(),
1440
1445
  theme: z5.unknown().optional(),
1441
1446
  previewBase: z5.record(z5.string()).optional(),
1447
+ previewPrefix: previewPrefixSchema.optional(),
1442
1448
  // UI handler functions (runtime only, don't serialize)
1443
1449
  onAccountClick: z5.function().returns(z5.void()).optional(),
1444
1450
  onLogoutClick: z5.function().returns(z5.void()).optional(),
@@ -1466,6 +1472,9 @@ var init_init = __esm({
1466
1472
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
1467
1473
  gitBotAuthorName: gitBotAuthorNameSchema,
1468
1474
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
1475
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
1476
+ gitEditedByTrailers: z5.boolean().optional(),
1477
+ gitCoAuthoredByTrailers: z5.boolean().optional(),
1469
1478
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
1470
1479
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
1471
1480
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -1477,7 +1486,6 @@ var init_init = __esm({
1477
1486
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
1478
1487
  allowNetworkRemoteInProd: z5.boolean().optional(),
1479
1488
  settingsBranch: z5.string().optional(),
1480
- autoCreateSettingsPR: z5.boolean().optional(),
1481
1489
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
1482
1490
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
1483
1491
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -1734,6 +1742,10 @@ var init_init = __esm({
1734
1742
  throw new Error(errorMsg);
1735
1743
  }
1736
1744
  }
1745
+ /**
1746
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
1747
+ * anything that can run concurrently uses {@link timed}.
1748
+ */
1737
1749
  time(label) {
1738
1750
  this.timers.set(label, Date.now());
1739
1751
  }
@@ -1748,12 +1760,16 @@ var init_init = __esm({
1748
1760
  this.debug(category, `${label} completed`, { durationMs: duration });
1749
1761
  return duration;
1750
1762
  }
1763
+ /**
1764
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
1765
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
1766
+ */
1751
1767
  async timed(category, label, fn) {
1752
- this.time(label);
1768
+ const start = Date.now();
1753
1769
  try {
1754
1770
  return await fn();
1755
1771
  } finally {
1756
- this.timeEnd(category, label);
1772
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
1757
1773
  }
1758
1774
  }
1759
1775
  };
@@ -1919,9 +1935,6 @@ var init_init = __esm({
1919
1935
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
1920
1936
  }
1921
1937
  }
1922
- shouldCreateSettingsPR(config) {
1923
- return config.autoCreateSettingsPR ?? true;
1924
- }
1925
1938
  };
1926
1939
  DevStrategy = class extends DevClientSafeStrategy {
1927
1940
  getWorkspaceRoot(sourceRoot) {
@@ -1968,9 +1981,6 @@ var init_init = __esm({
1968
1981
  }
1969
1982
  validateConfig(_config) {
1970
1983
  }
1971
- shouldCreateSettingsPR(_config) {
1972
- return false;
1973
- }
1974
1984
  };
1975
1985
  strategyCache = /* @__PURE__ */ new Map();
1976
1986
  init_mode_env();
@@ -3058,6 +3068,8 @@ import { promises as fs62 } from "fs";
3058
3068
  import { join as join3 } from "pathe";
3059
3069
  import { z as z8 } from "zod";
3060
3070
  import chokidar from "chokidar";
3071
+ import { AsyncLocalStorage } from "node:async_hooks";
3072
+ import { simpleGit as simpleGit3 } from "simple-git";
3061
3073
  import fs82 from "node:fs/promises";
3062
3074
  import { sep, resolve, relative } from "node:path";
3063
3075
  import fs9 from "node:fs/promises";
@@ -3079,7 +3091,6 @@ import lockfile2 from "proper-lockfile";
3079
3091
  import fs16 from "node:fs/promises";
3080
3092
  import path17 from "node:path";
3081
3093
  import { simpleGit as simpleGit22 } from "simple-git";
3082
- import { simpleGit as simpleGit3 } from "simple-git";
3083
3094
  import fs17 from "node:fs/promises";
3084
3095
  import path19 from "node:path";
3085
3096
  import matter3 from "gray-matter";
@@ -3145,6 +3156,9 @@ function createLogicalPath(...segments) {
3145
3156
  }
3146
3157
  return normalized;
3147
3158
  }
3159
+ function entryLogicalPath(collectionLogicalPath, slug) {
3160
+ return collectionLogicalPath ? `${collectionLogicalPath}/${slug}` : slug;
3161
+ }
3148
3162
  function trimSlashes(path222) {
3149
3163
  let start = 0;
3150
3164
  let end = path222.length;
@@ -3473,12 +3487,14 @@ async function withLock(key, fn) {
3473
3487
  locks.delete(key);
3474
3488
  }
3475
3489
  }
3476
- function provisioningLockOptions(lockPath, retries, onCompromised) {
3490
+ function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
3477
3491
  return {
3478
3492
  lockfilePath: lockPath,
3479
3493
  realpath: false,
3480
3494
  retries,
3481
- stale: 3e4,
3495
+ stale: staleMs,
3496
+ // Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
3497
+ update: 15e3,
3482
3498
  // proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
3483
3499
  // an uncaught exception that kills the process. Call sites are told not to throw (see
3484
3500
  // OnLockCompromised); this makes it structural rather than a convention, and also covers the
@@ -3514,23 +3530,26 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
3514
3530
  }
3515
3531
  };
3516
3532
  }
3517
- async function acquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
3518
- await fs23.mkdir(lockTargetDir2, { recursive: true });
3519
- const lockPath = path22.join(lockTargetDir2, lockName);
3520
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
3533
+ function branchProvisioningLockName(dirName) {
3534
+ return `.${dirName}.init.lock`;
3535
+ }
3536
+ async function acquireProvisioningLock(lockTargetDir22, lockName, onCompromised) {
3537
+ await fs23.mkdir(lockTargetDir22, { recursive: true });
3538
+ const lockPath = path22.join(lockTargetDir22, lockName);
3539
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
3521
3540
  return releaseIgnoringAlreadyReleased(release, lockPath);
3522
3541
  }
3523
- async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
3524
- await fs23.mkdir(lockTargetDir2, { recursive: true });
3525
- const lockPath = path22.join(lockTargetDir2, lockName);
3526
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
3542
+ async function tryAcquireProvisioningLock(lockTargetDir22, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS) {
3543
+ await fs23.mkdir(lockTargetDir22, { recursive: true });
3544
+ const lockPath = path22.join(lockTargetDir22, lockName);
3545
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
3527
3546
  return releaseIgnoringAlreadyReleased(release, lockPath);
3528
3547
  }
3529
3548
  function lockTargetDir(branchRoot) {
3530
3549
  return path32.join(path32.resolve(branchRoot), META_DIR);
3531
3550
  }
3532
3551
  function tryAcquireContentWriteLock(branchRoot, onCompromised) {
3533
- return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
3552
+ return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS);
3534
3553
  }
3535
3554
  async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, onCompromised) {
3536
3555
  const startedAt = Date.now();
@@ -3561,7 +3580,7 @@ async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRI
3561
3580
  await release();
3562
3581
  }
3563
3582
  if (compromised) {
3564
- throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.");
3583
+ throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.", "unknown");
3565
3584
  }
3566
3585
  return result;
3567
3586
  }
@@ -3591,8 +3610,62 @@ function buildResolvedReference(data, meta, body) {
3591
3610
  resolved.slug = meta.slug;
3592
3611
  resolved.collection = meta.collection;
3593
3612
  resolved.urlPath = meta.urlPath;
3613
+ delete resolved[RESTRICTED_REFERENCE_MARKER];
3594
3614
  return resolved;
3595
3615
  }
3616
+ function buildRestrictedReference(meta, title) {
3617
+ return {
3618
+ id: meta.id,
3619
+ slug: meta.slug,
3620
+ collection: meta.collection,
3621
+ urlPath: meta.urlPath,
3622
+ title,
3623
+ unavailable: true,
3624
+ reason: "restricted"
3625
+ };
3626
+ }
3627
+ function isGroupField(field) {
3628
+ return field.type === "group";
3629
+ }
3630
+ function extractTitleFromSchema(fields, data) {
3631
+ return findTitleValue(fields, data);
3632
+ }
3633
+ function findTitleValue(fields, data) {
3634
+ for (const field of fields) {
3635
+ if (isGroupField(field)) {
3636
+ const result = findTitleValue(field.fields, data);
3637
+ if (result !== void 0)
3638
+ return result;
3639
+ } else if ("isTitle" in field && field.isTitle) {
3640
+ const value = data[field.name];
3641
+ return typeof value === "string" ? value : void 0;
3642
+ } else if (field.type === "object" && "fields" in field && field.fields && !field.list) {
3643
+ const nested = data[field.name];
3644
+ if (nested != null && typeof nested === "object" && !Array.isArray(nested)) {
3645
+ const result = findTitleValue(field.fields, nested);
3646
+ if (result !== void 0)
3647
+ return result;
3648
+ }
3649
+ }
3650
+ }
3651
+ return void 0;
3652
+ }
3653
+ function humanizeSlug(slug) {
3654
+ return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
3655
+ }
3656
+ function resolveEntryTitle(data, options) {
3657
+ if (options?.schema) {
3658
+ const schemaTitle = extractTitleFromSchema(options.schema, data);
3659
+ if (schemaTitle)
3660
+ return schemaTitle;
3661
+ }
3662
+ const title = data.title ?? data.name;
3663
+ if (typeof title === "string")
3664
+ return title;
3665
+ if (options?.entryTypeLabel)
3666
+ return options.entryTypeLabel;
3667
+ return options?.slug ? humanizeSlug(options.slug) : "Untitled";
3668
+ }
3596
3669
  function isIndexSlug(slug) {
3597
3670
  return slug?.toLowerCase() === "index";
3598
3671
  }
@@ -4006,6 +4079,61 @@ function isValidSchema(schema) {
4006
4079
  const hasCollections = !!(schema.collections && schema.collections.length > 0);
4007
4080
  return hasEntries || hasCollections;
4008
4081
  }
4082
+ async function timeRequestPhase(phase, fn) {
4083
+ const parent = storage.getStore();
4084
+ if (!parent)
4085
+ return fn();
4086
+ const key = parent.path ? `${parent.path}>${phase}` : phase;
4087
+ let total = parent.phases.get(key);
4088
+ if (!total) {
4089
+ total = { count: 0, ms: 0 };
4090
+ parent.phases.set(key, total);
4091
+ }
4092
+ const start = performance.now();
4093
+ try {
4094
+ return await storage.run({ ...parent, path: key }, fn);
4095
+ } finally {
4096
+ total.count += 1;
4097
+ total.ms += performance.now() - start;
4098
+ }
4099
+ }
4100
+ function isNetworkRemoteUrl(url) {
4101
+ if (/^file:\/\//i.test(url))
4102
+ return false;
4103
+ if (NETWORK_SCHEME_PATTERN.test(url))
4104
+ return true;
4105
+ if (TRANSPORT_HELPER_PATTERN.test(url))
4106
+ return true;
4107
+ if (LEADING_OPTION_PATTERN.test(url))
4108
+ return true;
4109
+ if (WINDOWS_DRIVE_PATTERN.test(url))
4110
+ return false;
4111
+ return COLON_BEFORE_SLASH_PATTERN.test(url);
4112
+ }
4113
+ function isMissingRemoteRefFailure(message) {
4114
+ return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
4115
+ }
4116
+ async function stageAllExceptCanopyState(git) {
4117
+ await git.add(["-A"]);
4118
+ await git.raw(["reset", "-q", "--", CANOPY_META_DIR]);
4119
+ }
4120
+ async function detectHeadBranch(repoRoot, fallback = "main") {
4121
+ try {
4122
+ const git = simpleGit3({ baseDir: repoRoot });
4123
+ const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
4124
+ return head && head !== "HEAD" ? head : fallback;
4125
+ } catch {
4126
+ return fallback;
4127
+ }
4128
+ }
4129
+ async function resolveBaseBranch(options) {
4130
+ if (options.defaultBaseBranch)
4131
+ return options.defaultBaseBranch;
4132
+ if (options.mode === "dev") {
4133
+ return detectHeadBranch(options.detectFrom ?? process.cwd());
4134
+ }
4135
+ return "main";
4136
+ }
4009
4137
  async function isStaleByMtime(dir, cachedAt) {
4010
4138
  let entries;
4011
4139
  try {
@@ -4027,6 +4155,11 @@ async function isStaleByMtime(dir, cachedAt) {
4027
4155
  }
4028
4156
  return false;
4029
4157
  }
4158
+ async function schemaCacheDir(branchRoot) {
4159
+ const gitDir = path82.join(branchRoot, ".git");
4160
+ const isClone = await fs72.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
4161
+ return isClone ? path82.join(gitDir, "canopycms") : path82.join(branchRoot, CANOPY_META_DIR);
4162
+ }
4030
4163
  async function bumpContentIndexGeneration(root) {
4031
4164
  return bumpResourceGeneration(root, RESOURCE);
4032
4165
  }
@@ -4080,10 +4213,17 @@ function sanitizeBranchName(branchName) {
4080
4213
  const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
4081
4214
  return trimmedDots || "branch";
4082
4215
  }
4216
+ function isSettingsBranchName(branchName, settingsBranchName) {
4217
+ const sanitized = sanitizeBranchName(branchName);
4218
+ return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
4219
+ }
4083
4220
  function resolveBranchPath(options) {
4084
4221
  if (options.branchName.includes("..")) {
4085
4222
  throw new BranchPathError("Branch name cannot contain traversal segments");
4086
4223
  }
4224
+ if (isSettingsBranchName(options.branchName)) {
4225
+ throw new BranchPathError("Settings branches are not content branches");
4226
+ }
4087
4227
  const safeBranch = sanitizeBranchName(options.branchName);
4088
4228
  const strategy = operatingStrategy2(options.mode);
4089
4229
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
@@ -4663,7 +4803,7 @@ function resolveEntryLinksInText(text3, idIndex, contentRoot, customResolver) {
4663
4803
  return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
4664
4804
  const location = idIndex.findById(id);
4665
4805
  if (!location || location.type !== "entry" || !location.collection || !location.slug) {
4666
- log22.warn("resolve", `Entry link target not found: entry:${id}`);
4806
+ log3.warn("resolve", `Entry link target not found: entry:${id}`);
4667
4807
  return anchor ?? "#";
4668
4808
  }
4669
4809
  let url;
@@ -5118,7 +5258,7 @@ async function withOccFileLock(filePath, fn) {
5118
5258
  // Our critical sections are short and idempotent-on-conflict (OCC
5119
5259
  // verify inside); log and let the section finish.
5120
5260
  onCompromised: (err) => {
5121
- log4.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5261
+ log5.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5122
5262
  error: getErrorMessage3(err)
5123
5263
  });
5124
5264
  }
@@ -5140,43 +5280,10 @@ async function withOccFileLock(filePath, fn) {
5140
5280
  await release().catch((err) => {
5141
5281
  if (isNodeError3(err) && err.code === "ENOENT")
5142
5282
  return;
5143
- log4.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage3(err) });
5283
+ log5.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage3(err) });
5144
5284
  });
5145
5285
  }
5146
5286
  }
5147
- function isNetworkRemoteUrl(url) {
5148
- if (/^file:\/\//i.test(url))
5149
- return false;
5150
- if (NETWORK_SCHEME_PATTERN.test(url))
5151
- return true;
5152
- if (TRANSPORT_HELPER_PATTERN.test(url))
5153
- return true;
5154
- if (LEADING_OPTION_PATTERN.test(url))
5155
- return true;
5156
- if (WINDOWS_DRIVE_PATTERN.test(url))
5157
- return false;
5158
- return COLON_BEFORE_SLASH_PATTERN.test(url);
5159
- }
5160
- function isMissingRemoteRefFailure(message) {
5161
- return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
5162
- }
5163
- async function detectHeadBranch(repoRoot, fallback = "main") {
5164
- try {
5165
- const git = simpleGit3({ baseDir: repoRoot });
5166
- const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
5167
- return head && head !== "HEAD" ? head : fallback;
5168
- } catch {
5169
- return fallback;
5170
- }
5171
- }
5172
- async function resolveBaseBranch(options) {
5173
- if (options.defaultBaseBranch)
5174
- return options.defaultBaseBranch;
5175
- if (options.mode === "dev") {
5176
- return detectHeadBranch(options.detectFrom ?? process.cwd());
5177
- }
5178
- return "main";
5179
- }
5180
5287
  function gitChildEnv(overrides) {
5181
5288
  const env = {};
5182
5289
  for (const [key, value] of Object.entries(process.env)) {
@@ -5197,13 +5304,13 @@ async function ensureGitExcludePattern(repoPath, pattern) {
5197
5304
  }
5198
5305
  const lines = content.split("\n");
5199
5306
  if (lines.some((line) => line.trim() === pattern)) {
5200
- log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
5307
+ log6.debug("git", "Pattern already in .git/info/exclude", { pattern });
5201
5308
  return;
5202
5309
  }
5203
5310
  const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
5204
5311
  const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
5205
5312
  await fs16.writeFile(excludePath, newContent, "utf-8");
5206
- log5.debug("git", "Added pattern to .git/info/exclude", { pattern });
5313
+ log6.debug("git", "Added pattern to .git/info/exclude", { pattern });
5207
5314
  }
5208
5315
  async function loadOrCreateBranchContext(options) {
5209
5316
  if (readsFromCheckout(options.config)) {
@@ -5266,10 +5373,10 @@ async function listEntries(branchRoot, flatSchema, contentRootName, options, vis
5266
5373
  const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
5267
5374
  const shouldInclude = visibility?.shouldInclude;
5268
5375
  const skippedFiles = [];
5269
- const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName) : null;
5376
+ const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility) : null;
5270
5377
  const collectionResults = await Promise.all(collections.map(async (collection) => {
5271
5378
  const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
5272
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
5379
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
5273
5380
  const resolved = resolver ? await resolveCollectionItemReferences(visible, collection, resolver) : visible;
5274
5381
  return resolved.map((entry) => ({ entry, collection }));
5275
5382
  }));
@@ -5751,7 +5858,7 @@ async function generateAIContentCLI(options) {
5751
5858
  console.log(` Output: ${result.outputDir}
5752
5859
  `);
5753
5860
  }
5754
- var __defProp3, __getOwnPropNames3, __esm3, __export3, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize2, ROOT_COLLECTION_ID, init_types3, normalizePathValue, flattenSchema, init_flatten2, primitiveFieldTypes2, fieldTypes2, init_types22, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, assetMountUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned2, init_mode_env2, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned22, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, permissionTargetSchema2, pathPermissionSchema2, init_permissions2, init_helpers2, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, EVIDENCE_MAX_DEPTH, locks, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep, DEFAULT_SEO_FIELD_NAMES, SEO_GROUP_FIELDS, BASE58_PATTERN, CONTENT_ID_PATTERN, PHYSICAL_SEGMENT_PATTERN, isValidId, EMPTY_LOGICAL_PATH, ContentIdIndex, parseTypedFilename, CONTENT_EXTENSIONS, registry, finalization, LOG_LEVELS2, DebugLogger2, testLogger2, log2, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SCHEMA_GENERATION_RESOURCE, BranchSchemaCache, RESOURCE, getFormatExtension, isDataOnlyFormat, asRecord, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, FLAT_CELL_TYPES, log22, BASE58_CHAR, ENTRY_LINK_PATTERN, BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, branchMetadataFilePath, log3, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, BranchRegistry, log4, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, getBranchMetadataFileManager, loadBranchContext, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, log5, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, GitConflictError, GitRemoteRefMissingError, GitManager, log6, workspaceInitLocks, BranchWorkspaceManager, log7, normalizePath, readEntryData, createReferenceResolver, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
5861
+ var __defProp3, __getOwnPropNames3, __esm3, __export3, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize2, ROOT_COLLECTION_ID, init_types3, normalizePathValue, flattenSchema, init_flatten2, primitiveFieldTypes2, fieldTypes2, init_types22, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, assetMountUrlSchema2, previewPrefixSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned2, init_mode_env2, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned22, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, permissionTargetSchema2, pathPermissionSchema2, init_permissions2, init_helpers2, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, EVIDENCE_MAX_DEPTH, locks, PROVISIONING_LOCK_STALE_MS, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep, CONTENT_WRITE_LOCK_STALE_MS, DEFAULT_SEO_FIELD_NAMES, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, BASE58_PATTERN, CONTENT_ID_PATTERN, PHYSICAL_SEGMENT_PATTERN, isValidId, EMPTY_LOGICAL_PATH, ContentIdIndex, parseTypedFilename, CONTENT_EXTENSIONS, registry, finalization, LOG_LEVELS2, DebugLogger2, testLogger2, log2, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log22, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, BranchSchemaCache, RESOURCE, getFormatExtension, isDataOnlyFormat, asRecord, RESERVED_SETTINGS_BRANCH_PREFIX, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, FLAT_CELL_TYPES, log3, BASE58_CHAR, ENTRY_LINK_PATTERN, BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, branchMetadataFilePath, log4, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, BranchRegistry, log5, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, getBranchMetadataFileManager, loadBranchContext, log6, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, log7, workspaceInitLocks, BranchWorkspaceManager, log8, normalizePath, readEntryData, createReferenceResolver, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
5755
5862
  var init_generate_ai_content = __esm({
5756
5863
  "dist/cli/generate-ai-content.js"() {
5757
5864
  "use strict";
@@ -5975,7 +6082,8 @@ var init_generate_ai_content = __esm({
5975
6082
  });
5976
6083
  objectFieldSchema2 = fieldBaseSchema2.extend({
5977
6084
  type: z22.literal("object"),
5978
- fields: z22.array(z22.lazy(() => fieldHolder2[0])).min(1)
6085
+ fields: z22.array(z22.lazy(() => fieldHolder2[0])).min(1),
6086
+ itemTitleField: z22.string().min(1).optional()
5979
6087
  });
5980
6088
  inlineGroupFieldSchema2 = z22.object({
5981
6089
  type: z22.literal("group"),
@@ -6042,6 +6150,9 @@ var init_generate_ai_content = __esm({
6042
6150
  assetMountUrlSchema2 = z42.string().trim().refine((value) => isHttpUrlOrSameOriginPath2(value, { allowProtocolRelative: true }), {
6043
6151
  message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
6044
6152
  });
6153
+ previewPrefixSchema2 = z42.string().trim().refine((value) => isHttpUrlOrSameOriginPath2(value), {
6154
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
6155
+ });
6045
6156
  }
6046
6157
  });
6047
6158
  init_media2 = __esm3({
@@ -6091,6 +6202,7 @@ var init_generate_ai_content = __esm({
6091
6202
  "use strict";
6092
6203
  init_collection2();
6093
6204
  init_media2();
6205
+ init_url2();
6094
6206
  defaultBranchAccessSchema2 = z62.enum(["allow", "deny"]).default("deny");
6095
6207
  defaultPathAccessLevelSchema2 = z62.enum(["allow", "deny"]);
6096
6208
  defaultPathAccessLevelsSchema2 = z62.object({
@@ -6122,6 +6234,7 @@ var init_generate_ai_content = __esm({
6122
6234
  subtitle: z62.string().optional(),
6123
6235
  theme: z62.unknown().optional(),
6124
6236
  previewBase: z62.record(z62.string()).optional(),
6237
+ previewPrefix: previewPrefixSchema2.optional(),
6125
6238
  // UI handler functions (runtime only, don't serialize)
6126
6239
  onAccountClick: z62.function().returns(z62.void()).optional(),
6127
6240
  onLogoutClick: z62.function().returns(z62.void()).optional(),
@@ -6149,6 +6262,9 @@ var init_generate_ai_content = __esm({
6149
6262
  defaultRemoteUrl: defaultRemoteUrlSchema2.optional(),
6150
6263
  gitBotAuthorName: gitBotAuthorNameSchema2,
6151
6264
  gitBotAuthorEmail: gitBotAuthorEmailSchema2,
6265
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
6266
+ gitEditedByTrailers: z62.boolean().optional(),
6267
+ gitCoAuthoredByTrailers: z62.boolean().optional(),
6152
6268
  githubTokenEnvVar: githubTokenEnvVarSchema2.optional(),
6153
6269
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
6154
6270
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -6160,7 +6276,6 @@ var init_generate_ai_content = __esm({
6160
6276
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
6161
6277
  allowNetworkRemoteInProd: z62.boolean().optional(),
6162
6278
  settingsBranch: z62.string().optional(),
6163
- autoCreateSettingsPR: z62.boolean().optional(),
6164
6279
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
6165
6280
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
6166
6281
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -6377,9 +6492,6 @@ var init_generate_ai_content = __esm({
6377
6492
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
6378
6493
  }
6379
6494
  }
6380
- shouldCreateSettingsPR(config) {
6381
- return config.autoCreateSettingsPR ?? true;
6382
- }
6383
6495
  };
6384
6496
  DevStrategy2 = class extends DevClientSafeStrategy2 {
6385
6497
  getWorkspaceRoot(sourceRoot) {
@@ -6426,9 +6538,6 @@ var init_generate_ai_content = __esm({
6426
6538
  }
6427
6539
  validateConfig(_config) {
6428
6540
  }
6429
- shouldCreateSettingsPR(_config) {
6430
- return false;
6431
- }
6432
6541
  };
6433
6542
  strategyCache2 = /* @__PURE__ */ new Map();
6434
6543
  }
@@ -6458,18 +6567,21 @@ var init_generate_ai_content = __esm({
6458
6567
  locks = /* @__PURE__ */ new Map();
6459
6568
  init_logger2();
6460
6569
  init_logger2();
6570
+ PROVISIONING_LOCK_STALE_MS = 9e4;
6461
6571
  META_DIR = ".canopy-meta";
6462
6572
  CONTENT_WRITE_LOCK_NAME = "content-write.lock";
6463
6573
  DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2e3;
6464
6574
  ContentWriteLockBusyError = class extends Error {
6465
- constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.") {
6575
+ constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.", outcome = "not-run") {
6466
6576
  super(message);
6577
+ this.outcome = outcome;
6467
6578
  this.name = "ContentWriteLockBusyError";
6468
6579
  }
6469
6580
  };
6470
6581
  sleep = (ms) => new Promise((resolve2) => {
6471
6582
  setTimeout(resolve2, ms);
6472
6583
  });
6584
+ CONTENT_WRITE_LOCK_STALE_MS = 3e4;
6473
6585
  init_sanitize_href2();
6474
6586
  DEFAULT_SEO_FIELD_NAMES = {
6475
6587
  title: "metaTitle",
@@ -6480,6 +6592,7 @@ var init_generate_ai_content = __esm({
6480
6592
  noindex: "noindex",
6481
6593
  twitterCard: "twitterCard"
6482
6594
  };
6595
+ RESTRICTED_REFERENCE_MARKER = "unavailable";
6483
6596
  SEO_GROUP_FIELDS = [
6484
6597
  {
6485
6598
  name: DEFAULT_SEO_FIELD_NAMES.title,
@@ -6915,6 +7028,10 @@ var init_generate_ai_content = __esm({
6915
7028
  throw new Error(errorMsg);
6916
7029
  }
6917
7030
  }
7031
+ /**
7032
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
7033
+ * anything that can run concurrently uses {@link timed}.
7034
+ */
6918
7035
  time(label) {
6919
7036
  this.timers.set(label, Date.now());
6920
7037
  }
@@ -6929,12 +7046,16 @@ var init_generate_ai_content = __esm({
6929
7046
  this.debug(category, `${label} completed`, { durationMs: duration });
6930
7047
  return duration;
6931
7048
  }
7049
+ /**
7050
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
7051
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
7052
+ */
6932
7053
  async timed(category, label, fn) {
6933
- this.time(label);
7054
+ const start = Date.now();
6934
7055
  try {
6935
7056
  return await fn();
6936
7057
  } finally {
6937
- this.timeEnd(category, label);
7058
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
6938
7059
  }
6939
7060
  }
6940
7061
  };
@@ -7059,8 +7180,18 @@ var init_generate_ai_content = __esm({
7059
7180
  email: "static-deploy@canopycms",
7060
7181
  name: "Static Deploy"
7061
7182
  });
7183
+ storage = new AsyncLocalStorage();
7184
+ log22 = createDebugLogger2({ prefix: "CanopyCMS" });
7185
+ NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
7186
+ TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
7187
+ LEADING_OPTION_PATTERN = /^-/;
7188
+ WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
7189
+ COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
7190
+ MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
7191
+ CANOPY_META_DIR = ".canopy-meta";
7062
7192
  SCHEMA_CACHE_VERSION = 3;
7063
7193
  MTIME_CHECK_DEBOUNCE_MS = 1e3;
7194
+ SCHEMA_CACHE_FILE = "schema-cache.json";
7064
7195
  SCHEMA_GENERATION_RESOURCE = "schema";
7065
7196
  BranchSchemaCache = class {
7066
7197
  constructor(mode = "prod") {
@@ -7070,7 +7201,7 @@ var init_generate_ai_content = __esm({
7070
7201
  /**
7071
7202
  * Whether to skip the on-disk cache for this branchRoot.
7072
7203
  *
7073
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
7204
+ * Never write a schema cache at the project root, whichever entrypoint
7074
7205
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
7075
7206
  * synthetic contexts static deployments and build phases use; a real branch
7076
7207
  * root is always nested under the workspace.
@@ -7080,7 +7211,7 @@ var init_generate_ai_content = __esm({
7080
7211
  }
7081
7212
  /** Get schema for a branch, from the cache when fresh, else resolved fresh. */
7082
7213
  async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
7083
- return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
7214
+ return timeRequestPhase("schema", () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
7084
7215
  }
7085
7216
  /**
7086
7217
  * Resolve the schema from disk. Protected rather than a direct resolveSchema
@@ -7094,8 +7225,7 @@ var init_generate_ai_content = __esm({
7094
7225
  const contentRoot = path82.join(branchRoot, contentRootName);
7095
7226
  const skipDiskCache = this.skipDiskCache(branchRoot);
7096
7227
  if (!skipDiskCache) {
7097
- const cacheDir = path82.join(branchRoot, ".canopy-meta");
7098
- const cachePath = path82.join(cacheDir, "schema-cache.json");
7228
+ const cachePath = path82.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
7099
7229
  let cacheData = null;
7100
7230
  try {
7101
7231
  const cacheContent = await fs72.readFile(cachePath, "utf-8");
@@ -7137,7 +7267,7 @@ var init_generate_ai_content = __esm({
7137
7267
  const { skipDiskCache } = options;
7138
7268
  const contentRoot = path82.join(branchRoot, contentRootName);
7139
7269
  const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
7140
- const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
7270
+ const result = await timeRequestPhase("resolve", () => this.resolveFresh(contentRoot, entrySchemaRegistry));
7141
7271
  if (!isValidSchema(result.schema)) {
7142
7272
  throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
7143
7273
  }
@@ -7148,9 +7278,9 @@ var init_generate_ai_content = __esm({
7148
7278
  }
7149
7279
  const flatSchema = flattenSchema(result.schema, contentRootName);
7150
7280
  if (!skipDiskCache) {
7151
- const cacheDir = path82.join(branchRoot, ".canopy-meta");
7152
- const cachePath = path82.join(cacheDir, "schema-cache.json");
7153
- await fs72.unlink(path82.join(cacheDir, "schema-cache.stale")).catch(() => {
7281
+ const cacheDir = await schemaCacheDir(branchRoot);
7282
+ const cachePath = path82.join(cacheDir, SCHEMA_CACHE_FILE);
7283
+ await fs72.unlink(path82.join(branchRoot, CANOPY_META_DIR, "schema-cache.stale")).catch(() => {
7154
7284
  });
7155
7285
  if (read && read.ok) {
7156
7286
  const newCache = {
@@ -7217,6 +7347,7 @@ var init_generate_ai_content = __esm({
7217
7347
  asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7218
7348
  init_normalize2();
7219
7349
  init_operating_mode();
7350
+ RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
7220
7351
  BranchPathError = class extends Error {
7221
7352
  };
7222
7353
  resolveContentBranchesRoot = (mode, override) => {
@@ -7643,6 +7774,7 @@ var init_generate_ai_content = __esm({
7643
7774
  return {
7644
7775
  absolutePath: resolved,
7645
7776
  relativePath: path11.relative(this.root, resolved),
7777
+ logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
7646
7778
  id,
7647
7779
  entryTypeName: finalEntryTypeName,
7648
7780
  existed
@@ -7672,6 +7804,10 @@ var init_generate_ai_content = __esm({
7672
7804
  const schemaItem = this.assertSchemaItem(schemaPath);
7673
7805
  return await this.buildPaths(schemaItem, slug);
7674
7806
  }
7807
+ /**
7808
+ * Read one entry. References resolve unless `resolveReferences: false`; a target that
7809
+ * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
7810
+ */
7675
7811
  async read(collectionPath, slug = "", options = {}) {
7676
7812
  const schemaItem = this.assertSchemaItem(collectionPath);
7677
7813
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
@@ -7733,7 +7869,7 @@ var init_generate_ai_content = __esm({
7733
7869
  };
7734
7870
  }
7735
7871
  if (options.resolveReferences !== false) {
7736
- doc.data = await this.resolveReferencesInData(doc.data, fields);
7872
+ doc.data = await this.resolveReferencesInData(doc.data, fields, void 0, options.referenceAccess);
7737
7873
  }
7738
7874
  doc.version = stat.mtimeMs;
7739
7875
  return doc;
@@ -8187,20 +8323,25 @@ var init_generate_ai_content = __esm({
8187
8323
  * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
8188
8324
  * unmemoized behavior.
8189
8325
  *
8190
- * **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
8191
- * `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
8192
- * may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
8193
- * matched here on purpose so one rule covers both.
8326
+ * `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
8327
+ */
8328
+ async resolveReferences(data, fields, cache, access) {
8329
+ return this.resolveReferencesInData(data, fields, cache, access);
8330
+ }
8331
+ /**
8332
+ * Resolve one reference id the way a reference field resolves it, for the editor's
8333
+ * live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
8334
+ * when the id names no entry. No body is embedded, since there is no field to ask for one.
8194
8335
  */
8195
- async resolveReferences(data, fields, cache) {
8196
- return this.resolveReferencesInData(data, fields, cache);
8336
+ async resolveReferenceTarget(id, access) {
8337
+ return this.resolveSingleReference(id, await this.idIndex(), false, void 0, access);
8197
8338
  }
8198
- async resolveReferencesInData(data, fields, cache) {
8339
+ async resolveReferencesInData(data, fields, cache, access) {
8199
8340
  const resolved = { ...data };
8200
8341
  const idIndex = await this.idIndex();
8201
8342
  for (const field of fields) {
8202
8343
  if (field.type === "group") {
8203
- const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache);
8344
+ const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache, access);
8204
8345
  Object.assign(resolved, groupResolved);
8205
8346
  continue;
8206
8347
  }
@@ -8208,18 +8349,18 @@ var init_generate_ai_content = __esm({
8208
8349
  if (field.type === "reference") {
8209
8350
  const includeBody = field.includeBody === true;
8210
8351
  if (typeof value === "string" && value) {
8211
- resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
8352
+ resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache, access);
8212
8353
  } else if (field.list && Array.isArray(value)) {
8213
- resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache) : null));
8354
+ resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache, access) : null));
8214
8355
  }
8215
8356
  } else if (field.type === "object" && value) {
8216
8357
  const objectField = field;
8217
8358
  if (!objectField.fields)
8218
8359
  continue;
8219
8360
  if (objectField.list && Array.isArray(value)) {
8220
- resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache) : item));
8361
+ resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache, access) : item));
8221
8362
  } else if (typeof value === "object") {
8222
- resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
8363
+ resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache, access);
8223
8364
  }
8224
8365
  } else if (field.type === "block" && Array.isArray(value)) {
8225
8366
  const blockField = field;
@@ -8232,7 +8373,7 @@ var init_generate_ai_content = __esm({
8232
8373
  return block;
8233
8374
  return {
8234
8375
  ...b,
8235
- value: await this.resolveReferencesInData(b.value, template.fields, cache)
8376
+ value: await this.resolveReferencesInData(b.value, template.fields, cache, access)
8236
8377
  };
8237
8378
  }));
8238
8379
  }
@@ -8263,13 +8404,13 @@ var init_generate_ai_content = __esm({
8263
8404
  * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
8264
8405
  * cloned once per referencing entry.
8265
8406
  */
8266
- resolveSingleReference(id, idIndex, includeBody, cache) {
8407
+ resolveSingleReference(id, idIndex, includeBody, cache, access) {
8267
8408
  if (!cache)
8268
- return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
8409
+ return this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
8269
8410
  const key = includeBody ? `${id}:body` : id;
8270
8411
  let pending = cache.get(key);
8271
8412
  if (!pending) {
8272
- pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
8413
+ pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
8273
8414
  cache.set(key, pending);
8274
8415
  }
8275
8416
  return pending.then((resolved) => resolved === null ? null : structuredClone(resolved));
@@ -8287,15 +8428,27 @@ var init_generate_ai_content = __esm({
8287
8428
  * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
8288
8429
  * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
8289
8430
  */
8290
- async resolveSingleReferenceUncached(id, idIndex, includeBody) {
8291
- const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
8431
+ async resolveSingleReferenceUncached(id, idIndex, includeBody, access) {
8432
+ const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody, access);
8292
8433
  if (first !== STALE_LOOKUP)
8293
8434
  return first;
8294
8435
  await this.refreshIndexForSuspiciousLookup();
8295
- const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
8436
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody, access);
8296
8437
  return second === STALE_LOOKUP ? null : second;
8297
8438
  }
8298
- async resolveSingleReferenceOnce(id, idIndex, includeBody) {
8439
+ /**
8440
+ * The entry type an entry file was written as, by its filename's type token, falling back to
8441
+ * the collection's default the way `read()` does for a legacy untyped file.
8442
+ */
8443
+ entryTypeOf(collectionPath, relativePath) {
8444
+ const schemaItem = this.assertSchemaItem(collectionPath);
8445
+ if (schemaItem.type === "entry-type")
8446
+ return schemaItem;
8447
+ const entries = schemaItem.entries;
8448
+ const typeName = extractEntryTypeFromFilename(path11.basename(relativePath));
8449
+ return entries?.find((e) => e.name === typeName) ?? getDefaultEntryType(schemaItem.entries);
8450
+ }
8451
+ async resolveSingleReferenceOnce(id, idIndex, includeBody, access) {
8299
8452
  try {
8300
8453
  const location = idIndex.findById(id);
8301
8454
  if (!location)
@@ -8306,13 +8459,22 @@ var init_generate_ai_content = __esm({
8306
8459
  const doc = await this.read(location.collection, location.slug, {
8307
8460
  resolveReferences: false
8308
8461
  });
8309
- const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
8310
- return buildResolvedReference(doc.data, {
8462
+ const meta = {
8311
8463
  id,
8312
8464
  slug: location.slug,
8313
8465
  collection: location.collection,
8314
8466
  urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName)
8315
- }, bodyForEmbed);
8467
+ };
8468
+ if (access && !access(entryLogicalPath(location.collection, location.slug))) {
8469
+ const entryType = this.entryTypeOf(location.collection, location.relativePath);
8470
+ return buildRestrictedReference(meta, resolveEntryTitle(doc.data, {
8471
+ schema: entryType?.schema,
8472
+ entryTypeLabel: entryType?.label,
8473
+ slug: location.slug
8474
+ }));
8475
+ }
8476
+ const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
8477
+ return buildResolvedReference(doc.data, meta, bodyForEmbed);
8316
8478
  } catch (error) {
8317
8479
  if (isNodeError3(error) && error.code === "ENOENT")
8318
8480
  return STALE_LOOKUP;
@@ -8337,7 +8499,7 @@ var init_generate_ai_content = __esm({
8337
8499
  "reference",
8338
8500
  "image"
8339
8501
  ]);
8340
- log22 = createDebugLogger2({ prefix: "EntryLinks" });
8502
+ log3 = createDebugLogger2({ prefix: "EntryLinks" });
8341
8503
  BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
8342
8504
  ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
8343
8505
  BRANCH_META_DIR = ".canopy-meta";
@@ -8352,7 +8514,7 @@ var init_generate_ai_content = __esm({
8352
8514
  };
8353
8515
  branchMetadataFilePath = (branchRoot) => path13.join(path13.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
8354
8516
  init_logger2();
8355
- log3 = createDebugLogger2({ prefix: "BranchRegistry" });
8517
+ log4 = createDebugLogger2({ prefix: "BranchRegistry" });
8356
8518
  REGISTRY_FILE = "branches.json";
8357
8519
  REGISTRY_STALE_FILE = "branches.stale.json";
8358
8520
  REGISTRY_TEMP_FILE = "branches.tmp.json";
@@ -8432,7 +8594,7 @@ var init_generate_ai_content = __esm({
8432
8594
  });
8433
8595
  await this.regenerate();
8434
8596
  } catch (err) {
8435
- log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
8597
+ log4.warn("invalidate", "Eager regeneration after invalidate() failed", {
8436
8598
  error: getErrorMessage3(err)
8437
8599
  });
8438
8600
  }
@@ -8517,7 +8679,7 @@ var init_generate_ai_content = __esm({
8517
8679
  return branches;
8518
8680
  }
8519
8681
  };
8520
- log4 = createDebugLogger2({ prefix: "OccJsonWrite" });
8682
+ log5 = createDebugLogger2({ prefix: "OccJsonWrite" });
8521
8683
  OccWriteConflictError = class extends Error {
8522
8684
  constructor(message = "Concurrent modification detected") {
8523
8685
  super(message);
@@ -8687,13 +8849,7 @@ var init_generate_ai_content = __esm({
8687
8849
  };
8688
8850
  };
8689
8851
  init_operating_mode();
8690
- NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
8691
- TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
8692
- LEADING_OPTION_PATTERN = /^-/;
8693
- WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
8694
- COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
8695
- MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
8696
- log5 = createDebugLogger2({ prefix: "GitManager" });
8852
+ log6 = createDebugLogger2({ prefix: "GitManager" });
8697
8853
  GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|GIT_TERMINAL_PROMPT|GIT_TRACE[0-9A-Z_]*)$/;
8698
8854
  FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
8699
8855
  remoteInitLocks = /* @__PURE__ */ new Map();
@@ -8714,6 +8870,41 @@ var init_generate_ai_content = __esm({
8714
8870
  this.name = "GitRemoteRefMissingError";
8715
8871
  }
8716
8872
  };
8873
+ RemoteNotReadyError = class extends Error {
8874
+ constructor(expectedRemotePath) {
8875
+ super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
8876
+ this.expectedRemotePath = expectedRemotePath;
8877
+ this.name = "RemoteNotReadyError";
8878
+ }
8879
+ };
8880
+ SettingsRemoteUnreadableError = class extends Error {
8881
+ constructor(branch, remote, gitError) {
8882
+ super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so the settings workspace was not provisioned: ${getErrorMessage3(gitError)}`);
8883
+ this.branch = branch;
8884
+ this.remote = remote;
8885
+ this.gitError = gitError;
8886
+ this.name = "SettingsRemoteUnreadableError";
8887
+ }
8888
+ };
8889
+ SettingsBranchDivergedError = class extends Error {
8890
+ constructor(branch, workspacePath, localWork) {
8891
+ super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy is chosen. To keep the remote's settings: copy anything you need out of ${workspacePath}, move it aside, and restart; the next start checks out the remote's '${branch}', and copied changes can then be re-applied in the editor.`);
8892
+ this.branch = branch;
8893
+ this.workspacePath = workspacePath;
8894
+ this.localWork = localWork;
8895
+ this.name = "SettingsBranchDivergedError";
8896
+ }
8897
+ };
8898
+ SETTINGS_BRANCH_FILES = /* @__PURE__ */ new Set(["permissions.json", "groups.json"]);
8899
+ SettingsBranchHasContentHistoryError = class extends Error {
8900
+ constructor(branch, remote, evidence) {
8901
+ super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote from a copy of the settings (it may hold only permissions.json and groups.json), then restart.`);
8902
+ this.branch = branch;
8903
+ this.remote = remote;
8904
+ this.evidence = evidence;
8905
+ this.name = "SettingsBranchHasContentHistoryError";
8906
+ }
8907
+ };
8717
8908
  GitManager = class _GitManager {
8718
8909
  constructor(options, gitOptions) {
8719
8910
  this.repoPath = path17.resolve(options.repoPath);
@@ -8724,14 +8915,14 @@ var init_generate_ai_content = __esm({
8724
8915
  this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(this.repoPath) }));
8725
8916
  }
8726
8917
  static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
8727
- log5.debug("git", "Cloning repository", {
8918
+ log6.debug("git", "Cloning repository", {
8728
8919
  remoteUrl,
8729
8920
  targetPath,
8730
8921
  baseBranch
8731
8922
  });
8732
8923
  const git = simpleGit22();
8733
8924
  await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
8734
- log5.debug("git", "Clone complete");
8925
+ log6.debug("git", "Clone complete");
8735
8926
  }
8736
8927
  /**
8737
8928
  * Initializes a local bare git repository to simulate a remote for dev mode,
@@ -8745,15 +8936,15 @@ var init_generate_ai_content = __esm({
8745
8936
  static async ensureLocalSimulatedRemote(options) {
8746
8937
  const existingLock = remoteInitLocks.get(options.remotePath);
8747
8938
  if (existingLock) {
8748
- log5.debug("git", "Waiting for existing remote initialization", {
8939
+ log6.debug("git", "Waiting for existing remote initialization", {
8749
8940
  remotePath: options.remotePath
8750
8941
  });
8751
8942
  await existingLock;
8752
8943
  }
8753
- const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
8944
+ const lockPromise = log6.timed("git", "ensureLocalSimulatedRemote", async () => {
8754
8945
  let releaseLock;
8755
8946
  try {
8756
- log5.debug("git", "Initializing local simulated remote", {
8947
+ log6.debug("git", "Initializing local simulated remote", {
8757
8948
  remotePath: options.remotePath,
8758
8949
  baseBranch: options.baseBranch
8759
8950
  });
@@ -8768,7 +8959,7 @@ var init_generate_ai_content = __esm({
8768
8959
  throw err;
8769
8960
  }
8770
8961
  if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
8771
- log5.debug("git", "Remote already has base branch, skipping");
8962
+ log6.debug("git", "Remote already has base branch, skipping");
8772
8963
  return;
8773
8964
  }
8774
8965
  let gitRoot = options.sourcePath;
@@ -8787,8 +8978,8 @@ var init_generate_ai_content = __esm({
8787
8978
  }
8788
8979
  let hasCommits = false;
8789
8980
  try {
8790
- const log82 = await sourceGit.log(["-1"]);
8791
- hasCommits = log82.total > 0;
8981
+ const log92 = await sourceGit.log(["-1"]);
8982
+ hasCommits = log92.total > 0;
8792
8983
  } catch {
8793
8984
  hasCommits = false;
8794
8985
  }
@@ -8800,12 +8991,12 @@ var init_generate_ai_content = __esm({
8800
8991
  throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
8801
8992
  }
8802
8993
  if (remoteExists) {
8803
- log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
8994
+ log6.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
8804
8995
  remotePath: options.remotePath,
8805
8996
  baseBranch: options.baseBranch
8806
8997
  });
8807
8998
  } else {
8808
- log5.debug("git", "Creating bare remote repository");
8999
+ log6.debug("git", "Creating bare remote repository");
8809
9000
  await simpleGit22().raw([
8810
9001
  "init",
8811
9002
  "--bare",
@@ -8819,13 +9010,13 @@ var init_generate_ai_content = __esm({
8819
9010
  baseBranch: options.baseBranch,
8820
9011
  subdirectory: options.subdirectory
8821
9012
  });
8822
- log5.debug("git", "Remote initialization complete");
9013
+ log6.debug("git", "Remote initialization complete");
8823
9014
  } finally {
8824
9015
  if (releaseLock) {
8825
9016
  try {
8826
9017
  await releaseLock();
8827
9018
  } catch (err) {
8828
- log5.debug("git", "Failed to release remote-init lock", { err });
9019
+ log6.debug("git", "Failed to release remote-init lock", { err });
8829
9020
  }
8830
9021
  }
8831
9022
  remoteInitLocks.delete(options.remotePath);
@@ -8893,7 +9084,7 @@ var init_generate_ai_content = __esm({
8893
9084
  * deleteBranchHandler: a head left in `remote.git` forever makes the
8894
9085
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
8895
9086
  * the reused branch's first publish non-fast-forward against the stale head
8896
- * (`GitManager.push()` pushes `branch:branch`, and a squash-merged old tip is
9087
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
8897
9088
  * not an ancestor of the new branch), and a retried submit then skips the
8898
9089
  * local push on a clean tree and enqueues the worker push of the STALE head,
8899
9090
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -9024,6 +9215,12 @@ var init_generate_ai_content = __esm({
9024
9215
  return;
9025
9216
  throw new Error(`CanopyCMS: refusing to use a network remote URL in prod mode (from ${source}: "${url}"). The standard AWS Lambda+EC2-worker topology runs the CMS Lambda with no internet access \u2014 the EC2 worker owns all network git I/O, and the Lambda is expected to reach the EFS-local bare repo ({workspace}/remote.git) via auto-detect instead. Pointing a network URL here would make the internet-less Lambda try to clone/fetch/push it directly and hang until timeout. If this prod host genuinely has internet access and intentionally runs git against a network remote (e.g. a single-VM deployment), set config.allowNetworkRemoteInProd: true to acknowledge this.`);
9026
9217
  }
9218
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
9219
+ static async remoteNotReadyError(mode) {
9220
+ const { operatingStrategy: operatingStrategy22 } = await Promise.resolve().then(() => (init_operating_mode(), operating_mode_exports));
9221
+ const { autoDetectRemotePath } = operatingStrategy22(mode).getRemoteUrlConfig();
9222
+ return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : void 0;
9223
+ }
9027
9224
  /**
9028
9225
  * Resolves the remote URL for git operations following the priority:
9029
9226
  * 1. Explicit remoteUrl parameter
@@ -9060,7 +9257,7 @@ var init_generate_ai_content = __esm({
9060
9257
  try {
9061
9258
  const stat = await fs16.stat(config.autoDetectRemotePath);
9062
9259
  if (stat.isDirectory()) {
9063
- log5.debug("git", "Auto-detected local remote", {
9260
+ log6.debug("git", "Auto-detected local remote", {
9064
9261
  path: config.autoDetectRemotePath
9065
9262
  });
9066
9263
  return config.autoDetectRemotePath;
@@ -9124,7 +9321,7 @@ var init_generate_ai_content = __esm({
9124
9321
  try {
9125
9322
  const stat = await fs16.stat(gitPath);
9126
9323
  if (stat.isDirectory()) {
9127
- log5.debug("git", "Removing corrupt .git directory", {
9324
+ log6.debug("git", "Removing corrupt .git directory", {
9128
9325
  workspacePath: options.workspacePath
9129
9326
  });
9130
9327
  await fs16.rm(gitPath, { recursive: true });
@@ -9135,6 +9332,7 @@ var init_generate_ai_content = __esm({
9135
9332
  }
9136
9333
  }
9137
9334
  let justCloned = false;
9335
+ let resolvedRemoteUrl;
9138
9336
  if (!repoExists) {
9139
9337
  const remoteUrl = await _GitManager.resolveRemoteUrl({
9140
9338
  mode: options.mode,
@@ -9145,6 +9343,9 @@ var init_generate_ai_content = __esm({
9145
9343
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
9146
9344
  });
9147
9345
  if (!remoteUrl) {
9346
+ const notReady = await _GitManager.remoteNotReadyError(options.mode);
9347
+ if (notReady)
9348
+ throw notReady;
9148
9349
  throw new Error("CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace");
9149
9350
  }
9150
9351
  try {
@@ -9153,6 +9354,7 @@ var init_generate_ai_content = __esm({
9153
9354
  throw new Error(`Failed to clone branch workspace at ${options.workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage3(err)}`);
9154
9355
  }
9155
9356
  justCloned = true;
9357
+ resolvedRemoteUrl = remoteUrl;
9156
9358
  const freshGit = simpleGit22({ baseDir: options.workspacePath });
9157
9359
  freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(options.workspacePath) }));
9158
9360
  await freshGit.addConfig("canopycms.managed", "true");
@@ -9168,7 +9370,7 @@ var init_generate_ai_content = __esm({
9168
9370
  await git.git.addConfig("canopycms.managed", "true");
9169
9371
  await git.git.addConfig("user.name", options.gitBotAuthorName);
9170
9372
  await git.git.addConfig("user.email", options.gitBotAuthorEmail);
9171
- log5.debug("git", "Marked workspace as CanopyCMS-managed", {
9373
+ log6.debug("git", "Marked workspace as CanopyCMS-managed", {
9172
9374
  workspacePath: options.workspacePath
9173
9375
  });
9174
9376
  if (!justCloned) {
@@ -9183,9 +9385,17 @@ var init_generate_ai_content = __esm({
9183
9385
  if (remoteUrl) {
9184
9386
  await git.ensureRemote(remoteUrl);
9185
9387
  }
9388
+ resolvedRemoteUrl = remoteUrl;
9186
9389
  }
9187
9390
  if (options.branchType === "orphan") {
9188
- await git.createOrphanSettingsBranch(options.branchName, {});
9391
+ try {
9392
+ await git.createOrphanSettingsBranch(options.branchName, {});
9393
+ } catch (err) {
9394
+ if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
9395
+ throw await _GitManager.remoteNotReadyError(options.mode) ?? err;
9396
+ }
9397
+ throw err;
9398
+ }
9189
9399
  await git.ensureGitExclude("*.lock");
9190
9400
  } else {
9191
9401
  await git.checkoutBranch(options.branchName);
@@ -9294,7 +9504,7 @@ var init_generate_ai_content = __esm({
9294
9504
  const branches = await this.git.branch();
9295
9505
  const currentBranch = branches.current;
9296
9506
  try {
9297
- await this.git.fetch(this.remote, currentBranch);
9507
+ await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
9298
9508
  } catch (err) {
9299
9509
  if (!isMissingRemoteRefFailure(getErrorMessage3(err)))
9300
9510
  throw err;
@@ -9351,19 +9561,34 @@ var init_generate_ai_content = __esm({
9351
9561
  const fileArray = Array.isArray(files) ? files : [files];
9352
9562
  await this.git.add(fileArray);
9353
9563
  }
9564
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
9565
+ async addAllExceptCanopyState() {
9566
+ await stageAllExceptCanopyState(this.git);
9567
+ }
9354
9568
  async commit(message) {
9355
9569
  await this.git.commit(message);
9356
9570
  }
9357
9571
  async push(branch) {
9358
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
9572
+ const target = branch ?? await this.currentBranchName();
9359
9573
  await this.git.raw([
9360
9574
  "push",
9361
9575
  "--set-upstream",
9362
9576
  "--end-of-options",
9363
9577
  this.remote,
9364
- `${target}:${target}`
9578
+ `refs/heads/${target}:refs/heads/${target}`
9365
9579
  ]);
9366
9580
  }
9581
+ /**
9582
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
9583
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
9584
+ */
9585
+ async currentBranchName() {
9586
+ const branches = await this.git.branch();
9587
+ if (branches.detached || !branches.current) {
9588
+ throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
9589
+ }
9590
+ return branches.current;
9591
+ }
9367
9592
  /**
9368
9593
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
9369
9594
  * whether push() would move the remote ref forward.
@@ -9382,12 +9607,12 @@ var init_generate_ai_content = __esm({
9382
9607
  * ("couldn't find remote ref"); that counts as "ahead", not an error.
9383
9608
  */
9384
9609
  async hasUnpushedCommits(branch) {
9385
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "--end-of-options", "HEAD"]);
9386
- const localSha = (await this.git.revparse(["--end-of-options", target])).trim();
9610
+ const target = branch ?? await this.git.revparse(["--verify", "--abbrev-ref", "--end-of-options", "HEAD"]);
9611
+ const localSha = (await this.git.revparse(["--verify", "--end-of-options", target])).trim();
9387
9612
  let fetchedTip;
9388
9613
  try {
9389
9614
  await this.git.raw(["fetch", "--end-of-options", this.remote, target]);
9390
- fetchedTip = (await this.git.revparse(["--end-of-options", "FETCH_HEAD"])).trim();
9615
+ fetchedTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
9391
9616
  } catch {
9392
9617
  return true;
9393
9618
  }
@@ -9396,6 +9621,25 @@ var init_generate_ai_content = __esm({
9396
9621
  const aheadCount = (await this.git.raw(["rev-list", "--count", `${fetchedTip}..${localSha}`])).trim();
9397
9622
  return aheadCount !== "0";
9398
9623
  }
9624
+ /**
9625
+ * Repo-relative paths that differ between HEAD and its merge base with the
9626
+ * base branch: everything the branch changes, across all of its commits.
9627
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
9628
+ * constraint.
9629
+ */
9630
+ async listChangedPathsSinceBase() {
9631
+ await this.git.raw(["fetch", "--end-of-options", this.remote, this.baseBranch]);
9632
+ const baseTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
9633
+ const output = await this.git.raw([
9634
+ "diff",
9635
+ "--name-only",
9636
+ "-z",
9637
+ "--no-renames",
9638
+ `${baseTip}...HEAD`,
9639
+ "--"
9640
+ ]);
9641
+ return output.split("\0").filter((p5) => p5.length > 0);
9642
+ }
9399
9643
  async ensureAuthor(author) {
9400
9644
  const config = await this.git.listConfig();
9401
9645
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -9449,10 +9693,24 @@ var init_generate_ai_content = __esm({
9449
9693
  await ensureGitExcludePattern(this.repoPath, pattern);
9450
9694
  }
9451
9695
  /**
9452
- * Create an orphan branch (no shared history) for settings, so
9453
- * deployment-specific settings never pollute content history. It holds only
9454
- * settings files committed by explicit path (permissions.json, groups.json at
9455
- * the workspace root).
9696
+ * Put the workspace on the settings branch: an orphan (no shared history),
9697
+ * so deployment-specific settings never pollute content history. It holds
9698
+ * only settings files committed by explicit path (permissions.json,
9699
+ * groups.json at the workspace root).
9700
+ *
9701
+ * The remote is the settings branch's durable copy, and the workspace clone
9702
+ * is `--single-branch` at the base branch, so the remote is asked first:
9703
+ * - no local branch: check out the remote's, or create an empty orphan only
9704
+ * when the remote has none. An unreadable remote throws
9705
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
9706
+ * - local branch present: a history unrelated to the remote's is repaired
9707
+ * onto the remote's when the local branch is still its empty initial
9708
+ * commit with a clean working tree, and otherwise throws
9709
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
9710
+ * An unreadable remote throws only for that empty branch, since one that
9711
+ * holds settings can serve them and the next save surfaces the remote error.
9712
+ * Either way a remote branch holding content is never adopted:
9713
+ * {@link SettingsBranchHasContentHistoryError}.
9456
9714
  */
9457
9715
  async createOrphanSettingsBranch(branchName, initialFiles) {
9458
9716
  try {
@@ -9462,11 +9720,31 @@ var init_generate_ai_content = __esm({
9462
9720
  }
9463
9721
  }
9464
9722
  async createOrphanSettingsBranchInner(branchName, initialFiles) {
9465
- log5.debug("git", "Creating orphan settings branch", { branchName });
9723
+ log6.debug("git", "Ensuring settings branch", { branchName });
9466
9724
  const branches = await this.git.branch();
9467
- if (branches.all.includes(branchName)) {
9468
- log5.debug("git", "Orphan branch already exists", { branchName });
9725
+ const localExists = branches.all.includes(branchName);
9726
+ if (localExists) {
9469
9727
  await this.git.checkout(branchName);
9728
+ }
9729
+ let remoteTip;
9730
+ try {
9731
+ remoteTip = await this.remoteBranchTip(branchName);
9732
+ } catch (err) {
9733
+ if (localExists && !await this.isEmptyInitialBranch(branchName)) {
9734
+ log6.debug("git", "Remote unreadable; serving the local settings branch", { branchName });
9735
+ return;
9736
+ }
9737
+ throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
9738
+ }
9739
+ if (localExists) {
9740
+ await this.reconcileLocalSettingsBranch(branchName, remoteTip);
9741
+ return;
9742
+ }
9743
+ if (remoteTip) {
9744
+ const fetchedTip = await this.fetchBranchTip(branchName);
9745
+ await this.assertNotContentHistory(branchName, fetchedTip);
9746
+ await this.git.raw(["checkout", "-b", branchName, fetchedTip]);
9747
+ log6.debug("git", "Checked out the remote settings branch", { branchName });
9470
9748
  return;
9471
9749
  }
9472
9750
  await this.git.raw(["checkout", "--orphan", branchName]);
@@ -9481,17 +9759,127 @@ var init_generate_ai_content = __esm({
9481
9759
  await this.git.add(filePath);
9482
9760
  }
9483
9761
  await this.git.commit("Initialize settings branch", ["--allow-empty"]);
9484
- log5.debug("git", "Orphan settings branch created", { branchName });
9762
+ log6.debug("git", "Orphan settings branch created", { branchName });
9763
+ }
9764
+ /**
9765
+ * Compares the checked-out local settings branch with the remote's tip.
9766
+ * Related histories are left for the next settings pull to reconcile; only
9767
+ * an unrelated one is acted on here.
9768
+ */
9769
+ async reconcileLocalSettingsBranch(branchName, remoteTip) {
9770
+ if (!remoteTip)
9771
+ return;
9772
+ const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
9773
+ if (localTip === remoteTip)
9774
+ return;
9775
+ const fetchedTip = await this.fetchBranchTip(branchName);
9776
+ const [localRoots, remoteRoots] = await Promise.all([
9777
+ this.rootCommits(localTip),
9778
+ this.rootCommits(fetchedTip)
9779
+ ]);
9780
+ if (localRoots.some((root) => remoteRoots.includes(root)))
9781
+ return;
9782
+ const localWork = [];
9783
+ if (!await this.isEmptyInitialBranch(branchName)) {
9784
+ localWork.push("commits beyond its empty initial commit");
9785
+ }
9786
+ const uncommitted = await this.getUncommittedFiles();
9787
+ if (uncommitted.length > 0) {
9788
+ localWork.push(`uncommitted changes to ${uncommitted.join(", ")}`);
9789
+ }
9790
+ if (localWork.length > 0) {
9791
+ throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(" and "));
9792
+ }
9793
+ await this.assertNotContentHistory(branchName, fetchedTip);
9794
+ await this.git.raw(["checkout", "-B", branchName, fetchedTip]);
9795
+ log6.debug("git", "Replaced an empty settings branch with the remote one", { branchName });
9796
+ }
9797
+ /**
9798
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
9799
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
9800
+ * output only for a reachable remote without the ref.
9801
+ */
9802
+ async remoteBranchTip(branch) {
9803
+ const ref = `refs/heads/${branch}`;
9804
+ const output = await this.git.raw(["ls-remote", "--heads", this.remote, ref]);
9805
+ for (const line of output.split("\n")) {
9806
+ const [sha, name] = line.trim().split(" ");
9807
+ if (sha && name === ref)
9808
+ return sha;
9809
+ }
9810
+ return void 0;
9811
+ }
9812
+ /**
9813
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
9814
+ * pullBaseInner explains. The full ref: a bare name resolves to a
9815
+ * same-named tag first.
9816
+ */
9817
+ async fetchBranchTip(branch) {
9818
+ await this.git.fetch(this.remote, `refs/heads/${branch}`);
9819
+ return (await this.git.revparse(["FETCH_HEAD"])).trim();
9820
+ }
9821
+ /**
9822
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
9823
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
9824
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
9825
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
9826
+ */
9827
+ async assertNotContentHistory(branchName, tip) {
9828
+ const tree = await this.git.raw(["ls-tree", "-r", "--name-only", tip]);
9829
+ const strays = tree.split("\n").filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
9830
+ if (strays.length > 0) {
9831
+ const shown = strays.slice(0, 3).join(", ") + (strays.length > 3 ? ", \u2026" : "");
9832
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
9833
+ }
9834
+ const [tipRoots, baseRoots] = await Promise.all([
9835
+ this.rootCommits(tip),
9836
+ this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev))
9837
+ ]);
9838
+ if (tipRoots.some((root) => baseRoots.includes(root))) {
9839
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
9840
+ }
9841
+ }
9842
+ async baseBranchRev(settingsBranch) {
9843
+ for (const ref of [
9844
+ `refs/heads/${this.baseBranch}`,
9845
+ `refs/remotes/${this.remote}/${this.baseBranch}`
9846
+ ]) {
9847
+ const sha = await this.git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
9848
+ if (sha.trim())
9849
+ return sha.trim();
9850
+ }
9851
+ try {
9852
+ return await this.fetchBranchTip(this.baseBranch);
9853
+ } catch (err) {
9854
+ throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote '${this.remote}', so settings branch '${settingsBranch}' was not checked out: ${getErrorMessage3(err)}`);
9855
+ }
9856
+ }
9857
+ async rootCommits(rev) {
9858
+ const output = await this.git.raw(["rev-list", "--max-parents=0", rev]);
9859
+ return output.split("\n").filter(Boolean);
9860
+ }
9861
+ /**
9862
+ * Whether `branch` is a single parentless commit with an empty tree, as
9863
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
9864
+ * same-named tag first, and clones fetch tags.
9865
+ */
9866
+ async isEmptyInitialBranch(branch) {
9867
+ const ref = `refs/heads/${branch}`;
9868
+ const commits = await this.git.raw(["rev-list", "--max-count=2", ref]);
9869
+ if (commits.split("\n").filter(Boolean).length !== 1)
9870
+ return false;
9871
+ const tree = await this.git.raw(["ls-tree", ref]);
9872
+ return tree.trim() === "";
9485
9873
  }
9486
9874
  };
9487
- log6 = createDebugLogger2({ prefix: "BranchWorkspace" });
9875
+ log7 = createDebugLogger2({ prefix: "BranchWorkspace" });
9488
9876
  workspaceInitLocks = /* @__PURE__ */ new Map();
9489
9877
  BranchWorkspaceManager = class {
9490
9878
  constructor(config) {
9491
9879
  this.config = config;
9492
9880
  }
9493
9881
  async ensureGitWorkspace(options) {
9494
- return log6.timed("workspace", "ensureGitWorkspace", async () => {
9882
+ return log7.timed("workspace", "ensureGitWorkspace", async () => {
9495
9883
  const existingLock = workspaceInitLocks.get(options.branchRoot);
9496
9884
  if (existingLock) {
9497
9885
  await existingLock;
@@ -9500,11 +9888,11 @@ var init_generate_ai_content = __esm({
9500
9888
  const lockPromise = (async () => {
9501
9889
  let releaseLock;
9502
9890
  try {
9503
- log6.debug("workspace", "Ensuring git workspace", {
9891
+ log7.debug("workspace", "Ensuring git workspace", {
9504
9892
  branchName: options.branchName,
9505
9893
  mode: options.mode
9506
9894
  });
9507
- releaseLock = await acquireProvisioningLock(path18.dirname(options.branchRoot), `.${path18.basename(options.branchRoot)}.init.lock`);
9895
+ releaseLock = await acquireProvisioningLock(path18.dirname(options.branchRoot), branchProvisioningLockName(path18.basename(options.branchRoot)));
9508
9896
  await GitManager.initializeWorkspace({
9509
9897
  workspacePath: options.branchRoot,
9510
9898
  branchName: options.branchName,
@@ -9525,7 +9913,7 @@ var init_generate_ai_content = __esm({
9525
9913
  try {
9526
9914
  await releaseLock();
9527
9915
  } catch (err) {
9528
- log6.debug("workspace", "Failed to release workspace-init lock", { err });
9916
+ log7.debug("workspace", "Failed to release workspace-init lock", { err });
9529
9917
  }
9530
9918
  }
9531
9919
  workspaceInitLocks.delete(options.branchRoot);
@@ -9537,6 +9925,9 @@ var init_generate_ai_content = __esm({
9537
9925
  }
9538
9926
  async openOrCreateBranch(options) {
9539
9927
  const { branchName, mode, basePathOverride, title, description, access, createdBy, remoteUrl } = options;
9928
+ if (isSettingsBranchName(branchName, operatingStrategy2(mode).getSettingsBranchName(this.config))) {
9929
+ throw new BranchPathError("Settings branches are not content branches");
9930
+ }
9540
9931
  const { branchRoot, baseRoot, branchName: safeName } = await ensureBranchRoot({
9541
9932
  mode,
9542
9933
  branchName,
@@ -9572,7 +9963,7 @@ var init_generate_ai_content = __esm({
9572
9963
  };
9573
9964
  }
9574
9965
  };
9575
- log7 = createDebugLogger2({ prefix: "ContentListing" });
9966
+ log8 = createDebugLogger2({ prefix: "ContentListing" });
9576
9967
  normalizePath = (root, target) => {
9577
9968
  const result = validateAndNormalizePath(root, target);
9578
9969
  if (!result.valid) {
@@ -9598,13 +9989,17 @@ var init_generate_ai_content = __esm({
9598
9989
  } catch (err) {
9599
9990
  if (isNotFoundError3(err))
9600
9991
  return {};
9601
- log7.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage3(err)}`);
9992
+ log8.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage3(err)}`);
9602
9993
  return {};
9603
9994
  }
9604
9995
  };
9605
- createReferenceResolver = (branchRoot, flatSchema, contentRootName) => ({
9996
+ createReferenceResolver = (branchRoot, flatSchema, contentRootName, visibility) => ({
9606
9997
  store: new ContentStore(branchRoot, flatSchema, { contentRootName }),
9607
- cache: createReferenceResolveCache()
9998
+ cache: createReferenceResolveCache(),
9999
+ // The predicate that hides an entry from the listing is the one that restricts it as a
10000
+ // reference target, so a target is never shown in a sibling's data that its own listing
10001
+ // row would have hidden.
10002
+ access: visibility?.shouldInclude
9608
10003
  });
9609
10004
  resolveCollectionItemReferences = async (items, collection, resolver) => Promise.all(items.map(async (item) => {
9610
10005
  const fields = collection.entries?.find((e) => e.name === item.entryType)?.schema;
@@ -9612,7 +10007,7 @@ var init_generate_ai_content = __esm({
9612
10007
  return item;
9613
10008
  return {
9614
10009
  ...item,
9615
- data: await resolver.store.resolveReferences(item.data, fields, resolver.cache)
10010
+ data: await resolver.store.resolveReferences(item.data, fields, resolver.cache, resolver.access)
9616
10011
  };
9617
10012
  }));
9618
10013
  looksLikeMalformedEntry = (filename, entryTypes) => {
@@ -9655,7 +10050,7 @@ var init_generate_ai_content = __esm({
9655
10050
  const relativePath = normalizePath(root, absolutePath);
9656
10051
  const parsed = parseTypedFilename(file.name, entryTypes);
9657
10052
  if (!parsed) {
9658
- log7.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
10053
+ log8.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
9659
10054
  if (looksLikeMalformedEntry(file.name, entryTypes)) {
9660
10055
  onSkip?.({ filename: file.name, collectionPath: collection.logicalPath });
9661
10056
  }
@@ -9670,7 +10065,7 @@ var init_generate_ai_content = __esm({
9670
10065
  readEntryData(absolutePath, format, bodyField)
9671
10066
  ]);
9672
10067
  const item = {
9673
- logicalPath: `${collection.logicalPath}/${slug}`,
10068
+ logicalPath: entryLogicalPath(collection.logicalPath, slug),
9674
10069
  contentId,
9675
10070
  slug,
9676
10071
  collectionPath: collection.logicalPath,
@@ -9697,6 +10092,23 @@ var init_generate_ai_content = __esm({
9697
10092
  }
9698
10093
  });
9699
10094
 
10095
+ // dist/utils/git.js
10096
+ import { simpleGit as simpleGit4 } from "simple-git";
10097
+ function isCanopyInternalPath(repoRelativePath) {
10098
+ return repoRelativePath === CANOPY_META_DIR2 || repoRelativePath.startsWith(`${CANOPY_META_DIR2}/`);
10099
+ }
10100
+ async function stageAllExceptCanopyState2(git) {
10101
+ await git.add(["-A"]);
10102
+ await git.raw(["reset", "-q", "--", CANOPY_META_DIR2]);
10103
+ }
10104
+ var CANOPY_META_DIR2;
10105
+ var init_git = __esm({
10106
+ "dist/utils/git.js"() {
10107
+ "use strict";
10108
+ CANOPY_META_DIR2 = ".canopy-meta";
10109
+ }
10110
+ });
10111
+
9700
10112
  // dist/utils/atomic-write.js
9701
10113
  import fs19 from "node:fs/promises";
9702
10114
  import path23 from "node:path";
@@ -9783,6 +10195,10 @@ var init_debug2 = __esm({
9783
10195
  throw new Error(errorMsg);
9784
10196
  }
9785
10197
  }
10198
+ /**
10199
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
10200
+ * anything that can run concurrently uses {@link timed}.
10201
+ */
9786
10202
  time(label) {
9787
10203
  this.timers.set(label, Date.now());
9788
10204
  }
@@ -9797,12 +10213,16 @@ var init_debug2 = __esm({
9797
10213
  this.debug(category, `${label} completed`, { durationMs: duration });
9798
10214
  return duration;
9799
10215
  }
10216
+ /**
10217
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
10218
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
10219
+ */
9800
10220
  async timed(category, label, fn) {
9801
- this.time(label);
10221
+ const start = Date.now();
9802
10222
  try {
9803
10223
  return await fn();
9804
10224
  } finally {
9805
- this.timeEnd(category, label);
10225
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
9806
10226
  }
9807
10227
  }
9808
10228
  };
@@ -9827,7 +10247,7 @@ async function bumpResourceGeneration2(root, resource, options) {
9827
10247
  await atomicWriteFile3(resourceGenerationPath2(root, resource), token);
9828
10248
  return token;
9829
10249
  } catch (err) {
9830
- log8.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
10250
+ log9.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
9831
10251
  error: getErrorMessage(err)
9832
10252
  });
9833
10253
  if (options?.mustSucceed)
@@ -9842,7 +10262,7 @@ async function readResourceGeneration2(root, resource) {
9842
10262
  } catch (err) {
9843
10263
  if (isNodeError(err) && err.code === "ENOENT")
9844
10264
  return { ok: true, token: null };
9845
- log8.warn("generation", `Failed to read ${resource} generation for ${root}`, {
10265
+ log9.warn("generation", `Failed to read ${resource} generation for ${root}`, {
9846
10266
  error: getErrorMessage(err)
9847
10267
  });
9848
10268
  return { ok: false };
@@ -9851,14 +10271,14 @@ async function readResourceGeneration2(root, resource) {
9851
10271
  function isGenerationCurrent2(snapshotToken, read) {
9852
10272
  return read.ok && read.token === snapshotToken;
9853
10273
  }
9854
- var log8, META_DIR3;
10274
+ var log9, META_DIR3;
9855
10275
  var init_resource_generation = __esm({
9856
10276
  "dist/resource-generation.js"() {
9857
10277
  "use strict";
9858
10278
  init_atomic_write2();
9859
10279
  init_error();
9860
10280
  init_debug2();
9861
- log8 = createDebugLogger3({ prefix: "ResourceGeneration" });
10281
+ log9 = createDebugLogger3({ prefix: "ResourceGeneration" });
9862
10282
  META_DIR3 = ".canopy-meta";
9863
10283
  }
9864
10284
  });
@@ -10263,7 +10683,8 @@ var init_field3 = __esm({
10263
10683
  });
10264
10684
  objectFieldSchema3 = fieldBaseSchema3.extend({
10265
10685
  type: z10.literal("object"),
10266
- fields: z10.array(z10.lazy(() => fieldHolder3[0])).min(1)
10686
+ fields: z10.array(z10.lazy(() => fieldHolder3[0])).min(1),
10687
+ itemTitleField: z10.string().min(1).optional()
10267
10688
  });
10268
10689
  inlineGroupFieldSchema3 = z10.object({
10269
10690
  type: z10.literal("group"),
@@ -10379,7 +10800,7 @@ var init_sanitize_href3 = __esm({
10379
10800
 
10380
10801
  // dist/config/schemas/url.js
10381
10802
  import { z as z12 } from "zod";
10382
- var uploadTargetUrlSchema3, assetMountUrlSchema3;
10803
+ var uploadTargetUrlSchema3, assetMountUrlSchema3, previewPrefixSchema3;
10383
10804
  var init_url3 = __esm({
10384
10805
  "dist/config/schemas/url.js"() {
10385
10806
  "use strict";
@@ -10390,6 +10811,9 @@ var init_url3 = __esm({
10390
10811
  assetMountUrlSchema3 = z12.string().trim().refine((value) => isHttpUrlOrSameOriginPath3(value, { allowProtocolRelative: true }), {
10391
10812
  message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
10392
10813
  });
10814
+ previewPrefixSchema3 = z12.string().trim().refine((value) => isHttpUrlOrSameOriginPath3(value), {
10815
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
10816
+ });
10393
10817
  }
10394
10818
  });
10395
10819
 
@@ -10447,6 +10871,7 @@ var init_config4 = __esm({
10447
10871
  "use strict";
10448
10872
  init_collection3();
10449
10873
  init_media3();
10874
+ init_url3();
10450
10875
  defaultBranchAccessSchema3 = z14.enum(["allow", "deny"]).default("deny");
10451
10876
  defaultPathAccessLevelSchema3 = z14.enum(["allow", "deny"]);
10452
10877
  defaultPathAccessLevelsSchema3 = z14.object({
@@ -10478,6 +10903,7 @@ var init_config4 = __esm({
10478
10903
  subtitle: z14.string().optional(),
10479
10904
  theme: z14.unknown().optional(),
10480
10905
  previewBase: z14.record(z14.string()).optional(),
10906
+ previewPrefix: previewPrefixSchema3.optional(),
10481
10907
  // UI handler functions (runtime only, don't serialize)
10482
10908
  onAccountClick: z14.function().returns(z14.void()).optional(),
10483
10909
  onLogoutClick: z14.function().returns(z14.void()).optional(),
@@ -10505,6 +10931,9 @@ var init_config4 = __esm({
10505
10931
  defaultRemoteUrl: defaultRemoteUrlSchema3.optional(),
10506
10932
  gitBotAuthorName: gitBotAuthorNameSchema3,
10507
10933
  gitBotAuthorEmail: gitBotAuthorEmailSchema3,
10934
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
10935
+ gitEditedByTrailers: z14.boolean().optional(),
10936
+ gitCoAuthoredByTrailers: z14.boolean().optional(),
10508
10937
  githubTokenEnvVar: githubTokenEnvVarSchema3.optional(),
10509
10938
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
10510
10939
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -10516,7 +10945,6 @@ var init_config4 = __esm({
10516
10945
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
10517
10946
  allowNetworkRemoteInProd: z14.boolean().optional(),
10518
10947
  settingsBranch: z14.string().optional(),
10519
- autoCreateSettingsPR: z14.boolean().optional(),
10520
10948
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
10521
10949
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
10522
10950
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -10577,6 +11005,18 @@ var init_build_mode = __esm({
10577
11005
  }
10578
11006
  });
10579
11007
 
11008
+ // dist/utils/request-timing.js
11009
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
11010
+ var storage2, log10;
11011
+ var init_request_timing = __esm({
11012
+ "dist/utils/request-timing.js"() {
11013
+ "use strict";
11014
+ init_debug2();
11015
+ storage2 = new AsyncLocalStorage2();
11016
+ log10 = createDebugLogger3({ prefix: "CanopyCMS" });
11017
+ }
11018
+ });
11019
+
10580
11020
  // dist/branch-schema-cache.js
10581
11021
  var SCHEMA_GENERATION_RESOURCE2;
10582
11022
  var init_branch_schema_cache = __esm({
@@ -10587,6 +11027,8 @@ var init_branch_schema_cache = __esm({
10587
11027
  init_entry_type_reference_validator();
10588
11028
  init_build_mode();
10589
11029
  init_resource_generation();
11030
+ init_request_timing();
11031
+ init_git();
10590
11032
  SCHEMA_GENERATION_RESOURCE2 = "schema";
10591
11033
  }
10592
11034
  });
@@ -10611,14 +11053,142 @@ var init_content_index_generation = __esm({
10611
11053
  }
10612
11054
  });
10613
11055
 
10614
- // dist/sync-core.js
11056
+ // dist/utils/provisioning-lock.js
10615
11057
  import fs24 from "node:fs/promises";
10616
11058
  import path26 from "node:path";
10617
- import { simpleGit as simpleGit4 } from "simple-git";
11059
+ import lockfile3 from "proper-lockfile";
11060
+ function provisioningLockOptions2(lockPath, retries, onCompromised, staleMs) {
11061
+ return {
11062
+ lockfilePath: lockPath,
11063
+ realpath: false,
11064
+ retries,
11065
+ stale: staleMs,
11066
+ // Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
11067
+ update: 15e3,
11068
+ // proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
11069
+ // an uncaught exception that kills the process. Call sites are told not to throw (see
11070
+ // OnLockCompromised); this makes it structural rather than a convention, and also covers the
11071
+ // LOGGER throwing -- under `CI=true`, vitest's `onConsoleLog` turns a console write into a
11072
+ // throw.
11073
+ onCompromised: (err) => {
11074
+ try {
11075
+ if (onCompromised) {
11076
+ onCompromised(err);
11077
+ return;
11078
+ }
11079
+ canopyLogWarn3(`[canopy] Provisioning lock compromised mid-hold for ${lockPath}:`, getErrorMessage(err));
11080
+ } catch {
11081
+ try {
11082
+ process.stderr.write(`[canopy] lock compromised for ${lockPath}; its handler or logger threw
11083
+ `);
11084
+ } catch {
11085
+ }
11086
+ }
11087
+ }
11088
+ };
11089
+ }
11090
+ function releaseIgnoringAlreadyReleased2(release, lockPath) {
11091
+ return async () => {
11092
+ try {
11093
+ await release();
11094
+ } catch (err) {
11095
+ if (isNodeError(err) && err.code === "ERELEASED") {
11096
+ canopyLogWarn3(`[canopy] Lock at ${lockPath} was already released (compromised mid-hold, or released twice); nothing to release`);
11097
+ return;
11098
+ }
11099
+ throw err;
11100
+ }
11101
+ };
11102
+ }
11103
+ async function tryAcquireProvisioningLock2(lockTargetDir3, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS2) {
11104
+ await fs24.mkdir(lockTargetDir3, { recursive: true });
11105
+ const lockPath = path26.join(lockTargetDir3, lockName);
11106
+ const release = await lockfile3.lock(lockPath, provisioningLockOptions2(lockPath, 0, onCompromised, staleMs));
11107
+ return releaseIgnoringAlreadyReleased2(release, lockPath);
11108
+ }
11109
+ var PROVISIONING_LOCK_STALE_MS2;
11110
+ var init_provisioning_lock = __esm({
11111
+ "dist/utils/provisioning-lock.js"() {
11112
+ "use strict";
11113
+ init_error();
11114
+ init_logger3();
11115
+ PROVISIONING_LOCK_STALE_MS2 = 9e4;
11116
+ }
11117
+ });
11118
+
11119
+ // dist/utils/content-write-lock.js
11120
+ import path27 from "node:path";
11121
+ function lockTargetDir2(branchRoot) {
11122
+ return path27.join(path27.resolve(branchRoot), META_DIR4);
11123
+ }
11124
+ function tryAcquireContentWriteLock2(branchRoot, onCompromised) {
11125
+ return tryAcquireProvisioningLock2(lockTargetDir2(branchRoot), CONTENT_WRITE_LOCK_NAME2, onCompromised, CONTENT_WRITE_LOCK_STALE_MS2);
11126
+ }
11127
+ async function acquireContentWriteLock2(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2, onCompromised) {
11128
+ const startedAt = Date.now();
11129
+ for (let attempt = 0; ; attempt++) {
11130
+ try {
11131
+ return await tryAcquireContentWriteLock2(branchRoot, onCompromised);
11132
+ } catch (err) {
11133
+ if (!isNodeError(err) || err.code !== "ELOCKED")
11134
+ throw err;
11135
+ const remaining = waitMs - (Date.now() - startedAt);
11136
+ if (remaining <= 0)
11137
+ throw new ContentWriteLockBusyError2();
11138
+ const base = Math.min(40 * 2 ** attempt, 250);
11139
+ await sleep2(Math.min(base * (0.5 + Math.random()), remaining));
11140
+ }
11141
+ }
11142
+ }
11143
+ async function withContentWriteLock2(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2) {
11144
+ let compromised = false;
11145
+ const release = await acquireContentWriteLock2(branchRoot, waitMs, (err) => {
11146
+ compromised = true;
11147
+ canopyLogWarn3(`[canopy] Content-write lock compromised mid-hold for ${branchRoot}:`, getErrorMessage(err));
11148
+ });
11149
+ let result;
11150
+ try {
11151
+ result = await fn();
11152
+ } finally {
11153
+ await release();
11154
+ }
11155
+ if (compromised) {
11156
+ throw new ContentWriteLockBusyError2("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.", "unknown");
11157
+ }
11158
+ return result;
11159
+ }
11160
+ var META_DIR4, CONTENT_WRITE_LOCK_NAME2, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2, ContentWriteLockBusyError2, sleep2, CONTENT_WRITE_LOCK_STALE_MS2;
11161
+ var init_content_write_lock = __esm({
11162
+ "dist/utils/content-write-lock.js"() {
11163
+ "use strict";
11164
+ init_error();
11165
+ init_logger3();
11166
+ init_provisioning_lock();
11167
+ META_DIR4 = ".canopy-meta";
11168
+ CONTENT_WRITE_LOCK_NAME2 = "content-write.lock";
11169
+ DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2 = 2e3;
11170
+ ContentWriteLockBusyError2 = class extends Error {
11171
+ constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.", outcome = "not-run") {
11172
+ super(message);
11173
+ this.outcome = outcome;
11174
+ this.name = "ContentWriteLockBusyError";
11175
+ }
11176
+ };
11177
+ sleep2 = (ms) => new Promise((resolve2) => {
11178
+ setTimeout(resolve2, ms);
11179
+ });
11180
+ CONTENT_WRITE_LOCK_STALE_MS2 = 3e4;
11181
+ }
11182
+ });
11183
+
11184
+ // dist/sync-core.js
11185
+ import fs25 from "node:fs/promises";
11186
+ import path28 from "node:path";
11187
+ import { simpleGit as simpleGit5 } from "simple-git";
10618
11188
  function assertWithinDir(resolved, parent, label) {
10619
- const normalizedResolved = path26.resolve(resolved);
10620
- const normalizedParent = path26.resolve(parent);
10621
- if (!normalizedResolved.startsWith(normalizedParent + path26.sep) && normalizedResolved !== normalizedParent) {
11189
+ const normalizedResolved = path28.resolve(resolved);
11190
+ const normalizedParent = path28.resolve(parent);
11191
+ if (!normalizedResolved.startsWith(normalizedParent + path28.sep) && normalizedResolved !== normalizedParent) {
10622
11192
  throw new Error(`${label} escapes the expected directory: ${resolved}`);
10623
11193
  }
10624
11194
  }
@@ -10626,19 +11196,19 @@ async function safeReplaceDir(oldDir, newDir) {
10626
11196
  const backupDir = `${oldDir}.sync-backup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10627
11197
  const oldExists = await filePathExists(oldDir);
10628
11198
  if (oldExists) {
10629
- await fs24.rename(oldDir, backupDir);
11199
+ await fs25.rename(oldDir, backupDir);
10630
11200
  }
10631
11201
  try {
10632
- await fs24.rename(newDir, oldDir);
11202
+ await fs25.rename(newDir, oldDir);
10633
11203
  } catch (err) {
10634
11204
  if (oldExists) {
10635
- await fs24.rename(backupDir, oldDir).catch(() => {
11205
+ await fs25.rename(backupDir, oldDir).catch(() => {
10636
11206
  });
10637
11207
  }
10638
11208
  throw err;
10639
11209
  }
10640
11210
  if (oldExists) {
10641
- await fs24.rm(backupDir, { recursive: true, force: true }).catch(() => {
11211
+ await fs25.rm(backupDir, { recursive: true, force: true }).catch(() => {
10642
11212
  });
10643
11213
  }
10644
11214
  }
@@ -10646,7 +11216,7 @@ async function listFilesRecursive(dir, prefix = "") {
10646
11216
  const results = [];
10647
11217
  let entries;
10648
11218
  try {
10649
- entries = await fs24.readdir(dir, { withFileTypes: true });
11219
+ entries = await fs25.readdir(dir, { withFileTypes: true });
10650
11220
  } catch {
10651
11221
  return results;
10652
11222
  }
@@ -10657,7 +11227,7 @@ async function listFilesRecursive(dir, prefix = "") {
10657
11227
  continue;
10658
11228
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
10659
11229
  if (entry.isDirectory()) {
10660
- results.push(...await listFilesRecursive(path26.join(dir, entry.name), rel));
11230
+ results.push(...await listFilesRecursive(path28.join(dir, entry.name), rel));
10661
11231
  } else {
10662
11232
  results.push(rel);
10663
11233
  }
@@ -10665,51 +11235,64 @@ async function listFilesRecursive(dir, prefix = "") {
10665
11235
  return results;
10666
11236
  }
10667
11237
  async function copyDir(src, dest) {
10668
- await fs24.mkdir(dest, { recursive: true });
10669
- const entries = await fs24.readdir(src, { withFileTypes: true });
11238
+ await fs25.mkdir(dest, { recursive: true });
11239
+ const entries = await fs25.readdir(src, { withFileTypes: true });
10670
11240
  for (const entry of entries) {
10671
11241
  if (entry.name === ".git")
10672
11242
  continue;
10673
11243
  if (entry.isSymbolicLink())
10674
11244
  continue;
10675
- const srcPath = path26.join(src, entry.name);
10676
- const destPath = path26.join(dest, entry.name);
11245
+ const srcPath = path28.join(src, entry.name);
11246
+ const destPath = path28.join(dest, entry.name);
10677
11247
  if (entry.isDirectory()) {
10678
11248
  await copyDir(srcPath, destPath);
10679
11249
  } else {
10680
- await fs24.copyFile(srcPath, destPath);
11250
+ await fs25.copyFile(srcPath, destPath);
10681
11251
  }
10682
11252
  }
10683
11253
  }
10684
11254
  async function pushContentToWorkspace(options) {
10685
- const { srcContentDir, branchPath, contentRoot, commitMessage, baseTag } = options;
11255
+ const { srcContentDir, branchPath, contentRoot } = options;
10686
11256
  if (!await filePathExists(srcContentDir)) {
10687
- return { fileCount: 0 };
11257
+ return { fileCount: 0, savedEditorState: false };
10688
11258
  }
10689
- const wsContentDir = path26.join(branchPath, contentRoot);
11259
+ const wsContentDir = path28.join(branchPath, contentRoot);
10690
11260
  assertWithinDir(wsContentDir, branchPath, "content-root");
11261
+ return withContentWriteLock2(branchPath, () => replaceWorkspaceContent(options, wsContentDir));
11262
+ }
11263
+ async function replaceWorkspaceContent(options, wsContentDir) {
11264
+ const { srcContentDir, branchPath, commitMessage, baseTag, saveEditorStateMessage } = options;
11265
+ const wsGit = simpleGit5({ baseDir: branchPath });
11266
+ let savedEditorState = false;
11267
+ if (saveEditorStateMessage) {
11268
+ const editorChanges = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
11269
+ if (editorChanges.length > 0) {
11270
+ await stageAllExceptCanopyState2(wsGit);
11271
+ await wsGit.commit(saveEditorStateMessage);
11272
+ savedEditorState = true;
11273
+ }
11274
+ }
10691
11275
  const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
10692
11276
  try {
10693
11277
  await copyDir(srcContentDir, tmpDir);
10694
11278
  await safeReplaceDir(wsContentDir, tmpDir);
10695
11279
  } catch (err) {
10696
- await fs24.rm(tmpDir, { recursive: true, force: true }).catch(() => {
11280
+ await fs25.rm(tmpDir, { recursive: true, force: true }).catch(() => {
10697
11281
  });
10698
11282
  throw err;
10699
11283
  }
10700
11284
  try {
10701
- const wsGit = simpleGit4({ baseDir: branchPath });
10702
- await wsGit.add("-A");
10703
- const postStatus = await wsGit.status();
10704
- if (postStatus.files.length === 0) {
11285
+ await stageAllExceptCanopyState2(wsGit);
11286
+ const staged = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
11287
+ if (staged.length === 0) {
10705
11288
  if (baseTag)
10706
11289
  await wsGit.tag(["-f", baseTag]);
10707
- return { fileCount: 0 };
11290
+ return { fileCount: 0, savedEditorState };
10708
11291
  }
10709
11292
  await wsGit.commit(commitMessage ?? "sync: update content from working tree");
10710
11293
  if (baseTag)
10711
11294
  await wsGit.tag(["-f", baseTag]);
10712
- return { fileCount: postStatus.files.length };
11295
+ return { fileCount: staged.length, savedEditorState };
10713
11296
  } finally {
10714
11297
  await invalidateBranchContentCaches2(branchPath);
10715
11298
  }
@@ -10719,54 +11302,38 @@ var init_sync_core = __esm({
10719
11302
  "dist/sync-core.js"() {
10720
11303
  "use strict";
10721
11304
  init_content_index_generation();
11305
+ init_content_write_lock();
10722
11306
  init_fs();
11307
+ init_git();
10723
11308
  SYNC_BASE_TAG = "canopycms-sync-base";
10724
11309
  }
10725
11310
  });
10726
11311
 
10727
- // dist/utils/git.js
10728
- import { simpleGit as simpleGit5 } from "simple-git";
10729
- var init_git = __esm({
10730
- "dist/utils/git.js"() {
10731
- "use strict";
10732
- }
10733
- });
10734
-
10735
- // dist/utils/provisioning-lock.js
10736
- import lockfile3 from "proper-lockfile";
10737
- var init_provisioning_lock = __esm({
10738
- "dist/utils/provisioning-lock.js"() {
10739
- "use strict";
10740
- init_error();
10741
- init_logger3();
10742
- }
10743
- });
10744
-
10745
11312
  // dist/git-manager.js
10746
- import fs25 from "node:fs/promises";
10747
- import path27 from "node:path";
11313
+ import fs26 from "node:fs/promises";
11314
+ import path29 from "node:path";
10748
11315
  import { simpleGit as simpleGit6 } from "simple-git";
10749
11316
  async function ensureGitExcludePattern2(repoPath, pattern) {
10750
- const excludePath = path27.join(repoPath, ".git", "info", "exclude");
10751
- await fs25.mkdir(path27.dirname(excludePath), { recursive: true });
11317
+ const excludePath = path29.join(repoPath, ".git", "info", "exclude");
11318
+ await fs26.mkdir(path29.dirname(excludePath), { recursive: true });
10752
11319
  let content = "";
10753
11320
  try {
10754
- content = await fs25.readFile(excludePath, "utf-8");
11321
+ content = await fs26.readFile(excludePath, "utf-8");
10755
11322
  } catch (err) {
10756
11323
  if (!isNotFoundError(err))
10757
11324
  throw err;
10758
11325
  }
10759
11326
  const lines = content.split("\n");
10760
11327
  if (lines.some((line) => line.trim() === pattern)) {
10761
- log9.debug("git", "Pattern already in .git/info/exclude", { pattern });
11328
+ log11.debug("git", "Pattern already in .git/info/exclude", { pattern });
10762
11329
  return;
10763
11330
  }
10764
11331
  const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
10765
11332
  const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
10766
- await fs25.writeFile(excludePath, newContent, "utf-8");
10767
- log9.debug("git", "Added pattern to .git/info/exclude", { pattern });
11333
+ await fs26.writeFile(excludePath, newContent, "utf-8");
11334
+ log11.debug("git", "Added pattern to .git/info/exclude", { pattern });
10768
11335
  }
10769
- var log9;
11336
+ var log11;
10770
11337
  var init_git_manager = __esm({
10771
11338
  "dist/git-manager.js"() {
10772
11339
  "use strict";
@@ -10776,7 +11343,7 @@ var init_git_manager = __esm({
10776
11343
  init_error();
10777
11344
  init_git();
10778
11345
  init_provisioning_lock();
10779
- log9 = createDebugLogger3({ prefix: "GitManager" });
11346
+ log11 = createDebugLogger3({ prefix: "GitManager" });
10780
11347
  }
10781
11348
  });
10782
11349
 
@@ -10916,7 +11483,7 @@ var init_config5 = __esm({
10916
11483
  });
10917
11484
 
10918
11485
  // dist/operating-mode/client-unsafe-strategy.js
10919
- import path28 from "node:path";
11486
+ import path30 from "node:path";
10920
11487
  function operatingStrategy3(mode) {
10921
11488
  const cached = strategyCache3.get(mode);
10922
11489
  if (cached)
@@ -10946,32 +11513,32 @@ var init_client_unsafe_strategy2 = __esm({
10946
11513
  init_config5();
10947
11514
  ProdStrategy3 = class extends ProdClientSafeStrategy3 {
10948
11515
  getWorkspaceRoot(_sourceRoot) {
10949
- return path28.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE3);
11516
+ return path30.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE3);
10950
11517
  }
10951
11518
  getContentRoot(contentRoot, sourceRoot) {
10952
- return path28.resolve(sourceRoot ?? process.cwd(), contentRoot);
11519
+ return path30.resolve(sourceRoot ?? process.cwd(), contentRoot);
10953
11520
  }
10954
11521
  getContentBranchesRoot(sourceRoot) {
10955
- return path28.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
11522
+ return path30.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
10956
11523
  }
10957
11524
  getContentBranchRoot(branchName, sourceRoot) {
10958
- return path28.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
11525
+ return path30.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
10959
11526
  }
10960
11527
  getGitExcludePattern() {
10961
11528
  return ".canopy-meta/";
10962
11529
  }
10963
11530
  getPermissionsFilePath(root) {
10964
- return path28.join(root, this.getPermissionsFileName());
11531
+ return path30.join(root, this.getPermissionsFileName());
10965
11532
  }
10966
11533
  getGroupsFilePath(root) {
10967
- return path28.join(root, this.getGroupsFileName());
11534
+ return path30.join(root, this.getGroupsFileName());
10968
11535
  }
10969
11536
  getRemoteUrlConfig() {
10970
11537
  return {
10971
11538
  shouldAutoInitLocal: false,
10972
11539
  defaultRemotePath: "",
10973
11540
  envVarName: "CANOPYCMS_REMOTE_URL",
10974
- autoDetectRemotePath: path28.join(this.getWorkspaceRoot(), "remote.git")
11541
+ autoDetectRemotePath: path30.join(this.getWorkspaceRoot(), "remote.git")
10975
11542
  };
10976
11543
  }
10977
11544
  requiresExistingRepo() {
@@ -10983,7 +11550,7 @@ var init_client_unsafe_strategy2 = __esm({
10983
11550
  return `canopycms-settings-${resolveDeploymentName3(config, "prod")}`;
10984
11551
  }
10985
11552
  getSettingsRoot(sourceRoot) {
10986
- return path28.join(this.getWorkspaceRoot(sourceRoot), "settings");
11553
+ return path30.join(this.getWorkspaceRoot(sourceRoot), "settings");
10987
11554
  }
10988
11555
  usesSeparateSettingsBranch() {
10989
11556
  return true;
@@ -10993,31 +11560,28 @@ var init_client_unsafe_strategy2 = __esm({
10993
11560
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
10994
11561
  }
10995
11562
  }
10996
- shouldCreateSettingsPR(config) {
10997
- return config.autoCreateSettingsPR ?? true;
10998
- }
10999
11563
  };
11000
11564
  DevStrategy3 = class extends DevClientSafeStrategy3 {
11001
11565
  getWorkspaceRoot(sourceRoot) {
11002
- return path28.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
11566
+ return path30.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
11003
11567
  }
11004
11568
  getContentRoot(contentRoot, sourceRoot) {
11005
- return path28.resolve(sourceRoot ?? process.cwd(), contentRoot);
11569
+ return path30.resolve(sourceRoot ?? process.cwd(), contentRoot);
11006
11570
  }
11007
11571
  getContentBranchesRoot(sourceRoot) {
11008
- return path28.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
11572
+ return path30.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
11009
11573
  }
11010
11574
  getContentBranchRoot(branchName, sourceRoot) {
11011
- return path28.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
11575
+ return path30.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
11012
11576
  }
11013
11577
  getGitExcludePattern() {
11014
11578
  return ".canopy-meta/";
11015
11579
  }
11016
11580
  getPermissionsFilePath(root) {
11017
- return path28.join(root, this.getPermissionsFileName());
11581
+ return path30.join(root, this.getPermissionsFileName());
11018
11582
  }
11019
11583
  getGroupsFilePath(root) {
11020
- return path28.join(root, this.getGroupsFileName());
11584
+ return path30.join(root, this.getGroupsFileName());
11021
11585
  }
11022
11586
  getRemoteUrlConfig() {
11023
11587
  return {
@@ -11035,16 +11599,13 @@ var init_client_unsafe_strategy2 = __esm({
11035
11599
  return `canopycms-settings-${resolveDeploymentName3(config, "local")}`;
11036
11600
  }
11037
11601
  getSettingsRoot(sourceRoot) {
11038
- return path28.join(this.getWorkspaceRoot(sourceRoot), "settings");
11602
+ return path30.join(this.getWorkspaceRoot(sourceRoot), "settings");
11039
11603
  }
11040
11604
  usesSeparateSettingsBranch() {
11041
11605
  return true;
11042
11606
  }
11043
11607
  validateConfig(_config) {
11044
11608
  }
11045
- shouldCreateSettingsPR(_config) {
11046
- return false;
11047
- }
11048
11609
  };
11049
11610
  strategyCache3 = /* @__PURE__ */ new Map();
11050
11611
  }
@@ -11066,10 +11627,32 @@ var sync_exports = {};
11066
11627
  __export(sync_exports, {
11067
11628
  sync: () => sync
11068
11629
  });
11069
- import fs26 from "node:fs/promises";
11070
- import path29 from "node:path";
11630
+ import fs27 from "node:fs/promises";
11631
+ import path31 from "node:path";
11071
11632
  import { simpleGit as simpleGit7 } from "simple-git";
11072
11633
  import * as p2 from "@clack/prompts";
11634
+ function lockBusyAsSyncError(err, branchName) {
11635
+ if (!(err instanceof ContentWriteLockBusyError2))
11636
+ return err;
11637
+ return new SyncError(err.outcome === "not-run" ? `Branch workspace "${branchName}" is busy (the worker is syncing it, or an editor save is in flight); nothing was changed. Try again in a moment.` : `Branch workspace "${branchName}" was being synced during this run, so it may be partly updated. Check its state before running sync again.`);
11638
+ }
11639
+ async function withWorkspaceLock(branchPath, branchName, fn) {
11640
+ try {
11641
+ return await withContentWriteLock2(branchPath, fn);
11642
+ } catch (err) {
11643
+ throw lockBusyAsSyncError(err, branchName);
11644
+ }
11645
+ }
11646
+ async function abortWorkspaceMerge(branchPath, branchName) {
11647
+ return withWorkspaceLock(branchPath, branchName, async () => {
11648
+ const wsGit = simpleGit7({ baseDir: branchPath });
11649
+ if ((await wsGit.status()).conflicted.length === 0)
11650
+ return false;
11651
+ await wsGit.merge(["--abort"]);
11652
+ await invalidateBranchContentCaches2(branchPath);
11653
+ return true;
11654
+ });
11655
+ }
11073
11656
  async function detectCurrentBranch(projectDir) {
11074
11657
  const git = simpleGit7({ baseDir: projectDir });
11075
11658
  const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
@@ -11080,7 +11663,7 @@ async function detectCurrentBranch(projectDir) {
11080
11663
  async function selectBranch(options, branchesDir) {
11081
11664
  let branches = [];
11082
11665
  if (await filePathExists(branchesDir)) {
11083
- const entries = await fs26.readdir(branchesDir, { withFileTypes: true });
11666
+ const entries = await fs27.readdir(branchesDir, { withFileTypes: true });
11084
11667
  branches = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
11085
11668
  }
11086
11669
  let branchName = options.branch;
@@ -11108,11 +11691,11 @@ async function selectBranch(options, branchesDir) {
11108
11691
  branchName = result;
11109
11692
  }
11110
11693
  }
11111
- const branchPath = path29.join(branchesDir, branchName);
11694
+ const branchPath = path31.join(branchesDir, branchName);
11112
11695
  assertWithinDir(branchPath, branchesDir, "--branch");
11113
11696
  if (!branches.includes(branchName)) {
11114
11697
  if (options.autoCreate) {
11115
- await fs26.mkdir(branchPath, { recursive: true });
11698
+ await fs27.mkdir(branchPath, { recursive: true });
11116
11699
  const wsGit = simpleGit7({ baseDir: branchPath });
11117
11700
  await wsGit.init();
11118
11701
  await wsGit.checkoutLocalBranch(branchName);
@@ -11129,8 +11712,8 @@ async function selectBranch(options, branchesDir) {
11129
11712
  async function syncPush(options) {
11130
11713
  const { projectDir } = options;
11131
11714
  const contentRoot = options.contentRoot || "content";
11132
- const branchesDir = path29.join(projectDir, ".canopy-dev", "content-branches");
11133
- const srcContentDir = path29.join(projectDir, contentRoot);
11715
+ const branchesDir = path31.join(projectDir, ".canopy-dev", "content-branches");
11716
+ const srcContentDir = path31.join(projectDir, contentRoot);
11134
11717
  assertWithinDir(srcContentDir, projectDir, "--content-root");
11135
11718
  if (!await filePathExists(srcContentDir)) {
11136
11719
  throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
@@ -11138,7 +11721,7 @@ async function syncPush(options) {
11138
11721
  const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
11139
11722
  if (!branchName)
11140
11723
  return { fileCount: 0 };
11141
- const branchPath = path29.join(branchesDir, branchName);
11724
+ const branchPath = path31.join(branchesDir, branchName);
11142
11725
  assertWithinDir(branchPath, branchesDir, "--branch");
11143
11726
  const wsGit = simpleGit7({ baseDir: branchPath });
11144
11727
  const status = await wsGit.status();
@@ -11150,20 +11733,21 @@ async function syncPush(options) {
11150
11733
  initialValue: false
11151
11734
  });
11152
11735
  if (!p2.isCancel(shouldAbort) && shouldAbort) {
11153
- await wsGit.merge(["--abort"]);
11154
- await invalidateBranchContentCaches2(branchPath);
11155
- p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
11736
+ if (await abortWorkspaceMerge(branchPath, branchName)) {
11737
+ p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
11738
+ }
11156
11739
  }
11157
11740
  }
11158
11741
  return { fileCount: 0 };
11159
11742
  }
11160
- if (status.files.length > 0 && !options.force) {
11161
- p2.log.warn(`Branch workspace has ${status.files.length} uncommitted change(s) that will be committed to history then overwritten:`);
11162
- for (const file of status.files.slice(0, 10)) {
11743
+ const editorChanges = status.files.filter((f) => !isCanopyInternalPath(f.path));
11744
+ if (editorChanges.length > 0 && !options.force) {
11745
+ p2.log.warn(`Branch workspace has ${editorChanges.length} uncommitted change(s) that will be committed to history then overwritten:`);
11746
+ for (const file of editorChanges.slice(0, 10)) {
11163
11747
  p2.log.warn(` ${file.path}`);
11164
11748
  }
11165
- if (status.files.length > 10) {
11166
- p2.log.warn(` ... and ${status.files.length - 10} more`);
11749
+ if (editorChanges.length > 10) {
11750
+ p2.log.warn(` ... and ${editorChanges.length - 10} more`);
11167
11751
  }
11168
11752
  const confirm5 = await p2.confirm({
11169
11753
  message: "Continue? Editor changes will be preserved in git history.",
@@ -11174,18 +11758,22 @@ async function syncPush(options) {
11174
11758
  return { fileCount: 0 };
11175
11759
  }
11176
11760
  }
11177
- if (status.files.length > 0) {
11178
- await wsGit.add("-A");
11179
- await wsGit.commit("sync: save editor state before push");
11180
- p2.log.info("Committed editor changes to history before push");
11181
- }
11182
11761
  p2.log.step(`Pushing content to branch workspace: ${branchName}`);
11183
- const { fileCount } = await pushContentToWorkspace({
11184
- srcContentDir,
11185
- branchPath,
11186
- contentRoot,
11187
- baseTag: SYNC_BASE_TAG
11188
- });
11762
+ let fileCount;
11763
+ try {
11764
+ const result = await pushContentToWorkspace({
11765
+ srcContentDir,
11766
+ branchPath,
11767
+ contentRoot,
11768
+ baseTag: SYNC_BASE_TAG,
11769
+ saveEditorStateMessage: "sync: save editor state before push"
11770
+ });
11771
+ fileCount = result.fileCount;
11772
+ if (result.savedEditorState)
11773
+ p2.log.info("Committed editor changes to history before push");
11774
+ } catch (err) {
11775
+ throw lockBusyAsSyncError(err, branchName);
11776
+ }
11189
11777
  if (fileCount === 0) {
11190
11778
  p2.log.info("Content is already up to date \u2014 nothing to push");
11191
11779
  } else {
@@ -11196,19 +11784,19 @@ async function syncPush(options) {
11196
11784
  async function syncPull(options) {
11197
11785
  const { projectDir } = options;
11198
11786
  const contentRoot = options.contentRoot || "content";
11199
- const branchesDir = path29.join(projectDir, ".canopy-dev", "content-branches");
11787
+ const branchesDir = path31.join(projectDir, ".canopy-dev", "content-branches");
11200
11788
  const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
11201
11789
  if (!branchName)
11202
11790
  return { fileCount: 0 };
11203
- const branchPath = path29.join(branchesDir, branchName);
11791
+ const branchPath = path31.join(branchesDir, branchName);
11204
11792
  assertWithinDir(branchPath, branchesDir, "--branch");
11205
- const branchContentDir = path29.join(branchPath, contentRoot);
11793
+ const branchContentDir = path31.join(branchPath, contentRoot);
11206
11794
  assertWithinDir(branchContentDir, branchPath, "--content-root");
11207
11795
  if (!await filePathExists(branchContentDir)) {
11208
11796
  throw new SyncError(`Content directory not found in branch workspace: ${branchName}/${contentRoot}`);
11209
11797
  }
11210
11798
  p2.log.step(`Pulling content from branch: ${branchName}`);
11211
- const destContentDir = path29.join(projectDir, contentRoot);
11799
+ const destContentDir = path31.join(projectDir, contentRoot);
11212
11800
  assertWithinDir(destContentDir, projectDir, "--content-root");
11213
11801
  const sourceGit = simpleGit7({ baseDir: projectDir });
11214
11802
  const status = await sourceGit.status();
@@ -11260,7 +11848,7 @@ async function syncPull(options) {
11260
11848
  await copyDir(branchContentDir, tmpDestDir);
11261
11849
  await safeReplaceDir(destContentDir, tmpDestDir);
11262
11850
  } catch (err) {
11263
- await fs26.rm(tmpDestDir, { recursive: true, force: true }).catch(() => {
11851
+ await fs27.rm(tmpDestDir, { recursive: true, force: true }).catch(() => {
11264
11852
  });
11265
11853
  throw err;
11266
11854
  }
@@ -11284,8 +11872,8 @@ async function syncPull(options) {
11284
11872
  async function syncBoth(options) {
11285
11873
  const { projectDir } = options;
11286
11874
  const contentRoot = options.contentRoot || "content";
11287
- const branchesDir = path29.join(projectDir, ".canopy-dev", "content-branches");
11288
- const srcContentDir = path29.join(projectDir, contentRoot);
11875
+ const branchesDir = path31.join(projectDir, ".canopy-dev", "content-branches");
11876
+ const srcContentDir = path31.join(projectDir, contentRoot);
11289
11877
  assertWithinDir(srcContentDir, projectDir, "--content-root");
11290
11878
  if (!await filePathExists(srcContentDir)) {
11291
11879
  throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
@@ -11293,7 +11881,7 @@ async function syncBoth(options) {
11293
11881
  const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
11294
11882
  if (!branchName)
11295
11883
  return { pushed: 0, pulled: 0 };
11296
- const branchPath = path29.join(branchesDir, branchName);
11884
+ const branchPath = path31.join(branchesDir, branchName);
11297
11885
  assertWithinDir(branchPath, branchesDir, "--branch");
11298
11886
  const wsGit = simpleGit7({ baseDir: branchPath });
11299
11887
  const status = await wsGit.status();
@@ -11305,15 +11893,21 @@ async function syncBoth(options) {
11305
11893
  initialValue: false
11306
11894
  });
11307
11895
  if (!p2.isCancel(shouldAbort) && shouldAbort) {
11308
- await wsGit.merge(["--abort"]);
11309
- await invalidateBranchContentCaches2(branchPath);
11310
- p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
11896
+ if (await abortWorkspaceMerge(branchPath, branchName)) {
11897
+ p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
11898
+ }
11311
11899
  }
11312
11900
  }
11313
11901
  return { pushed: 0, pulled: 0 };
11314
11902
  }
11315
- if (status.files.length > 0) {
11316
- await wsGit.add("-A");
11903
+ return withWorkspaceLock(branchPath, branchName, () => mergeIntoWorkspace(options, { branchName, branchPath, srcContentDir, contentRoot }));
11904
+ }
11905
+ async function mergeIntoWorkspace(options, ws) {
11906
+ const { branchName, branchPath, srcContentDir, contentRoot } = ws;
11907
+ const wsGit = simpleGit7({ baseDir: branchPath });
11908
+ const status = await wsGit.status();
11909
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
11910
+ await stageAllExceptCanopyState2(wsGit);
11317
11911
  await wsGit.commit("sync: save editor state before merge");
11318
11912
  p2.log.info("Committed editor changes before merge");
11319
11913
  }
@@ -11328,23 +11922,23 @@ async function syncBoth(options) {
11328
11922
  try {
11329
11923
  const incomingBranch = `sync-incoming-${Date.now()}`;
11330
11924
  await wsGit.raw(["checkout", "-b", incomingBranch, baseRef]);
11331
- const wsContentDir = path29.join(branchPath, contentRoot);
11925
+ const wsContentDir = path31.join(branchPath, contentRoot);
11332
11926
  assertWithinDir(wsContentDir, branchPath, "--content-root");
11333
11927
  const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
11334
11928
  try {
11335
11929
  await copyDir(srcContentDir, tmpDir);
11336
11930
  await safeReplaceDir(wsContentDir, tmpDir);
11337
11931
  } catch (err) {
11338
- await fs26.rm(tmpDir, { recursive: true, force: true }).catch(() => {
11932
+ await fs27.rm(tmpDir, { recursive: true, force: true }).catch(() => {
11339
11933
  });
11340
11934
  await wsGit.checkout(currentBranch);
11341
11935
  await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
11342
11936
  });
11343
11937
  throw err;
11344
11938
  }
11345
- await wsGit.add("-A");
11346
- const incomingStatus = await wsGit.status();
11347
- if (incomingStatus.files.length === 0) {
11939
+ await stageAllExceptCanopyState2(wsGit);
11940
+ const incomingFiles = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
11941
+ if (incomingFiles.length === 0) {
11348
11942
  await wsGit.checkout(currentBranch);
11349
11943
  await wsGit.raw(["branch", "-D", incomingBranch]);
11350
11944
  p2.log.info("No working-tree changes to merge \u2014 pulling editor changes only");
@@ -11385,27 +11979,23 @@ async function syncBoth(options) {
11385
11979
  await wsGit.tag(["-f", SYNC_BASE_TAG]);
11386
11980
  p2.log.success("Merged working-tree changes with editor changes");
11387
11981
  const pullResult = await syncPull({ ...options, branch: branchName, force: true });
11388
- return { pushed: incomingStatus.files.length, pulled: pullResult.fileCount };
11982
+ return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
11389
11983
  } finally {
11390
11984
  await invalidateBranchContentCaches2(branchPath);
11391
11985
  }
11392
11986
  }
11393
11987
  async function syncAbort(options) {
11394
11988
  const { projectDir } = options;
11395
- const branchesDir = path29.join(projectDir, ".canopy-dev", "content-branches");
11989
+ const branchesDir = path31.join(projectDir, ".canopy-dev", "content-branches");
11396
11990
  const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
11397
11991
  if (!branchName)
11398
11992
  return;
11399
- const branchPath = path29.join(branchesDir, branchName);
11993
+ const branchPath = path31.join(branchesDir, branchName);
11400
11994
  assertWithinDir(branchPath, branchesDir, "--branch");
11401
- const wsGit = simpleGit7({ baseDir: branchPath });
11402
- const status = await wsGit.status();
11403
- if (status.conflicted.length === 0) {
11995
+ if (!await abortWorkspaceMerge(branchPath, branchName)) {
11404
11996
  p2.log.info(`Branch workspace "${branchName}" is not in a merge state \u2014 nothing to abort.`);
11405
11997
  return;
11406
11998
  }
11407
- await wsGit.merge(["--abort"]);
11408
- await invalidateBranchContentCaches2(branchPath);
11409
11999
  p2.log.success(`Merge aborted in branch workspace "${branchName}". Workspace restored to pre-merge state.`);
11410
12000
  }
11411
12001
  async function sync(options) {
@@ -11433,8 +12023,10 @@ var init_sync = __esm({
11433
12023
  "dist/cli/sync.js"() {
11434
12024
  "use strict";
11435
12025
  init_fs();
12026
+ init_git();
11436
12027
  init_sync_core();
11437
12028
  init_content_index_generation();
12029
+ init_content_write_lock();
11438
12030
  init_git_manager();
11439
12031
  init_operating_mode2();
11440
12032
  SyncError = class extends Error {
@@ -11472,6 +12064,7 @@ var init_branch = __esm({
11472
12064
  "dist/paths/branch.js"() {
11473
12065
  "use strict";
11474
12066
  init_operating_mode2();
12067
+ init_error();
11475
12068
  init_branch_name();
11476
12069
  }
11477
12070
  });
@@ -11498,34 +12091,9 @@ var init_schema = __esm({
11498
12091
  }
11499
12092
  });
11500
12093
 
11501
- // dist/utils/async-mutex.js
11502
- async function withLock2(key, fn) {
11503
- const prev = locks2.get(key) ?? Promise.resolve();
11504
- let resolve2;
11505
- const next = new Promise((r) => {
11506
- resolve2 = r;
11507
- });
11508
- locks2.set(key, next);
11509
- await prev;
11510
- try {
11511
- return await fn();
11512
- } finally {
11513
- resolve2();
11514
- if (locks2.get(key) === next)
11515
- locks2.delete(key);
11516
- }
11517
- }
11518
- var locks2;
11519
- var init_async_mutex = __esm({
11520
- "dist/utils/async-mutex.js"() {
11521
- "use strict";
11522
- locks2 = /* @__PURE__ */ new Map();
11523
- }
11524
- });
11525
-
11526
12094
  // dist/utils/occ-json-write.js
11527
- import fs27 from "node:fs/promises";
11528
- import path30 from "node:path";
12095
+ import fs28 from "node:fs/promises";
12096
+ import path33 from "node:path";
11529
12097
  import { randomUUID as randomUUID4 } from "node:crypto";
11530
12098
  import lockfile4 from "proper-lockfile";
11531
12099
  async function writeOccJsonFile2(filePath, payload, options) {
@@ -11534,29 +12102,29 @@ async function writeOccJsonFile2(filePath, payload, options) {
11534
12102
  const writeId = randomUUID4();
11535
12103
  const data = { ...payload, version: newVersion, writeId };
11536
12104
  const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
11537
- const dir = path30.dirname(filePath);
11538
- await fs27.mkdir(dir, { recursive: true });
12105
+ const dir = path33.dirname(filePath);
12106
+ await fs28.mkdir(dir, { recursive: true });
11539
12107
  const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
11540
- await fs27.writeFile(tempPath, content, "utf-8");
12108
+ await fs28.writeFile(tempPath, content, "utf-8");
11541
12109
  if (expectedVersion === null) {
11542
12110
  try {
11543
- await fs27.link(tempPath, filePath);
12111
+ await fs28.link(tempPath, filePath);
11544
12112
  } catch (err) {
11545
- await fs27.unlink(tempPath).catch(() => {
12113
+ await fs28.unlink(tempPath).catch(() => {
11546
12114
  });
11547
12115
  if (isFileExistsError(err)) {
11548
12116
  throw new OccWriteConflictError2();
11549
12117
  }
11550
12118
  throw err;
11551
12119
  }
11552
- await fs27.unlink(tempPath).catch(() => {
12120
+ await fs28.unlink(tempPath).catch(() => {
11553
12121
  });
11554
12122
  return { version: newVersion, writeId };
11555
12123
  }
11556
12124
  try {
11557
12125
  let currentVersion = null;
11558
12126
  try {
11559
- const current = JSON.parse(await fs27.readFile(filePath, "utf-8"));
12127
+ const current = JSON.parse(await fs28.readFile(filePath, "utf-8"));
11560
12128
  currentVersion = current.version ?? 0;
11561
12129
  } catch {
11562
12130
  currentVersion = null;
@@ -11564,16 +12132,16 @@ async function writeOccJsonFile2(filePath, payload, options) {
11564
12132
  if (currentVersion !== expectedVersion) {
11565
12133
  throw new OccWriteConflictError2();
11566
12134
  }
11567
- await fs27.rename(tempPath, filePath);
12135
+ await fs28.rename(tempPath, filePath);
11568
12136
  if (settleMs > 0) {
11569
12137
  await new Promise((resolve2) => setTimeout(resolve2, settleMs));
11570
12138
  }
11571
- const afterWrite = JSON.parse(await fs27.readFile(filePath, "utf-8"));
12139
+ const afterWrite = JSON.parse(await fs28.readFile(filePath, "utf-8"));
11572
12140
  if (afterWrite.writeId !== writeId) {
11573
12141
  throw new OccWriteConflictError2();
11574
12142
  }
11575
12143
  } catch (err) {
11576
- await fs27.unlink(tempPath).catch(() => {
12144
+ await fs28.unlink(tempPath).catch(() => {
11577
12145
  });
11578
12146
  throw err;
11579
12147
  }
@@ -11599,8 +12167,8 @@ async function withOccRetry2(operation, options) {
11599
12167
  throw new Error("Unreachable");
11600
12168
  }
11601
12169
  async function withOccFileLock2(filePath, fn) {
11602
- const dir = path30.dirname(filePath);
11603
- await fs27.mkdir(dir, { recursive: true });
12170
+ const dir = path33.dirname(filePath);
12171
+ await fs28.mkdir(dir, { recursive: true });
11604
12172
  const MAX_ATTEMPTS = 14;
11605
12173
  let release = null;
11606
12174
  for (let attempt = 1; release === null; attempt++) {
@@ -11616,7 +12184,7 @@ async function withOccFileLock2(filePath, fn) {
11616
12184
  // Our critical sections are short and idempotent-on-conflict (OCC
11617
12185
  // verify inside); log and let the section finish.
11618
12186
  onCompromised: (err) => {
11619
- log11.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
12187
+ log13.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
11620
12188
  error: getErrorMessage(err)
11621
12189
  });
11622
12190
  }
@@ -11638,17 +12206,17 @@ async function withOccFileLock2(filePath, fn) {
11638
12206
  await release().catch((err) => {
11639
12207
  if (isNodeError(err) && err.code === "ENOENT")
11640
12208
  return;
11641
- log11.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
12209
+ log13.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
11642
12210
  });
11643
12211
  }
11644
12212
  }
11645
- var log11, OccWriteConflictError2;
12213
+ var log13, OccWriteConflictError2;
11646
12214
  var init_occ_json_write = __esm({
11647
12215
  "dist/utils/occ-json-write.js"() {
11648
12216
  "use strict";
11649
12217
  init_error();
11650
12218
  init_debug2();
11651
- log11 = createDebugLogger3({ prefix: "OccJsonWrite" });
12219
+ log13 = createDebugLogger3({ prefix: "OccJsonWrite" });
11652
12220
  OccWriteConflictError2 = class extends Error {
11653
12221
  constructor(message = "Concurrent modification detected") {
11654
12222
  super(message);
@@ -11658,13 +12226,124 @@ var init_occ_json_write = __esm({
11658
12226
  }
11659
12227
  });
11660
12228
 
12229
+ // dist/utils/async-mutex.js
12230
+ async function withLock2(key, fn) {
12231
+ const prev = locks2.get(key) ?? Promise.resolve();
12232
+ let resolve2;
12233
+ const next = new Promise((r) => {
12234
+ resolve2 = r;
12235
+ });
12236
+ locks2.set(key, next);
12237
+ await prev;
12238
+ try {
12239
+ return await fn();
12240
+ } finally {
12241
+ resolve2();
12242
+ if (locks2.get(key) === next)
12243
+ locks2.delete(key);
12244
+ }
12245
+ }
12246
+ var locks2;
12247
+ var init_async_mutex = __esm({
12248
+ "dist/utils/async-mutex.js"() {
12249
+ "use strict";
12250
+ locks2 = /* @__PURE__ */ new Map();
12251
+ }
12252
+ });
12253
+
12254
+ // dist/utils/entry-url.js
12255
+ var init_entry_url = __esm({
12256
+ "dist/utils/entry-url.js"() {
12257
+ "use strict";
12258
+ init_normalize3();
12259
+ }
12260
+ });
12261
+
12262
+ // dist/utils/typed-filename.js
12263
+ var init_typed_filename = __esm({
12264
+ "dist/utils/typed-filename.js"() {
12265
+ "use strict";
12266
+ init_id();
12267
+ }
12268
+ });
12269
+
12270
+ // dist/url-collision.js
12271
+ var init_url_collision = __esm({
12272
+ "dist/url-collision.js"() {
12273
+ "use strict";
12274
+ init_content_id_index();
12275
+ init_entry_url();
12276
+ init_error();
12277
+ init_typed_filename();
12278
+ }
12279
+ });
12280
+
12281
+ // dist/schema/schema-store.js
12282
+ import path34 from "node:path";
12283
+ import { z as z16 } from "zod";
12284
+ async function withBranchSchemaLock(branchRoot, fn, contentWriteLockWaitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2) {
12285
+ const schemaLockPath = path34.join(path34.resolve(branchRoot), ".canopy-meta", "schema");
12286
+ return withContentWriteLock2(branchRoot, () => withLock2(schemaLockPath, () => withOccFileLock2(schemaLockPath, fn)), contentWriteLockWaitMs);
12287
+ }
12288
+ var MAX_NAME_LENGTH, MAX_LABEL_LENGTH, SAFE_NAME_PATTERN, SAFE_NAME_MESSAGE, entryTypeInputSchema, createCollectionInputSchema, updateCollectionInputSchema, updateEntryTypeInputSchema, log14;
12289
+ var init_schema_store = __esm({
12290
+ "dist/schema/schema-store.js"() {
12291
+ "use strict";
12292
+ init_atomic_write2();
12293
+ init_async_mutex();
12294
+ init_debug2();
12295
+ init_content_write_lock();
12296
+ init_error();
12297
+ init_occ_json_write();
12298
+ init_content_id_index();
12299
+ init_url_collision();
12300
+ init_entry_url();
12301
+ init_content_index_generation();
12302
+ init_id();
12303
+ init_paths();
12304
+ MAX_NAME_LENGTH = 64;
12305
+ MAX_LABEL_LENGTH = 128;
12306
+ SAFE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
12307
+ SAFE_NAME_MESSAGE = "must start with a letter and contain only lowercase letters, numbers, and hyphens";
12308
+ entryTypeInputSchema = z16.object({
12309
+ name: z16.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Entry type name ${SAFE_NAME_MESSAGE}`),
12310
+ label: z16.string().max(MAX_LABEL_LENGTH).optional(),
12311
+ format: z16.enum(["md", "mdx", "json", "yaml"]),
12312
+ schema: z16.string().min(1),
12313
+ default: z16.boolean().optional(),
12314
+ maxItems: z16.number().int().positive().optional()
12315
+ });
12316
+ createCollectionInputSchema = z16.object({
12317
+ name: z16.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Collection name ${SAFE_NAME_MESSAGE}`),
12318
+ label: z16.string().max(MAX_LABEL_LENGTH).optional(),
12319
+ parentPath: z16.string().optional(),
12320
+ entries: z16.array(entryTypeInputSchema).min(1, "Collection must have at least one entry type")
12321
+ });
12322
+ updateCollectionInputSchema = z16.object({
12323
+ name: z16.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Collection name ${SAFE_NAME_MESSAGE}`).optional(),
12324
+ label: z16.string().max(MAX_LABEL_LENGTH).optional(),
12325
+ // Directory name (e.g., "posts" in "posts.{id}/")
12326
+ slug: z16.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Slug ${SAFE_NAME_MESSAGE}`).optional(),
12327
+ order: z16.array(z16.string()).optional()
12328
+ });
12329
+ updateEntryTypeInputSchema = z16.object({
12330
+ label: z16.string().max(MAX_LABEL_LENGTH).optional(),
12331
+ format: z16.enum(["md", "mdx", "json", "yaml"]).optional(),
12332
+ schema: z16.string().min(1).optional(),
12333
+ default: z16.boolean().optional(),
12334
+ maxItems: z16.number().int().positive().optional()
12335
+ });
12336
+ log14 = createDebugLogger3({ prefix: "SchemaOps" });
12337
+ }
12338
+ });
12339
+
11661
12340
  // dist/branch-metadata-file.js
11662
- import fs28 from "node:fs/promises";
11663
- import path31 from "node:path";
12341
+ import fs29 from "node:fs/promises";
12342
+ import path35 from "node:path";
11664
12343
  async function readBranchMetadataFile2(branchRoot) {
11665
- const resolvedRoot = path31.resolve(branchRoot);
12344
+ const resolvedRoot = path35.resolve(branchRoot);
11666
12345
  try {
11667
- const raw = await fs28.readFile(branchMetadataFilePath2(resolvedRoot), "utf8");
12346
+ const raw = await fs29.readFile(branchMetadataFilePath2(resolvedRoot), "utf8");
11668
12347
  return JSON.parse(raw);
11669
12348
  } catch (err) {
11670
12349
  if (isNotFoundError(err)) {
@@ -11691,14 +12370,14 @@ var init_branch_metadata_file = __esm({
11691
12370
  this.parseCause = cause;
11692
12371
  }
11693
12372
  };
11694
- branchMetadataFilePath2 = (branchRoot) => path31.join(path31.resolve(branchRoot), BRANCH_META_DIR2, BRANCH_META_FILE2);
12373
+ branchMetadataFilePath2 = (branchRoot) => path35.join(path35.resolve(branchRoot), BRANCH_META_DIR2, BRANCH_META_FILE2);
11695
12374
  }
11696
12375
  });
11697
12376
 
11698
12377
  // dist/branch-registry.js
11699
- import fs29 from "node:fs/promises";
11700
- import path33 from "node:path";
11701
- var log12, REGISTRY_FILE2, REGISTRY_STALE_FILE2, REGISTRY_TEMP_FILE2, REGISTRY_VERSION2, RESOURCE4, GET_MISS_REFRESH_MIN_INTERVAL_MS2, BranchRegistry2;
12378
+ import fs30 from "node:fs/promises";
12379
+ import path36 from "node:path";
12380
+ var log15, REGISTRY_FILE2, REGISTRY_STALE_FILE2, REGISTRY_TEMP_FILE2, REGISTRY_VERSION2, RESOURCE4, GET_MISS_REFRESH_MIN_INTERVAL_MS2, BranchRegistry2;
11702
12381
  var init_branch_registry = __esm({
11703
12382
  "dist/branch-registry.js"() {
11704
12383
  "use strict";
@@ -11707,7 +12386,7 @@ var init_branch_registry = __esm({
11707
12386
  init_debug2();
11708
12387
  init_logger3();
11709
12388
  init_resource_generation();
11710
- log12 = createDebugLogger3({ prefix: "BranchRegistry" });
12389
+ log15 = createDebugLogger3({ prefix: "BranchRegistry" });
11711
12390
  REGISTRY_FILE2 = "branches.json";
11712
12391
  REGISTRY_STALE_FILE2 = "branches.stale.json";
11713
12392
  REGISTRY_TEMP_FILE2 = "branches.tmp.json";
@@ -11718,10 +12397,10 @@ var init_branch_registry = __esm({
11718
12397
  constructor(root) {
11719
12398
  this.regenInFlight = null;
11720
12399
  this.lastForcedRefreshMs = 0;
11721
- this.root = path33.resolve(root);
11722
- this.registryPath = path33.join(this.root, REGISTRY_FILE2);
11723
- this.stalePath = path33.join(this.root, REGISTRY_STALE_FILE2);
11724
- this.tempPath = path33.join(this.root, REGISTRY_TEMP_FILE2);
12400
+ this.root = path36.resolve(root);
12401
+ this.registryPath = path36.join(this.root, REGISTRY_FILE2);
12402
+ this.stalePath = path36.join(this.root, REGISTRY_STALE_FILE2);
12403
+ this.tempPath = path36.join(this.root, REGISTRY_TEMP_FILE2);
11725
12404
  }
11726
12405
  /**
11727
12406
  * Returns all branches. Uses the cached snapshot if its embedded generation
@@ -11730,7 +12409,7 @@ var init_branch_registry = __esm({
11730
12409
  async list() {
11731
12410
  let parsed;
11732
12411
  try {
11733
- const raw = await fs29.readFile(this.registryPath, "utf8");
12412
+ const raw = await fs30.readFile(this.registryPath, "utf8");
11734
12413
  parsed = JSON.parse(raw);
11735
12414
  } catch (err) {
11736
12415
  if (isNotFoundError(err)) {
@@ -11787,7 +12466,7 @@ var init_branch_registry = __esm({
11787
12466
  });
11788
12467
  await this.regenerate();
11789
12468
  } catch (err) {
11790
- log12.warn("invalidate", "Eager regeneration after invalidate() failed", {
12469
+ log15.warn("invalidate", "Eager regeneration after invalidate() failed", {
11791
12470
  error: getErrorMessage(err)
11792
12471
  });
11793
12472
  }
@@ -11812,7 +12491,7 @@ var init_branch_registry = __esm({
11812
12491
  async performRegenerate() {
11813
12492
  const read = await readResourceGeneration2(this.root, RESOURCE4);
11814
12493
  const branches = await this.scanBranchDirectories();
11815
- await fs29.unlink(this.stalePath).catch(() => {
12494
+ await fs30.unlink(this.stalePath).catch(() => {
11816
12495
  });
11817
12496
  if (!read.ok) {
11818
12497
  return branches;
@@ -11823,12 +12502,12 @@ var init_branch_registry = __esm({
11823
12502
  generation: read.token
11824
12503
  };
11825
12504
  const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
11826
- await fs29.mkdir(this.root, { recursive: true });
11827
- await fs29.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
12505
+ await fs30.mkdir(this.root, { recursive: true });
12506
+ await fs30.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
11828
12507
  try {
11829
- await fs29.rename(uniqueTempPath, this.registryPath);
12508
+ await fs30.rename(uniqueTempPath, this.registryPath);
11830
12509
  } catch (err) {
11831
- await fs29.unlink(uniqueTempPath).catch(() => {
12510
+ await fs30.unlink(uniqueTempPath).catch(() => {
11832
12511
  });
11833
12512
  throw err;
11834
12513
  }
@@ -11842,12 +12521,12 @@ var init_branch_registry = __esm({
11842
12521
  async scanBranchDirectories() {
11843
12522
  const branches = [];
11844
12523
  try {
11845
- const entries = await fs29.readdir(this.root, { withFileTypes: true });
12524
+ const entries = await fs30.readdir(this.root, { withFileTypes: true });
11846
12525
  for (const entry of entries) {
11847
12526
  if (!entry.isDirectory() || entry.name.startsWith(".")) {
11848
12527
  continue;
11849
12528
  }
11850
- const branchRoot = path33.join(this.root, entry.name);
12529
+ const branchRoot = path36.join(this.root, entry.name);
11851
12530
  let meta;
11852
12531
  try {
11853
12532
  meta = await readBranchMetadataFile2(branchRoot);
@@ -11876,8 +12555,8 @@ var init_branch_registry = __esm({
11876
12555
  });
11877
12556
 
11878
12557
  // dist/branch-metadata.js
11879
- import fs30 from "node:fs/promises";
11880
- import path34 from "node:path";
12558
+ import fs31 from "node:fs/promises";
12559
+ import path37 from "node:path";
11881
12560
  var CURRENT_SCHEMA_VERSION2, BranchMetadataConflictError2, BranchMetadataFileManager2;
11882
12561
  var init_branch_metadata = __esm({
11883
12562
  "dist/branch-metadata.js"() {
@@ -11897,8 +12576,8 @@ var init_branch_metadata = __esm({
11897
12576
  };
11898
12577
  BranchMetadataFileManager2 = class _BranchMetadataFileManager2 {
11899
12578
  constructor(branchRoot, baseRoot, options) {
11900
- this.branchRoot = path34.resolve(branchRoot);
11901
- this.filePath = path34.join(this.branchRoot, BRANCH_META_DIR2, BRANCH_META_FILE2);
12579
+ this.branchRoot = path37.resolve(branchRoot);
12580
+ this.filePath = path37.join(this.branchRoot, BRANCH_META_DIR2, BRANCH_META_FILE2);
11902
12581
  this.baseRoot = baseRoot;
11903
12582
  this.settleMs = options?.settleMs;
11904
12583
  }
@@ -11915,7 +12594,7 @@ var init_branch_metadata = __esm({
11915
12594
  }
11916
12595
  async load() {
11917
12596
  try {
11918
- const raw = await fs30.readFile(this.filePath, "utf8");
12597
+ const raw = await fs31.readFile(this.filePath, "utf8");
11919
12598
  const parsed = JSON.parse(raw);
11920
12599
  const version = parsed.version ?? 0;
11921
12600
  return { meta: parsed, version };
@@ -11969,7 +12648,7 @@ var init_branch_metadata = __esm({
11969
12648
  */
11970
12649
  async save(incoming) {
11971
12650
  try {
11972
- await fs30.stat(this.branchRoot);
12651
+ await fs31.stat(this.branchRoot);
11973
12652
  } catch (err) {
11974
12653
  if (isNotFoundError(err)) {
11975
12654
  throw new BranchMetadataConflictError2("Branch no longer exists");
@@ -12041,8 +12720,8 @@ __export(migrate_exports, {
12041
12720
  migrate: () => migrate,
12042
12721
  slugifyName: () => slugifyName
12043
12722
  });
12044
- import fs31 from "node:fs/promises";
12045
- import path35 from "node:path";
12723
+ import fs33 from "node:fs/promises";
12724
+ import path38 from "node:path";
12046
12725
  import * as p3 from "@clack/prompts";
12047
12726
  function slugifyName(name) {
12048
12727
  const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
@@ -12067,7 +12746,7 @@ function stripDirIdSuffix(dirName) {
12067
12746
  return dirName;
12068
12747
  }
12069
12748
  async function planDirectory(dirPath, isRoot, ctx) {
12070
- const entries = await fs31.readdir(dirPath, { withFileTypes: true });
12749
+ const entries = await fs33.readdir(dirPath, { withFileTypes: true });
12071
12750
  entries.sort((a, b) => a.name.localeCompare(b.name));
12072
12751
  const ops = [];
12073
12752
  let hasOwnFiles = false;
@@ -12076,7 +12755,7 @@ async function planDirectory(dirPath, isRoot, ctx) {
12076
12755
  if (entry.name.startsWith("."))
12077
12756
  continue;
12078
12757
  if (entry.isDirectory()) {
12079
- const subPath = path35.join(dirPath, entry.name);
12758
+ const subPath = path38.join(dirPath, entry.name);
12080
12759
  const sub = await planDirectory(subPath, false, ctx);
12081
12760
  if (!sub.hasContent)
12082
12761
  continue;
@@ -12084,10 +12763,10 @@ async function planDirectory(dirPath, isRoot, ctx) {
12084
12763
  ops.push(...sub.ops);
12085
12764
  if (!extractIdFromFilename2(entry.name)) {
12086
12765
  const newName = `${toValidSlug(entry.name)}.${generateId2()}`;
12087
- ops.push({ kind: "rename", from: subPath, to: path35.join(dirPath, newName) });
12766
+ ops.push({ kind: "rename", from: subPath, to: path38.join(dirPath, newName) });
12088
12767
  }
12089
12768
  } else if (entry.isFile()) {
12090
- const ext = path35.extname(entry.name).slice(1);
12769
+ const ext = path38.extname(entry.name).slice(1);
12091
12770
  const matchesFormat = ext === ctx.format || ctx.format === "yaml" && ext === "yml";
12092
12771
  if (!matchesFormat)
12093
12772
  continue;
@@ -12098,17 +12777,17 @@ async function planDirectory(dirPath, isRoot, ctx) {
12098
12777
  const newName = `${ctx.entryType}.${toValidSlug(base)}.${generateId2()}.${ext}`;
12099
12778
  ops.push({
12100
12779
  kind: "rename",
12101
- from: path35.join(dirPath, entry.name),
12102
- to: path35.join(dirPath, newName)
12780
+ from: path38.join(dirPath, entry.name),
12781
+ to: path38.join(dirPath, newName)
12103
12782
  });
12104
12783
  }
12105
12784
  }
12106
12785
  }
12107
12786
  const needsMeta = isRoot ? hasOwnFiles : hasContent;
12108
- const metaPath = path35.join(dirPath, ".collection.json");
12787
+ const metaPath = path38.join(dirPath, ".collection.json");
12109
12788
  if (needsMeta && !await filePathExists(metaPath)) {
12110
12789
  const entryTypeMeta = { name: ctx.entryType, format: ctx.format, schema: ctx.schema };
12111
- const meta = isRoot ? { entries: [entryTypeMeta] } : { name: toValidSlug(stripDirIdSuffix(path35.basename(dirPath))), entries: [entryTypeMeta] };
12790
+ const meta = isRoot ? { entries: [entryTypeMeta] } : { name: toValidSlug(stripDirIdSuffix(path38.basename(dirPath))), entries: [entryTypeMeta] };
12112
12791
  ops.push({ kind: "write", filePath: metaPath, content: JSON.stringify(meta, null, 2) + "\n" });
12113
12792
  }
12114
12793
  return { ops, hasContent };
@@ -12117,7 +12796,7 @@ async function migrate(options) {
12117
12796
  const { projectDir } = options;
12118
12797
  const contentRoot = options.contentRoot || "content";
12119
12798
  p3.intro("CanopyCMS migrate");
12120
- const contentDir = path35.join(projectDir, contentRoot);
12799
+ const contentDir = path38.join(projectDir, contentRoot);
12121
12800
  if (!await filePathExists(contentDir)) {
12122
12801
  throw new MigrateError(`Content directory not found: ${contentRoot}/ (expected at ${contentDir})`);
12123
12802
  }
@@ -12194,20 +12873,22 @@ async function migrate(options) {
12194
12873
  const applyOps = async () => {
12195
12874
  for (const op of ops) {
12196
12875
  if (op.kind === "rename") {
12197
- await fs31.rename(op.from, op.to);
12876
+ await fs33.rename(op.from, op.to);
12198
12877
  } else {
12199
- await fs31.writeFile(op.filePath, op.content, "utf-8");
12878
+ await fs33.writeFile(op.filePath, op.content, "utf-8");
12200
12879
  }
12201
12880
  }
12202
12881
  };
12203
12882
  if (isBranchClone) {
12204
- const lockPath = path35.join(projectDir, ".canopy-meta", "schema");
12205
12883
  try {
12206
- await withLock2(lockPath, () => withOccFileLock2(lockPath, applyOps));
12884
+ await withBranchSchemaLock(projectDir, applyOps);
12207
12885
  } catch (err) {
12208
12886
  if (err instanceof OccWriteConflictError2) {
12209
12887
  throw new MigrateError("Another process is modifying the schema, try again.");
12210
12888
  }
12889
+ if (err instanceof ContentWriteLockBusyError2) {
12890
+ throw new MigrateError(err.outcome === "unknown" ? "The branch was being synced while migrate ran, so it may be partly applied. Check its state before running migrate again." : err.message);
12891
+ }
12211
12892
  throw err;
12212
12893
  }
12213
12894
  } else {
@@ -12238,8 +12919,9 @@ var init_migrate = __esm({
12238
12919
  init_fs();
12239
12920
  init_schema();
12240
12921
  init_error();
12241
- init_async_mutex();
12922
+ init_content_write_lock();
12242
12923
  init_occ_json_write();
12924
+ init_schema_store();
12243
12925
  init_branch_metadata();
12244
12926
  init_content_index_generation();
12245
12927
  MIGRATE_FORMATS = ["md", "mdx", "json", "yaml"];
@@ -12249,7 +12931,7 @@ var init_migrate = __esm({
12249
12931
  this.name = "MigrateError";
12250
12932
  }
12251
12933
  };
12252
- describeOp = (op, baseDir) => op.kind === "rename" ? `rename ${path35.relative(baseDir, op.from)} \u2192 ${path35.basename(op.to)}` : `create ${path35.relative(baseDir, op.filePath)}`;
12934
+ describeOp = (op, baseDir) => op.kind === "rename" ? `rename ${path38.relative(baseDir, op.from)} \u2192 ${path38.basename(op.to)}` : `create ${path38.relative(baseDir, op.filePath)}`;
12253
12935
  }
12254
12936
  });
12255
12937