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,73 @@
1
+ import type { CanopyUser } from './user.js';
2
+ /**
3
+ * Who submitted (and edited) a branch, recorded in the submit commit's trailers
4
+ * and the pull request body. The bot stays the commit author; these records are
5
+ * the only place the editing user appears.
6
+ *
7
+ * Display names, ids and emails come from the auth provider and end up in git
8
+ * history and in GitHub-rendered Markdown, so each passes through a sanitizer
9
+ * below before it is written anywhere.
10
+ */
11
+ export interface SubmissionEditor {
12
+ userId: string;
13
+ name?: string;
14
+ email?: string;
15
+ }
16
+ /**
17
+ * The PR body region canopycms owns. On update only the text between these
18
+ * markers is replaced, so anything a human wrote around it survives.
19
+ * @internal Exported for tests.
20
+ */
21
+ export declare const PR_SECTION_START = "<!-- canopycms:submission:start -->";
22
+ /** @internal Exported for tests. */
23
+ export declare const PR_SECTION_END = "<!-- canopycms:submission:end -->";
24
+ /**
25
+ * A display name safe for a single line of git trailer or Markdown, or undefined.
26
+ * @internal Exported for tests.
27
+ */
28
+ export declare function sanitizeDisplayName(raw: string | undefined): string | undefined;
29
+ /**
30
+ * The auth user id unchanged, or undefined if it holds characters unsafe to record.
31
+ * @internal Exported for tests.
32
+ */
33
+ export declare function sanitizeUserId(raw: string | undefined): string | undefined;
34
+ /**
35
+ * The email as given if it is a plain single-address email, otherwise undefined.
36
+ * @internal Exported for tests.
37
+ */
38
+ export declare function sanitizeEmail(raw: string | undefined): string | undefined;
39
+ /** The submitter as a SubmissionEditor, or undefined for an anonymous user. */
40
+ export declare function submissionEditorFromUser(user: CanopyUser): SubmissionEditor | undefined;
41
+ export interface CommitTrailerOptions {
42
+ /** One `Edited-by: Name (id)` per editor. */
43
+ editedBy: boolean;
44
+ /** One `Co-authored-by: Name <email>` per editor whose email is valid. */
45
+ coAuthoredBy: boolean;
46
+ }
47
+ /** Git trailer lines for the given editors, deduplicated by user id. */
48
+ export declare function buildEditorTrailers(editors: readonly SubmissionEditor[], options: CommitTrailerOptions): string[];
49
+ /**
50
+ * `subject`, then a blank line and the trailer block when there are trailers.
51
+ * Git reads trailers only from the last paragraph, so the blank line is what
52
+ * makes them trailers rather than part of the subject.
53
+ */
54
+ export declare function appendTrailers(subject: string, trailers: readonly string[]): string;
55
+ export interface PrSectionInput {
56
+ /** The branch description the editor wrote; free Markdown. */
57
+ description?: string;
58
+ submitter?: SubmissionEditor;
59
+ /** Editors other than the submitter, when known. */
60
+ editors?: readonly SubmissionEditor[];
61
+ /** Repo-relative paths changed on the branch. */
62
+ changedPaths: readonly string[];
63
+ }
64
+ /** The canopycms-owned PR body section, including its start and end markers. */
65
+ export declare function buildPrSection(input: PrSectionInput): string;
66
+ /**
67
+ * `existing` with its canopycms section replaced by `section`, keeping every
68
+ * character outside the markers. The section is the first end marker together
69
+ * with the nearest start marker before it, so a lone start marker a human quoted
70
+ * earlier in the body is skipped. With no such pair, stray markers are
71
+ * removed and `section` is appended after the human text.
72
+ */
73
+ export declare function mergePrSection(existing: string | null | undefined, section: string): string;
@@ -0,0 +1,221 @@
1
+ import { canopyLogWarn } from './utils/logger.js';
2
+ const MAX_NAME_LENGTH = 80;
3
+ const MAX_ID_LENGTH = 128;
4
+ const MAX_EMAIL_LENGTH = 254;
5
+ const MAX_DESCRIPTION_LENGTH = 10_000;
6
+ const MAX_LISTED_ENTRIES = 100;
7
+ /**
8
+ * The PR body region canopycms owns. On update only the text between these
9
+ * markers is replaced, so anything a human wrote around it survives.
10
+ * @internal Exported for tests.
11
+ */
12
+ export const PR_SECTION_START = '<!-- canopycms:submission:start -->';
13
+ /** @internal Exported for tests. */
14
+ export const PR_SECTION_END = '<!-- canopycms:submission:end -->';
15
+ // Cc (C0/C1 controls incl. newlines), Cf (zero-width and bidi overrides), Zl/Zp
16
+ // (Unicode line/paragraph separators) can forge a trailer line or hide text; Cs
17
+ // (a lone surrogate) reaches git as U+FFFD, since Node encodes it so.
18
+ const INVISIBLE_OR_CONTROL = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu;
19
+ // `<>` would end a Co-authored-by email or open HTML / a section marker, `()`
20
+ // would let a name forge the id that follows it, a backtick would close the
21
+ // code span names render in, and a backslash is Markdown's escape character.
22
+ const STRUCTURAL = /[<>()`\\]/g;
23
+ function truncate(value, max) {
24
+ const chars = Array.from(value);
25
+ return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : value;
26
+ }
27
+ /**
28
+ * A changed path for a code span: only what could break out of the span or out
29
+ * of the section is removed, so `app/(group)/page.mdx` stays distinct from
30
+ * `app/page.mdx`. No NFKC, for the same reason.
31
+ */
32
+ function cleanPath(raw) {
33
+ const cleaned = raw.replace(INVISIBLE_OR_CONTROL, ' ').replace(/[<>`]/g, '').trim();
34
+ return cleaned ? truncate(cleaned, 300) : undefined;
35
+ }
36
+ function cleanText(raw, max) {
37
+ if (raw === undefined)
38
+ return undefined;
39
+ const cleaned = raw
40
+ .normalize('NFKC')
41
+ .replace(INVISIBLE_OR_CONTROL, ' ')
42
+ .replace(STRUCTURAL, '')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ return cleaned ? truncate(cleaned, max) : undefined;
46
+ }
47
+ /**
48
+ * A display name safe for a single line of git trailer or Markdown, or undefined.
49
+ * @internal Exported for tests.
50
+ */
51
+ export function sanitizeDisplayName(raw) {
52
+ return cleanText(raw, MAX_NAME_LENGTH);
53
+ }
54
+ // Ids and emails are accepted as the provider issued them or rejected, never
55
+ // cleaned: cleaning could merge two users or attribute an edit to someone else.
56
+ // A trailer still swaps in look-alikes for display (trailerText).
57
+ const UNSAFE_VERBATIM_CHAR = /[\s\p{Cc}\p{Cf}\p{Cs}<>()`\\]/u;
58
+ /**
59
+ * The auth user id unchanged, or undefined if it holds characters unsafe to record.
60
+ * @internal Exported for tests.
61
+ */
62
+ export function sanitizeUserId(raw) {
63
+ if (!raw || Array.from(raw).length > MAX_ID_LENGTH || UNSAFE_VERBATIM_CHAR.test(raw))
64
+ return undefined;
65
+ return raw;
66
+ }
67
+ const EMAIL_SHAPE = /^[^\s@<>()"',;:\\[\]]+@[^\s@<>()"',;:\\[\]]+\.[^\s@<>()"',;:\\[\]]+$/;
68
+ /**
69
+ * The email as given if it is a plain single-address email, otherwise undefined.
70
+ * @internal Exported for tests.
71
+ */
72
+ export function sanitizeEmail(raw) {
73
+ if (raw === undefined)
74
+ return undefined;
75
+ const trimmed = raw.trim();
76
+ // `\s` misses most C0/C1 controls, and a NUL makes git's spawn throw. `#` is
77
+ // legal in an address but GitHub links `#12` in a commit message.
78
+ if (trimmed.length > MAX_EMAIL_LENGTH ||
79
+ UNSAFE_VERBATIM_CHAR.test(trimmed) ||
80
+ trimmed.includes('#'))
81
+ return undefined;
82
+ return EMAIL_SHAPE.test(trimmed) ? trimmed : undefined;
83
+ }
84
+ /** The submitter as a SubmissionEditor, or undefined for an anonymous user. */
85
+ export function submissionEditorFromUser(user) {
86
+ if (user.type !== 'authenticated')
87
+ return undefined;
88
+ return { userId: user.userId, name: user.name, email: user.email };
89
+ }
90
+ function cleanEditors(editors) {
91
+ const seen = new Set();
92
+ const result = [];
93
+ for (const editor of editors) {
94
+ const userId = sanitizeUserId(editor.userId);
95
+ if (!userId) {
96
+ // The id itself stays out of the log: it is the unsafe value.
97
+ if (editor.userId) {
98
+ canopyLogWarn('CanopyCMS: Not recording an editor whose user id is unsafe to write to git');
99
+ }
100
+ continue;
101
+ }
102
+ if (seen.has(userId))
103
+ continue;
104
+ seen.add(userId);
105
+ result.push({
106
+ userId,
107
+ name: sanitizeDisplayName(editor.name),
108
+ email: sanitizeEmail(editor.email),
109
+ });
110
+ }
111
+ return result;
112
+ }
113
+ /**
114
+ * GitHub autolinks commit messages: `@user` mentions, `scheme://` URLs, and `#12` /
115
+ * `GH-12` issue references, which a closing keyword ("Closes #12") turns into an
116
+ * issue close once the commit reaches the default branch. A trailer value gets
117
+ * `@` and `#` swapped for look-alikes and the other two patterns broken. Its
118
+ * input is already single-line and structure-free.
119
+ */
120
+ function trailerText(value) {
121
+ return value
122
+ .replace(/@/g, '@')
123
+ .replace(/#/g, '#')
124
+ .replace(/:\/\//g, ': //')
125
+ .replace(/\bGH-(?=\d)/gi, (m) => `${m.slice(0, 2)}‐`);
126
+ }
127
+ /** Git trailer lines for the given editors, deduplicated by user id. */
128
+ export function buildEditorTrailers(editors, options) {
129
+ const trailers = [];
130
+ const clean = cleanEditors(editors);
131
+ if (options.editedBy) {
132
+ for (const editor of clean) {
133
+ trailers.push(editor.name
134
+ ? `Edited-by: ${trailerText(editor.name)} (${trailerText(editor.userId)})`
135
+ : `Edited-by: ${trailerText(editor.userId)}`);
136
+ }
137
+ }
138
+ if (options.coAuthoredBy) {
139
+ for (const editor of clean) {
140
+ if (!editor.email)
141
+ continue;
142
+ trailers.push(`Co-authored-by: ${trailerText(editor.name ?? editor.userId)} <${editor.email}>`);
143
+ }
144
+ }
145
+ return trailers;
146
+ }
147
+ /**
148
+ * `subject`, then a blank line and the trailer block when there are trailers.
149
+ * Git reads trailers only from the last paragraph, so the blank line is what
150
+ * makes them trailers rather than part of the subject.
151
+ */
152
+ export function appendTrailers(subject, trailers) {
153
+ return trailers.length > 0 ? `${subject.trimEnd()}\n\n${trailers.join('\n')}` : subject;
154
+ }
155
+ /**
156
+ * Renders as inline code: GitHub neither autolinks, mentions nor interprets HTML
157
+ * inside a code span. The input is already free of backticks.
158
+ */
159
+ function codeSpan(value) {
160
+ return `\`${value}\``;
161
+ }
162
+ /**
163
+ * Free Markdown with every HTML comment opener and closer escaped, so the text
164
+ * can neither close the canopycms section early nor open a comment that hides
165
+ * the rest of the body. Escaped comments render as their literal text.
166
+ */
167
+ function neutralizeComments(text) {
168
+ return text.replace(/<!--/g, '&lt;!--').replace(/--!?>/g, '--&gt;');
169
+ }
170
+ function editorLabel(editor) {
171
+ return editor.name
172
+ ? `${codeSpan(editor.name)} (${codeSpan(editor.userId)})`
173
+ : codeSpan(editor.userId);
174
+ }
175
+ /** The canopycms-owned PR body section, including its start and end markers. */
176
+ export function buildPrSection(input) {
177
+ const parts = [];
178
+ const [submitter] = input.submitter ? cleanEditors([input.submitter]) : [];
179
+ parts.push(submitter
180
+ ? `Submitted by ${editorLabel(submitter)} via CanopyCMS.`
181
+ : 'Submitted via CanopyCMS.');
182
+ const others = cleanEditors(input.editors ?? []).filter((e) => e.userId !== submitter?.userId);
183
+ if (others.length > 0) {
184
+ parts.push(`Also edited by: ${others.map(editorLabel).join(', ')}`);
185
+ }
186
+ const paths = [...new Set(input.changedPaths)]
187
+ .map(cleanPath)
188
+ .filter((p) => p !== undefined);
189
+ if (paths.length > 0) {
190
+ const listed = paths.slice(0, MAX_LISTED_ENTRIES).map((p) => `- ${codeSpan(p)}`);
191
+ if (paths.length > MAX_LISTED_ENTRIES) {
192
+ listed.push(`- …and ${paths.length - MAX_LISTED_ENTRIES} more`);
193
+ }
194
+ parts.push(`**Changed entries (${paths.length})**\n\n${listed.join('\n')}`);
195
+ }
196
+ // Last, so an unclosed fence or `<details>` in free Markdown cannot swallow
197
+ // the attribution above it.
198
+ const description = input.description?.trim();
199
+ if (description)
200
+ parts.push(neutralizeComments(truncate(description, MAX_DESCRIPTION_LENGTH)));
201
+ return `${PR_SECTION_START}\n${parts.join('\n\n')}\n${PR_SECTION_END}`;
202
+ }
203
+ /**
204
+ * `existing` with its canopycms section replaced by `section`, keeping every
205
+ * character outside the markers. The section is the first end marker together
206
+ * with the nearest start marker before it, so a lone start marker a human quoted
207
+ * earlier in the body is skipped. With no such pair, stray markers are
208
+ * removed and `section` is appended after the human text.
209
+ */
210
+ export function mergePrSection(existing, section) {
211
+ const body = existing ?? '';
212
+ for (let end = body.indexOf(PR_SECTION_END); end >= 0;) {
213
+ const start = body.lastIndexOf(PR_SECTION_START, end - PR_SECTION_START.length);
214
+ if (start >= 0) {
215
+ return body.slice(0, start) + section + body.slice(end + PR_SECTION_END.length);
216
+ }
217
+ end = body.indexOf(PR_SECTION_END, end + PR_SECTION_END.length);
218
+ }
219
+ const human = body.split(PR_SECTION_START).join('').split(PR_SECTION_END).join('').trimEnd();
220
+ return human ? `${human}\n\n${section}` : section;
221
+ }
@@ -44,13 +44,24 @@ export interface PushContentToWorkspaceOptions {
44
44
  commitMessage?: string;
45
45
  /** When set, (re)tag the resulting commit as the sync base (used by `sync both` 3-way merges). */
46
46
  baseTag?: string;
47
+ /**
48
+ * When set, uncommitted workspace changes (editor saves) are first committed with this
49
+ * message, so the content replacement leaves them in history rather than discarding them.
50
+ */
51
+ saveEditorStateMessage?: string;
47
52
  }
48
53
  /**
49
54
  * Copy working-tree content into a branch workspace and commit it. Prompt-free — the interactive CLI
50
55
  * (`canopycms sync push`) calls this for the actual copy + commit + tag step.
51
56
  *
52
- * Returns the number of changed files committed (0 when content was already up to date).
57
+ * [SYNC-C1] Replaces the workspace's content directory wholesale, so it runs under the branch's
58
+ * content-write lock: an editor save landing mid-swap would go into the directory being discarded.
59
+ * Rejects with `ContentWriteLockBusyError` when the branch is busy.
60
+ *
61
+ * Returns the number of changed files committed (0 when content was already up to date), and
62
+ * whether uncommitted editor changes were committed first.
53
63
  */
54
64
  export declare function pushContentToWorkspace(options: PushContentToWorkspaceOptions): Promise<{
55
65
  fileCount: number;
66
+ savedEditorState: boolean;
56
67
  }>;
package/dist/sync-core.js CHANGED
@@ -7,7 +7,9 @@ import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { simpleGit } from 'simple-git';
9
9
  import { invalidateBranchContentCaches } from './content-index-generation.js';
10
+ import { withContentWriteLock } from './utils/content-write-lock.js';
10
11
  import { filePathExists } from './utils/fs.js';
12
+ import { isCanopyInternalPath, stageAllExceptCanopyState } from './utils/git.js';
11
13
  /** Git tag marking the last known sync point, used as the merge base for `sync both` 3-way merges. */
12
14
  export const SYNC_BASE_TAG = 'canopycms-sync-base';
13
15
  /** Validate that a resolved path stays within the expected parent directory. */
@@ -132,15 +134,34 @@ export async function diffContentTrees(workingTreeDir, branchDir) {
132
134
  * Copy working-tree content into a branch workspace and commit it. Prompt-free — the interactive CLI
133
135
  * (`canopycms sync push`) calls this for the actual copy + commit + tag step.
134
136
  *
135
- * Returns the number of changed files committed (0 when content was already up to date).
137
+ * [SYNC-C1] Replaces the workspace's content directory wholesale, so it runs under the branch's
138
+ * content-write lock: an editor save landing mid-swap would go into the directory being discarded.
139
+ * Rejects with `ContentWriteLockBusyError` when the branch is busy.
140
+ *
141
+ * Returns the number of changed files committed (0 when content was already up to date), and
142
+ * whether uncommitted editor changes were committed first.
136
143
  */
137
144
  export async function pushContentToWorkspace(options) {
138
- const { srcContentDir, branchPath, contentRoot, commitMessage, baseTag } = options;
145
+ const { srcContentDir, branchPath, contentRoot } = options;
139
146
  if (!(await filePathExists(srcContentDir))) {
140
- return { fileCount: 0 };
147
+ return { fileCount: 0, savedEditorState: false };
141
148
  }
142
149
  const wsContentDir = path.join(branchPath, contentRoot);
143
150
  assertWithinDir(wsContentDir, branchPath, 'content-root');
151
+ return withContentWriteLock(branchPath, () => replaceWorkspaceContent(options, wsContentDir));
152
+ }
153
+ async function replaceWorkspaceContent(options, wsContentDir) {
154
+ const { srcContentDir, branchPath, commitMessage, baseTag, saveEditorStateMessage } = options;
155
+ const wsGit = simpleGit({ baseDir: branchPath });
156
+ let savedEditorState = false;
157
+ if (saveEditorStateMessage) {
158
+ const editorChanges = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
159
+ if (editorChanges.length > 0) {
160
+ await stageAllExceptCanopyState(wsGit);
161
+ await wsGit.commit(saveEditorStateMessage);
162
+ savedEditorState = true;
163
+ }
164
+ }
144
165
  // Copy into a temp dir, then atomically swap it into place.
145
166
  const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
146
167
  try {
@@ -154,23 +175,20 @@ export async function pushContentToWorkspace(options) {
154
175
  // The content dir was replaced wholesale, so ContentStore ID indexes rooted
155
176
  // at this workspace must be marked stale — in this process and (via the
156
177
  // on-disk generation marker) in every other process sharing the filesystem.
157
- // Done in the finally AFTER the git add/commit below: the marker lives under
158
- // .canopy-meta/ inside the clone, and writing it first would stage it into
159
- // the sync commit via `add -A` (production workspaces git-exclude
160
- // .canopy-meta/, but this function shouldn't depend on that).
178
+ // Done in the finally AFTER the git add/commit below, and staging skips
179
+ // .canopy-meta/, where the marker lives.
161
180
  try {
162
- const wsGit = simpleGit({ baseDir: branchPath });
163
- await wsGit.add('-A');
164
- const postStatus = await wsGit.status();
165
- if (postStatus.files.length === 0) {
181
+ await stageAllExceptCanopyState(wsGit);
182
+ const staged = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
183
+ if (staged.length === 0) {
166
184
  if (baseTag)
167
185
  await wsGit.tag(['-f', baseTag]);
168
- return { fileCount: 0 };
186
+ return { fileCount: 0, savedEditorState };
169
187
  }
170
188
  await wsGit.commit(commitMessage ?? 'sync: update content from working tree');
171
189
  if (baseTag)
172
190
  await wsGit.tag(['-f', baseTag]);
173
- return { fileCount: postStatus.files.length };
191
+ return { fileCount: staged.length, savedEditorState };
174
192
  }
175
193
  finally {
176
194
  await invalidateBranchContentCaches(branchPath);
@@ -32,4 +32,12 @@ export declare const WORKER_STATUS_FILE = "worker-status.json";
32
32
  * `processTaskQueue`, `start()`); any that don't fall back on scheduleLoop's
33
33
  * per-cycle catch, which logs and continues.
34
34
  */
35
+ /**
36
+ * The `lastFatalError` in the current status file, or undefined when the file is
37
+ * missing, unreadable or has none. Tolerant for the same reason readers are.
38
+ * Its one caller carries the value into a full snapshot right after taking the
39
+ * worker lock; a write racing in between only keeps or drops one crash alert
40
+ * until the next write.
41
+ */
42
+ export declare function readLastFatalError(taskDir: string): Promise<WorkerStatusReport['lastFatalError']>;
35
43
  export declare function writeWorkerStatus(taskDir: string, report: WorkerStatusReport): Promise<void>;
@@ -19,6 +19,7 @@
19
19
  * docs/concurrency.md) means a reader can see a snapshot a cache window old
20
20
  * however carefully it was written.
21
21
  */
22
+ import fs from 'node:fs/promises';
22
23
  import path from 'node:path';
23
24
  import { atomicWriteFile } from '../utils/atomic-write.js';
24
25
  export const WORKER_STATUS_FILE = 'worker-status.json';
@@ -33,6 +34,22 @@ export const WORKER_STATUS_FILE = 'worker-status.json';
33
34
  * `processTaskQueue`, `start()`); any that don't fall back on scheduleLoop's
34
35
  * per-cycle catch, which logs and continues.
35
36
  */
37
+ /**
38
+ * The `lastFatalError` in the current status file, or undefined when the file is
39
+ * missing, unreadable or has none. Tolerant for the same reason readers are.
40
+ * Its one caller carries the value into a full snapshot right after taking the
41
+ * worker lock; a write racing in between only keeps or drops one crash alert
42
+ * until the next write.
43
+ */
44
+ export async function readLastFatalError(taskDir) {
45
+ try {
46
+ const content = await fs.readFile(path.join(taskDir, WORKER_STATUS_FILE), 'utf-8');
47
+ return JSON.parse(content).lastFatalError;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
36
53
  export async function writeWorkerStatus(taskDir, report) {
37
54
  const filePath = path.join(taskDir, WORKER_STATUS_FILE);
38
55
  const stamped = { ...report, updatedAt: new Date().toISOString() };
package/dist/types.d.ts CHANGED
@@ -102,6 +102,31 @@ export interface BranchContext extends BranchPaths {
102
102
  export interface BranchContextWithSchema extends BranchContext {
103
103
  flatSchema: import('./config/index.js').FlatSchemaItem[];
104
104
  }
105
+ /**
106
+ * Outcome of the worker's per-cycle fast-forward of the base branch's own clone
107
+ * (worker/git-sync.ts's `refreshBaseBranchWorkspace`); the rebase loop skips
108
+ * that clone.
109
+ */
110
+ export interface BaseRefreshReport {
111
+ outcome: 'refreshed' | 'up-to-date' | 'skipped-dirty' | 'skipped-locked' | 'skipped-not-provisioned' | 'failed';
112
+ /** Tracked files blocking the refresh, at most 10; set when `skipped-dirty`. */
113
+ dirtyFiles?: string[];
114
+ /** What went wrong, credential-redacted; set when `failed` or `skipped-dirty`. */
115
+ message?: string;
116
+ /**
117
+ * Files under `.canopy-meta/` the adopter's repo tracks, at most 10. canopycms
118
+ * rewrites that state per branch, so tracking it is an adopter misconfiguration
119
+ * the panel surfaces with its fix, whatever the outcome.
120
+ */
121
+ trackedCanopyMeta?: string[];
122
+ }
123
+ /** What a running CMS process reports about the build it was started from. */
124
+ export interface BuildIdentity {
125
+ /** canopycms package version this process runs. */
126
+ canopycmsVersion: string;
127
+ /** Adopter source revision the deployed image was built from (CANOPY_SOURCE_SHA); absent when unset. */
128
+ sourceRevision?: string;
129
+ }
105
130
  /**
106
131
  * Wire shape of the worker's self-reported status file (worker-status.json, under
107
132
  * the task queue dir), written by the CmsWorker daemon. Read-only here: GET
@@ -109,6 +134,7 @@ export interface BranchContextWithSchema extends BranchContext {
109
134
  */
110
135
  export interface WorkerStatusReport {
111
136
  version: 1;
137
+ /** canopycms version the worker runs. Optional: status files from older workers lack it. */
112
138
  workerVersion?: string;
113
139
  startedAt: string;
114
140
  updatedAt: string;
@@ -123,8 +149,9 @@ export interface WorkerStatusReport {
123
149
  rebased: string[];
124
150
  skippedDirty: string[];
125
151
  /**
126
- * [SYNC-C1] Branches skipped because a content write held the branch's
127
- * cross-host content-write lock (utils/content-write-lock.ts); the worker
152
+ * Branches skipped because another process held a lock the worker
153
+ * try-acquires: the [SYNC-C1] content-write lock (utils/content-write-lock.ts)
154
+ * or the provisioning lock (worker/provisioned-workspace.ts). The worker
128
155
  * yields and retries next cycle. Optional for the same reason as `tracked`
129
156
  * below, and readers must tolerate its absence.
130
157
  */
@@ -133,6 +160,8 @@ export interface WorkerStatusReport {
133
160
  branch: string;
134
161
  error: string;
135
162
  }[];
163
+ /** Optional for the same reason as `tracked` below. */
164
+ baseRefresh?: BaseRefreshReport;
136
165
  /**
137
166
  * Outcome of reconciling remote.git's `refs/heads/*` against GitHub's fetched
138
167
  * tips, non-destructively (worker/git-sync.ts's `reconcileTrackedBranches`).
@@ -5,7 +5,9 @@
5
5
  * deletes every comment in the file, because comments live in neither the object nor that round
6
6
  * trip. So these functions re-serialise onto the file's OWN parsed document: a node whose value
7
7
  * did not change is left untouched, and an untouched node keeps its attached comments and its
8
- * original quoting/block style. Only what actually changed is rewritten.
8
+ * original quoting/block style. Only what actually changed is rewritten, and
9
+ * `yaml-source-splice.ts` writes it into the file's own text, so untouched lines keep their bytes
10
+ * wherever it can vouch for the result.
9
11
  *
10
12
  * The file gets no authority over its own content. The reconciler makes the document's key set
11
13
  * match `data` exactly — a key the caller dropped disappears, a key the caller kept survives
@@ -19,7 +21,8 @@
19
21
  *
20
22
  * Falls back to a plain stringify — byte-identical to serialising without preservation — when
21
23
  * there is nothing to preserve (a new file) or nothing trustworthy to preserve (the bytes on disk
22
- * do not parse). A save must not fail because the previous content was malformed.
24
+ * do not parse, or the reconciled document does not read back as its own data). A save must not
25
+ * fail because the previous content was malformed.
23
26
  */
24
27
  export declare function serializeYaml(data: Record<string, unknown>, existingRaw?: string): string;
25
28
  /**