canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Sparse checkout for content-branch clones. An editor reads and writes only under the content
3
+ * root, and on EFS every checked-out file costs an NFS round trip, so a content branch's clone
4
+ * checks out the content root and `.canopy-meta` in cone mode, which always keeps the files at
5
+ * the repository root (a root permissions.json among them).
6
+ *
7
+ * A cone is set only by `stageBranchWorkspace` and, on clones already sparse, by the worker
8
+ * (worker/sparse-cone.ts), so settings workspaces are never sparse; their orphan init also
9
+ * empties the index with `--sparse`, so a sparse one could not leak base content either.
10
+ * Sparse-index stays off: git expands it for most commands anyway.
11
+ *
12
+ * `git add` and `git rm` refuse a path outside the cone unless given `--sparse` (git >= 2.34),
13
+ * so every call that can name one passes it.
14
+ */
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { readsFromCheckout } from './build-mode.js';
18
+ import { operatingStrategy } from './operating-mode/index.js';
19
+ import { getErrorMessage, isNodeError } from './utils/error.js';
20
+ import { CANOPY_META_DIR } from './utils/git.js';
21
+ import { canopyLogWarn } from './utils/logger.js';
22
+ /**
23
+ * The cone a content branch's clone checks out for `contentRoot`, sorted as
24
+ * `git sparse-checkout list` prints it, or null for a full clone when the content root is the
25
+ * repository root.
26
+ */
27
+ export function sparseConeFor(contentRoot) {
28
+ const root = (contentRoot ?? 'content')
29
+ .split('/')
30
+ .filter((segment) => segment && segment !== '.')
31
+ .join('/');
32
+ return root ? [...new Set([CANOPY_META_DIR, root])].sort() : null;
33
+ }
34
+ export function sameCone(a, b) {
35
+ if (a === null || b === null)
36
+ return a === b;
37
+ const [left, right] = [[...a].sort(), [...b].sort()];
38
+ return left.length === right.length && left.every((dir, i) => dir === right[i]);
39
+ }
40
+ /**
41
+ * Beside the branch directories: the cone this deployment's config asks for, written by the
42
+ * processes that hold the config and read by the worker, which does not, to re-apply a changed
43
+ * content root to existing clones.
44
+ */
45
+ const SPARSE_CONE_RECORD = '.sparse-cone.json';
46
+ function isConeDir(dir) {
47
+ return (typeof dir === 'string' &&
48
+ dir.length > 0 &&
49
+ !path.isAbsolute(dir) &&
50
+ !dir.split('/').some((segment) => segment === '' || segment === '.' || segment === '..'));
51
+ }
52
+ /** The recorded cone, or undefined when none is recorded or the record does not parse. */
53
+ export async function readRecordedSparseCone(contentBranchesRoot) {
54
+ let raw;
55
+ try {
56
+ raw = await fs.readFile(path.join(contentBranchesRoot, SPARSE_CONE_RECORD), 'utf8');
57
+ }
58
+ catch (err) {
59
+ if (isNodeError(err) && err.code === 'ENOENT')
60
+ return undefined;
61
+ throw err;
62
+ }
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ if (typeof parsed !== 'object' || parsed === null || !('cone' in parsed))
71
+ return undefined;
72
+ const { cone } = parsed;
73
+ if (cone === null)
74
+ return { cone: null };
75
+ return Array.isArray(cone) && cone.length > 0 && cone.every(isConeDir)
76
+ ? { cone: cone.filter(isConeDir) }
77
+ : undefined;
78
+ }
79
+ /**
80
+ * Record `cone` unless it already is, by temp file and rename. Does not create the branches
81
+ * root: a deployment without one has no clones to re-apply a cone to.
82
+ */
83
+ export async function recordSparseCone(contentBranchesRoot, cone) {
84
+ const current = await readRecordedSparseCone(contentBranchesRoot);
85
+ if (current && sameCone(current.cone, cone))
86
+ return;
87
+ const target = path.join(contentBranchesRoot, SPARSE_CONE_RECORD);
88
+ const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
89
+ try {
90
+ await fs.writeFile(temp, `${JSON.stringify({ cone })}\n`, 'utf8');
91
+ }
92
+ catch (err) {
93
+ if (isNodeError(err) && err.code === 'ENOENT')
94
+ return;
95
+ throw err;
96
+ }
97
+ try {
98
+ await fs.rename(temp, target);
99
+ }
100
+ catch (err) {
101
+ await fs.unlink(temp).catch(() => { });
102
+ throw err;
103
+ }
104
+ }
105
+ /**
106
+ * Record the cone `config` asks for, once per process start, so a changed content root reaches
107
+ * existing clones without waiting for a new branch. Best-effort: a failure is logged.
108
+ */
109
+ export async function recordConfiguredSparseCone(config) {
110
+ try {
111
+ if (readsFromCheckout(config))
112
+ return;
113
+ await recordSparseCone(operatingStrategy(config.mode).getContentBranchesRoot(config.sourceRoot), sparseConeFor(config.contentRoot));
114
+ }
115
+ catch (err) {
116
+ canopyLogWarn(`[canopy] Could not record the sparse-checkout cone: ${getErrorMessage(err)}`);
117
+ }
118
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CanopyConfig } from './config/index.js';
2
+ import { type BlockingKind } from './branch-provisioning.js';
2
3
  import type { BranchAccessControl, BranchContext, CanopyUserId } from './types.js';
3
4
  import type { OperatingMode } from './operating-mode/index.js';
4
5
  export interface OpenBranchOptions {
@@ -12,14 +13,52 @@ export interface OpenBranchOptions {
12
13
  remoteUrl?: string;
13
14
  }
14
15
  /**
15
- * Manages per-branch filesystem workspace: resolves root, ensures metadata,
16
- * and updates the branch registry.
16
+ * `created`: this call published the branch. `exists`: it was already there, or another request
17
+ * published it first; its metadata is returned as found, never merged with this call's.
18
+ */
19
+ export interface ProvisionOutcome {
20
+ kind: 'created' | 'exists';
21
+ context: BranchContext;
22
+ }
23
+ /** @internal Test seams around the publish step. */
24
+ export interface ProvisioningTestHooks {
25
+ beforePublish?: (paths: {
26
+ stagingPath: string;
27
+ finalPath: string;
28
+ }) => Promise<void>;
29
+ inspectBlocked?: (finalPath: string) => Promise<BlockingKind>;
30
+ }
31
+ /** @internal Exported for tests; no argument clears them. */
32
+ export declare function setProvisioningTestHooks(hooks?: ProvisioningTestHooks): void;
33
+ /**
34
+ * Provisions per-branch workspaces under [PROV-1]: a branch's directory appears only by rename,
35
+ * complete with branch.json (branch-provisioning.ts).
17
36
  */
18
37
  export declare class BranchWorkspaceManager {
19
38
  private readonly config;
20
39
  constructor(config: CanopyConfig);
21
- private ensureGitWorkspace;
40
+ /** The branch's context, provisioning it first when it does not exist. */
22
41
  openOrCreateBranch(options: OpenBranchOptions): Promise<BranchContext>;
42
+ /**
43
+ * Provision a branch workspace, or report the one already there.
44
+ *
45
+ * @throws BranchProvisioningBusyError when another process is mid-way through this branch, or
46
+ * what is at its path is too fresh to judge (retriable)
47
+ * @throws BranchDirOccupiedError in prod, when its path holds a repository this deployment did
48
+ * not create
49
+ * @throws BranchMetadataCorruptError when its branch.json does not parse
50
+ */
51
+ provisionBranch(options: OpenBranchOptions): Promise<ProvisionOutcome>;
52
+ private provisionUnshared;
53
+ private stageAndPublish;
54
+ /**
55
+ * The one exception to [PROV-1]: a dev workspace that already holds real work is kept where it
56
+ * is, given branch.json under the branch's lock.
57
+ */
58
+ private adoptInPlace;
59
+ /** Whether residue at the branch path may still belong to a live process. */
60
+ private recentlyActive;
61
+ private existing;
23
62
  }
24
63
  export { loadBranchContext } from './branch-metadata.js';
25
64
  /**
@@ -1,121 +1,267 @@
1
+ import fs from 'node:fs/promises';
1
2
  import path from 'node:path';
2
- import { ensureBranchRoot } from './paths/index.js';
3
- import { getBranchMetadataFileManager, loadBranchContext } from './branch-metadata.js';
3
+ import { BranchPathError, isSettingsBranchName, resolveBranchPath } from './paths/index.js';
4
+ import { buildInitialBranchMetadata, loadBranchContext } from './branch-metadata.js';
5
+ import { BRANCH_META_DIR, BRANCH_META_FILE, readBranchMetadataFile } from './branch-metadata-file.js';
6
+ import { BranchRegistry } from './branch-registry.js';
7
+ import { BranchDirOccupiedError, BranchProvisioningBusyError, CREATE_RESIDUE_MIN_QUIET_MS, classifyFinalDir, removeLeftoverDir, lockBranchName, publishStaging, quietAgeMs, stageBranchWorkspace, } from './branch-provisioning.js';
8
+ import { recordSparseCone, sparseConeFor } from './branch-sparse.js';
4
9
  import { readsFromCheckout } from './build-mode.js';
10
+ import { invalidateBranchContentCaches } from './content-index-generation.js';
5
11
  import { operatingStrategy } from './operating-mode/index.js';
6
- import { GitManager } from './git-manager.js';
7
- import { createDebugLogger } from './utils/debug.js';
12
+ import { GitManager, managedWorkspaceConfig } from './git-manager.js';
13
+ import { getErrorMessage } from './utils/error.js';
8
14
  import { resolveBaseBranch } from './utils/git.js';
9
- import { acquireProvisioningLock } from './utils/provisioning-lock.js';
10
- const log = createDebugLogger({ prefix: 'BranchWorkspace' });
11
- // In-memory lock to prevent concurrent workspace initialization
12
- const workspaceInitLocks = new Map();
15
+ import { canopyLogWarn } from './utils/logger.js';
16
+ import { OccWriteConflictError, writeOccJsonFile } from './utils/occ-json-write.js';
17
+ import { ProvisionLog } from './utils/provision-log.js';
18
+ import { branchProvisioningLockName, PROVISIONING_LOCK_STALE_MS } from './utils/provisioning-lock.js';
19
+ let testHooks = {};
20
+ /** @internal Exported for tests; no argument clears them. */
21
+ export function setProvisioningTestHooks(hooks = {}) {
22
+ testHooks = hooks;
23
+ }
24
+ /** One provisioning per branch path per process; a joiner gets the leader's branch as `exists`. */
25
+ const provisionsInFlight = new Map();
13
26
  /**
14
- * Manages per-branch filesystem workspace: resolves root, ensures metadata,
15
- * and updates the branch registry.
27
+ * Provisions per-branch workspaces under [PROV-1]: a branch's directory appears only by rename,
28
+ * complete with branch.json (branch-provisioning.ts).
16
29
  */
17
30
  export class BranchWorkspaceManager {
18
31
  constructor(config) {
19
32
  this.config = config;
20
33
  }
21
- async ensureGitWorkspace(options) {
22
- return log.timed('workspace', 'ensureGitWorkspace', async () => {
23
- // One initialization per branch workspace per process.
24
- const existingLock = workspaceInitLocks.get(options.branchRoot);
25
- if (existingLock) {
26
- await existingLock;
27
- return;
28
- }
29
- const lockPromise = (async () => {
30
- // The in-memory lock above only serializes one process, and separate
31
- // processes can provision the same workspace at once (several Lambda
32
- // containers on one EFS root; the dev server beside a content-reading
33
- // script), which without a cross-process lock means both cloning into
34
- // it: "destination path already exists". initializeWorkspace is
35
- // idempotent, so the waiter finds the workspace already cloned. A
36
- // build's content reads never get here — loadOrCreateBranchContext
37
- // returns the checkout before provisioning.
38
- let releaseLock;
39
- try {
40
- log.debug('workspace', 'Ensuring git workspace', {
41
- branchName: options.branchName,
42
- mode: options.mode,
43
- });
44
- releaseLock = await acquireProvisioningLock(path.dirname(options.branchRoot), `.${path.basename(options.branchRoot)}.init.lock`);
45
- await GitManager.initializeWorkspace({
46
- workspacePath: options.branchRoot,
47
- branchName: options.branchName,
48
- mode: options.mode,
49
- baseBranch: options.baseBranch,
50
- sourceRoot: this.config.sourceRoot,
51
- defaultRemoteUrl: this.config.defaultRemoteUrl,
52
- remoteUrl: options.remoteUrl,
53
- remoteName: this.config.defaultRemoteName,
54
- allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
55
- branchType: 'content',
56
- gitBotAuthorName: this.config.gitBotAuthorName,
57
- gitBotAuthorEmail: this.config.gitBotAuthorEmail,
58
- gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern(),
59
- });
60
- }
61
- finally {
62
- // Release the cross-process lock first, then clear the in-memory lock.
63
- if (releaseLock) {
64
- try {
65
- await releaseLock();
66
- }
67
- catch (err) {
68
- log.debug('workspace', 'Failed to release workspace-init lock', { err });
69
- }
70
- }
71
- workspaceInitLocks.delete(options.branchRoot);
72
- }
73
- })();
74
- workspaceInitLocks.set(options.branchRoot, lockPromise);
75
- await lockPromise;
76
- });
77
- }
34
+ /** The branch's context, provisioning it first when it does not exist. */
78
35
  async openOrCreateBranch(options) {
79
- const { branchName, mode, basePathOverride, title, description, access, createdBy, remoteUrl } = options;
80
- const { branchRoot, baseRoot, branchName: safeName, } = await ensureBranchRoot({
81
- mode,
82
- branchName,
83
- basePathOverride,
84
- });
85
- // Resolve the fork point once so the workspace clone and the recorded
86
- // metadata are guaranteed to agree (config value, or dev-mode git HEAD).
36
+ return (await this.provisionBranch(options)).context;
37
+ }
38
+ /**
39
+ * Provision a branch workspace, or report the one already there.
40
+ *
41
+ * @throws BranchProvisioningBusyError when another process is mid-way through this branch, or
42
+ * what is at its path is too fresh to judge (retriable)
43
+ * @throws BranchDirOccupiedError in prod, when its path holds a repository this deployment did
44
+ * not create
45
+ * @throws BranchMetadataCorruptError when its branch.json does not parse
46
+ */
47
+ async provisionBranch(options) {
48
+ // resolveBranchPath refuses the reserved prefix; this also refuses an adopter's
49
+ // configured settings branch name, which only the config knows.
50
+ if (isSettingsBranchName(options.branchName, operatingStrategy(options.mode).getSettingsBranchName(this.config))) {
51
+ throw new BranchPathError('Settings branches are not content branches');
52
+ }
53
+ const { branchRoot, baseRoot, branchName: safeName } = resolveBranchPath(options);
54
+ const paths = {
55
+ branchRoot,
56
+ baseRoot,
57
+ dirName: path.basename(branchRoot),
58
+ safeName,
59
+ };
60
+ const existing = await readBranchMetadataFile(branchRoot);
61
+ if (existing) {
62
+ return { kind: 'exists', context: { branch: existing.branch, branchRoot, baseRoot } };
63
+ }
64
+ const inFlight = provisionsInFlight.get(branchRoot);
65
+ if (inFlight)
66
+ return { kind: 'exists', context: (await inFlight).context };
67
+ const run = this.provisionUnshared(options, paths);
68
+ provisionsInFlight.set(branchRoot, run);
69
+ try {
70
+ return await run;
71
+ }
72
+ finally {
73
+ provisionsInFlight.delete(branchRoot);
74
+ }
75
+ }
76
+ async provisionUnshared(options, paths) {
77
+ const { branchRoot, baseRoot, dirName } = paths;
78
+ await fs.mkdir(baseRoot, { recursive: true });
79
+ // Resolve the fork point once so the clone and the recorded metadata agree (config value, or
80
+ // dev-mode git HEAD), and the remote before any clone is paid for.
87
81
  const baseBranch = await resolveBaseBranch({
88
82
  defaultBaseBranch: this.config.defaultBaseBranch,
89
- mode,
83
+ mode: options.mode,
90
84
  detectFrom: this.config.sourceRoot
91
85
  ? path.resolve(process.cwd(), this.config.sourceRoot)
92
86
  : undefined,
93
87
  });
94
- await this.ensureGitWorkspace({
95
- branchRoot,
96
- branchName: safeName,
97
- mode,
88
+ const remoteUrl = await GitManager.resolveCloneRemoteUrl({
89
+ mode: options.mode,
90
+ remoteUrl: options.remoteUrl,
91
+ defaultRemoteUrl: this.config.defaultRemoteUrl,
98
92
  baseBranch,
99
- remoteUrl,
93
+ sourceRoot: this.config.sourceRoot,
94
+ allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
100
95
  });
101
- // save() covers creation and update, keeping existing values and
102
- // invalidating the registry.
103
- const metadata = getBranchMetadataFileManager(branchRoot, baseRoot);
104
- const meta = await metadata.save({
96
+ const metadata = buildInitialBranchMetadata({
105
97
  branch: {
106
- name: safeName,
107
- title,
108
- description,
109
- access,
110
- createdBy,
98
+ name: paths.safeName,
99
+ title: options.title,
100
+ description: options.description,
101
+ access: options.access,
102
+ createdBy: options.createdBy,
111
103
  baseBranch,
112
104
  },
113
105
  });
114
- return {
115
- branch: meta.branch,
116
- branchRoot,
106
+ // Advisory and lock-free: refuse early, before paying for a clone, what publish would
107
+ // refuse anyway. Publish re-checks everything under the lock.
108
+ const state = await classifyFinalDir(branchRoot, remoteUrl);
109
+ if (state.kind === 'live' || state.kind === 'corrupt')
110
+ return this.existing(paths);
111
+ if (state.kind === 'protected')
112
+ throw new BranchProvisioningBusyError(dirName);
113
+ if (state.kind === 'foreign') {
114
+ // dev's CLI sync builds a branch workspace by `git init` and real commits, with no
115
+ // remote and no branch.json; it is adopted where it is.
116
+ if (options.mode !== 'dev')
117
+ throw new BranchDirOccupiedError(dirName);
118
+ return this.adoptInPlace(options, paths, baseBranch, metadata);
119
+ }
120
+ if (state.kind === 'residue' && (await this.recentlyActive(paths))) {
121
+ throw new BranchProvisioningBusyError(dirName);
122
+ }
123
+ const sparseCone = sparseConeFor(this.config.contentRoot);
124
+ await recordSparseCone(baseRoot, sparseCone).catch((err) => {
125
+ canopyLogWarn(`[canopy] Could not record the sparse-checkout cone: ${getErrorMessage(err)}`);
126
+ });
127
+ const provisionLog = new ProvisionLog(dirName);
128
+ let outcome;
129
+ try {
130
+ outcome = await this.stageAndPublish(paths, {
131
+ provisionLog,
132
+ baseBranch,
133
+ remoteUrl,
134
+ metadata,
135
+ mode: options.mode,
136
+ sparseCone,
137
+ });
138
+ }
139
+ catch (err) {
140
+ provisionLog.finish('error');
141
+ throw err;
142
+ }
143
+ provisionLog.finish(outcome.kind === 'created' ? 'ok' : 'exists');
144
+ return outcome;
145
+ }
146
+ async stageAndPublish(paths, run) {
147
+ const { branchRoot, baseRoot, dirName } = paths;
148
+ const staged = await stageBranchWorkspace({
117
149
  baseRoot,
118
- };
150
+ dirName,
151
+ branchName: paths.safeName,
152
+ baseBranch: run.baseBranch,
153
+ remoteUrl: run.remoteUrl,
154
+ clone: {
155
+ remoteName: this.config.defaultRemoteName,
156
+ config: managedWorkspaceConfig(this.config.gitBotAuthorName, this.config.gitBotAuthorEmail),
157
+ },
158
+ gitExcludePattern: operatingStrategy(run.mode).getGitExcludePattern(),
159
+ metadata: run.metadata,
160
+ provisionLog: run.provisionLog,
161
+ sparseCone: run.sparseCone,
162
+ });
163
+ if (staged.kind === 'exists')
164
+ return this.existing(paths);
165
+ let published;
166
+ try {
167
+ await testHooks.beforePublish?.({ stagingPath: staged.stagingPath, finalPath: branchRoot });
168
+ published = await run.provisionLog.step('publish', () => publishStaging({
169
+ baseRoot,
170
+ dirName,
171
+ stagingPath: staged.stagingPath,
172
+ writeId: staged.meta.writeId,
173
+ expectedRemoteUrl: run.remoteUrl,
174
+ inspectBlocked: testHooks.inspectBlocked,
175
+ }));
176
+ }
177
+ finally {
178
+ if (published !== 'published')
179
+ await removeLeftoverDir(staged.stagingPath);
180
+ }
181
+ if (published === 'exists')
182
+ return this.existing(paths);
183
+ await run.provisionLog.step('register', () => register(paths));
184
+ return { kind: 'created', context: { branch: staged.meta.branch, branchRoot, baseRoot } };
185
+ }
186
+ /**
187
+ * The one exception to [PROV-1]: a dev workspace that already holds real work is kept where it
188
+ * is, given branch.json under the branch's lock.
189
+ */
190
+ async adoptInPlace(options, paths, baseBranch, metadata) {
191
+ const { branchRoot, baseRoot, dirName } = paths;
192
+ const provisionLog = new ProvisionLog(dirName);
193
+ let created = false;
194
+ try {
195
+ const release = await provisionLog.step('lock', () => lockBranchName(baseRoot, dirName));
196
+ try {
197
+ if (!(await readBranchMetadataFile(branchRoot))) {
198
+ await GitManager.initializeWorkspace({
199
+ workspacePath: branchRoot,
200
+ branchName: paths.safeName,
201
+ mode: options.mode,
202
+ baseBranch,
203
+ sourceRoot: this.config.sourceRoot,
204
+ defaultRemoteUrl: this.config.defaultRemoteUrl,
205
+ remoteUrl: options.remoteUrl,
206
+ remoteName: this.config.defaultRemoteName,
207
+ allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
208
+ branchType: 'content',
209
+ gitBotAuthorName: this.config.gitBotAuthorName,
210
+ gitBotAuthorEmail: this.config.gitBotAuthorEmail,
211
+ gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern(),
212
+ provisionLog,
213
+ });
214
+ created = await provisionLog.step('metadata', () => writeOccJsonFile(path.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE), { ...metadata }, { expectedVersion: null, trailingNewline: true }).then(() => true, (err) => {
215
+ if (err instanceof OccWriteConflictError)
216
+ return false;
217
+ throw err;
218
+ }));
219
+ }
220
+ }
221
+ finally {
222
+ await release().catch(() => { });
223
+ }
224
+ if (created)
225
+ await provisionLog.step('register', () => register(paths));
226
+ }
227
+ catch (err) {
228
+ provisionLog.finish('error');
229
+ throw err;
230
+ }
231
+ provisionLog.finish(created ? 'ok' : 'exists');
232
+ if (!created)
233
+ return this.existing(paths);
234
+ const meta = await readBranchMetadataFile(branchRoot);
235
+ if (!meta)
236
+ throw new BranchProvisioningBusyError(dirName);
237
+ return { kind: 'created', context: { branch: meta.branch, branchRoot, baseRoot } };
238
+ }
239
+ /** Whether residue at the branch path may still belong to a live process. */
240
+ async recentlyActive({ branchRoot, baseRoot, dirName }) {
241
+ const lockAgeMs = await fs.stat(path.join(baseRoot, branchProvisioningLockName(dirName))).then((stat) => Date.now() - stat.mtimeMs, () => Infinity);
242
+ if (lockAgeMs < PROVISIONING_LOCK_STALE_MS)
243
+ return true;
244
+ return (await quietAgeMs(branchRoot)) < CREATE_RESIDUE_MIN_QUIET_MS;
245
+ }
246
+ async existing({ branchRoot, baseRoot, dirName, }) {
247
+ const meta = await readBranchMetadataFile(branchRoot);
248
+ if (!meta)
249
+ throw new BranchProvisioningBusyError(dirName);
250
+ return { kind: 'exists', context: { branch: meta.branch, branchRoot, baseRoot } };
251
+ }
252
+ }
253
+ /**
254
+ * A new branch directory replaces whatever an earlier one of the same name left in this
255
+ * process's caches, and joins the registry. The branch is already published, so a failure here
256
+ * is logged rather than failing the create; the registry's miss backstop covers it.
257
+ */
258
+ async function register({ branchRoot, baseRoot }) {
259
+ try {
260
+ await invalidateBranchContentCaches(branchRoot);
261
+ await new BranchRegistry(baseRoot).invalidate();
262
+ }
263
+ catch (err) {
264
+ canopyLogWarn(`[canopy] Registering new branch ${branchRoot} failed: ${getErrorMessage(err)}`);
119
265
  }
120
266
  }
121
267
  export { loadBranchContext } from './branch-metadata.js';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Collect the asset URLs a static build references, so a release can make them exist first.
3
+ *
4
+ * The public `/assets/t/` path serves only what is stored, and widths are chosen by site code at
5
+ * render time, so build output is the one place every final URL appears. The adopter contract
6
+ * that follows: every `/assets/t/` URL the site can request must appear as text in its build
7
+ * output (ARCHITECTURE.md, "Why transform by URL directive and materialize at release?").
8
+ */
9
+ import { z } from 'zod';
10
+ /** @internal Written into the scanned directory, so a manifest the adopter builds afterwards covers it. */
11
+ export declare const ASSET_REFS_FILENAME = "canopy-asset-refs.json";
12
+ declare const assetRefsFileSchema: z.ZodObject<{
13
+ version: z.ZodLiteral<1>;
14
+ transforms: z.ZodArray<z.ZodObject<{
15
+ key: z.ZodString;
16
+ routes: z.ZodArray<z.ZodString, "many">;
17
+ files: z.ZodArray<z.ZodString, "many">;
18
+ }, "strict", z.ZodTypeAny, {
19
+ key: string;
20
+ files: string[];
21
+ routes: string[];
22
+ }, {
23
+ key: string;
24
+ files: string[];
25
+ routes: string[];
26
+ }>, "many">;
27
+ statics: z.ZodArray<z.ZodObject<{
28
+ key: z.ZodString;
29
+ routes: z.ZodArray<z.ZodString, "many">;
30
+ files: z.ZodArray<z.ZodString, "many">;
31
+ }, "strict", z.ZodTypeAny, {
32
+ key: string;
33
+ files: string[];
34
+ routes: string[];
35
+ }, {
36
+ key: string;
37
+ files: string[];
38
+ routes: string[];
39
+ }>, "many">;
40
+ }, "strict", z.ZodTypeAny, {
41
+ version: 1;
42
+ transforms: {
43
+ key: string;
44
+ files: string[];
45
+ routes: string[];
46
+ }[];
47
+ statics: {
48
+ key: string;
49
+ files: string[];
50
+ routes: string[];
51
+ }[];
52
+ }, {
53
+ version: 1;
54
+ transforms: {
55
+ key: string;
56
+ files: string[];
57
+ routes: string[];
58
+ }[];
59
+ statics: {
60
+ key: string;
61
+ files: string[];
62
+ routes: string[];
63
+ }[];
64
+ }>;
65
+ export type AssetRefsFile = z.infer<typeof assetRefsFileSchema>;
66
+ /** A URL in the output that no stored object can answer. */
67
+ export interface AssetRefProblem {
68
+ file: string;
69
+ url: string;
70
+ error: string;
71
+ }
72
+ /** The output references URLs that cannot be served from the store; nothing was written. */
73
+ export declare class AssetRefsError extends Error {
74
+ readonly problems: readonly AssetRefProblem[];
75
+ constructor(problems: readonly AssetRefProblem[]);
76
+ }
77
+ export interface CollectAssetRefsResult {
78
+ refs: AssetRefsFile;
79
+ /** Absolute path of the refs file written. */
80
+ filePath: string;
81
+ scannedFiles: number;
82
+ }
83
+ /**
84
+ * Scan `outDir` for `/assets/t/...` transform URLs and `/assets/{hash32}/...` static URLs and
85
+ * write their stored keys to `outDir/canopy-asset-refs.json`, sorted so the same output always
86
+ * yields the same file. Throws `AssetRefsError`, writing nothing, if any transform URL is not in
87
+ * canonical form or any static URL is malformed: only canonical keys are stored, and the public
88
+ * path is to compute nothing on a miss.
89
+ */
90
+ export declare function collectAssetRefs(outDir: string): Promise<CollectAssetRefsResult>;
91
+ /** Read and validate a refs file `collectAssetRefs` wrote. */
92
+ export declare function readAssetRefsFile(filePath: string): Promise<AssetRefsFile>;
93
+ export {};