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
@@ -24,9 +24,10 @@ export type PermissionSet = Readonly<Record<string, PermissionLevel>>;
24
24
  * - `metadata: read` — implied by any repository permission; declared so
25
25
  * this object states the whole surface, not just the non-automatic part.
26
26
  * Nothing else: no `issues`, `administration`, `actions`, or organisation
27
- * permissions. No `workflows` either, though GitHub may refuse to push
28
- * rebased history touching `.github/workflows/` — see
29
- * .claude/future-tasks/worker-push-refused-when-base-changes-workflows.md.
27
+ * permissions. No `workflows` either: GitHub refuses only workflow content it
28
+ * does not already hold, so rebasing onto a base that changed a workflow is
29
+ * accepted. Content that is new to GitHub fails the push task with the file
30
+ * named (`throwIfWorkflowRefusal` in worker/task-runner.ts).
30
31
  */
31
32
  export declare const CANOPY_APP_PERMISSIONS: PermissionSet;
32
33
  /**
@@ -22,9 +22,10 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
22
22
  * - `metadata: read` — implied by any repository permission; declared so
23
23
  * this object states the whole surface, not just the non-automatic part.
24
24
  * Nothing else: no `issues`, `administration`, `actions`, or organisation
25
- * permissions. No `workflows` either, though GitHub may refuse to push
26
- * rebased history touching `.github/workflows/` — see
27
- * .claude/future-tasks/worker-push-refused-when-base-changes-workflows.md.
25
+ * permissions. No `workflows` either: GitHub refuses only workflow content it
26
+ * does not already hold, so rebasing onto a base that changed a workflow is
27
+ * accepted. Content that is new to GitHub fails the push task with the file
28
+ * named (`throwIfWorkflowRefusal` in worker/task-runner.ts).
28
29
  */
29
30
  export const CANOPY_APP_PERMISSIONS = {
30
31
  contents: 'write',
package/dist/cli/init.js CHANGED
@@ -115,7 +115,8 @@ var init_field = __esm({
115
115
  });
116
116
  objectFieldSchema = fieldBaseSchema.extend({
117
117
  type: z.literal("object"),
118
- fields: z.array(z.lazy(() => fieldHolder[0])).min(1)
118
+ fields: z.array(z.lazy(() => fieldHolder[0])).min(1),
119
+ itemTitleField: z.string().min(1).optional()
119
120
  });
120
121
  inlineGroupFieldSchema = z.object({
121
122
  type: z.literal("group"),
@@ -231,7 +232,7 @@ var init_sanitize_href = __esm({
231
232
 
232
233
  // dist/config/schemas/url.js
233
234
  import { z as z3 } from "zod";
234
- var uploadTargetUrlSchema, assetMountUrlSchema;
235
+ var uploadTargetUrlSchema, assetMountUrlSchema, previewPrefixSchema;
235
236
  var init_url = __esm({
236
237
  "dist/config/schemas/url.js"() {
237
238
  "use strict";
@@ -242,6 +243,9 @@ var init_url = __esm({
242
243
  assetMountUrlSchema = z3.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
243
244
  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'
244
245
  });
246
+ previewPrefixSchema = z3.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
247
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
248
+ });
245
249
  }
246
250
  });
247
251
 
@@ -299,6 +303,7 @@ var init_config = __esm({
299
303
  "use strict";
300
304
  init_collection();
301
305
  init_media();
306
+ init_url();
302
307
  defaultBranchAccessSchema = z5.enum(["allow", "deny"]).default("deny");
303
308
  defaultPathAccessLevelSchema = z5.enum(["allow", "deny"]);
304
309
  defaultPathAccessLevelsSchema = z5.object({
@@ -330,6 +335,7 @@ var init_config = __esm({
330
335
  subtitle: z5.string().optional(),
331
336
  theme: z5.unknown().optional(),
332
337
  previewBase: z5.record(z5.string()).optional(),
338
+ previewPrefix: previewPrefixSchema.optional(),
333
339
  // UI handler functions (runtime only, don't serialize)
334
340
  onAccountClick: z5.function().returns(z5.void()).optional(),
335
341
  onLogoutClick: z5.function().returns(z5.void()).optional(),
@@ -357,6 +363,9 @@ var init_config = __esm({
357
363
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
358
364
  gitBotAuthorName: gitBotAuthorNameSchema,
359
365
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
366
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
367
+ gitEditedByTrailers: z5.boolean().optional(),
368
+ gitCoAuthoredByTrailers: z5.boolean().optional(),
360
369
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
361
370
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
362
371
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -368,7 +377,6 @@ var init_config = __esm({
368
377
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
369
378
  allowNetworkRemoteInProd: z5.boolean().optional(),
370
379
  settingsBranch: z5.string().optional(),
371
- autoCreateSettingsPR: z5.boolean().optional(),
372
380
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
373
381
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
374
382
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -889,6 +897,10 @@ var init_debug = __esm({
889
897
  throw new Error(errorMsg);
890
898
  }
891
899
  }
900
+ /**
901
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
902
+ * anything that can run concurrently uses {@link timed}.
903
+ */
892
904
  time(label) {
893
905
  this.timers.set(label, Date.now());
894
906
  }
@@ -903,12 +915,16 @@ var init_debug = __esm({
903
915
  this.debug(category, `${label} completed`, { durationMs: duration });
904
916
  return duration;
905
917
  }
918
+ /**
919
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
920
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
921
+ */
906
922
  async timed(category, label, fn) {
907
- this.time(label);
923
+ const start = Date.now();
908
924
  try {
909
925
  return await fn();
910
926
  } finally {
911
- this.timeEnd(category, label);
927
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
912
928
  }
913
929
  }
914
930
  };
@@ -1525,9 +1541,6 @@ var ProdStrategy = class extends ProdClientSafeStrategy {
1525
1541
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
1526
1542
  }
1527
1543
  }
1528
- shouldCreateSettingsPR(config) {
1529
- return config.autoCreateSettingsPR ?? true;
1530
- }
1531
1544
  };
1532
1545
  var DevStrategy = class extends DevClientSafeStrategy {
1533
1546
  getWorkspaceRoot(sourceRoot) {
@@ -1574,9 +1587,6 @@ var DevStrategy = class extends DevClientSafeStrategy {
1574
1587
  }
1575
1588
  validateConfig(_config) {
1576
1589
  }
1577
- shouldCreateSettingsPR(_config) {
1578
- return false;
1579
- }
1580
1590
  };
1581
1591
  var strategyCache = /* @__PURE__ */ new Map();
1582
1592
  function operatingStrategy(mode) {
@@ -21,8 +21,9 @@ import { parseSlug } from '../paths/index.js';
21
21
  import { filePathExists } from '../utils/fs.js';
22
22
  import { loadCollectionMetaFiles } from '../schema/index.js';
23
23
  import { getErrorMessage } from '../utils/error.js';
24
- import { withLock } from '../utils/async-mutex.js';
25
- import { withOccFileLock, OccWriteConflictError } from '../utils/occ-json-write.js';
24
+ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
25
+ import { OccWriteConflictError } from '../utils/occ-json-write.js';
26
+ import { withBranchSchemaLock } from '../schema/schema-store.js';
26
27
  import { BranchMetadataFileManager } from '../branch-metadata.js';
27
28
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
28
29
  const MIGRATE_FORMATS = ['md', 'mdx', 'json', 'yaml'];
@@ -245,18 +246,21 @@ export async function migrate(options) {
245
246
  };
246
247
  if (isBranchClone) {
247
248
  // projectDir IS the branch root in this case (see the invariant note
248
- // above) — the same surrogate lock path SchemaOps uses for its own
249
- // schema mutations (schema-store.ts's withSchemaLock), so a migrate run
250
- // racing a live schema-editing request on this same branch clone
251
- // serializes against it instead of silently interleaving writes.
252
- const lockPath = path.join(projectDir, '.canopy-meta', 'schema');
249
+ // above). The renames rewrite the working tree the worker rebases, so this
250
+ // takes the same locks SchemaOps does for its own schema mutations: the
251
+ // content-write lock, then the schema surrogate.
253
252
  try {
254
- await withLock(lockPath, () => withOccFileLock(lockPath, applyOps));
253
+ await withBranchSchemaLock(projectDir, applyOps);
255
254
  }
256
255
  catch (err) {
257
256
  if (err instanceof OccWriteConflictError) {
258
257
  throw new MigrateError('Another process is modifying the schema, try again.');
259
258
  }
259
+ if (err instanceof ContentWriteLockBusyError) {
260
+ throw new MigrateError(err.outcome === 'unknown'
261
+ ? 'The branch was being synced while migrate ran, so it may be partly applied. Check its state before running migrate again.'
262
+ : err.message);
263
+ }
260
264
  throw err;
261
265
  }
262
266
  }
package/dist/cli/sync.js CHANGED
@@ -17,8 +17,10 @@ import path from 'node:path';
17
17
  import { simpleGit } from 'simple-git';
18
18
  import * as p from '@clack/prompts';
19
19
  import { filePathExists } from '../utils/fs.js';
20
+ import { isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
20
21
  import { assertWithinDir, copyDir, listFilesRecursive, pushContentToWorkspace, safeReplaceDir, SYNC_BASE_TAG, } from '../sync-core.js';
21
22
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
23
+ import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
22
24
  import { ensureGitExcludePattern } from '../git-manager.js';
23
25
  import { operatingStrategy } from '../operating-mode/index.js';
24
26
  /**
@@ -32,6 +34,42 @@ class SyncError extends Error {
32
34
  this.name = 'SyncError';
33
35
  }
34
36
  }
37
+ /**
38
+ * [SYNC-C1] Every sync step that rewrites a branch workspace (content replace, checkout, merge,
39
+ * merge abort) runs under the workspace's content-write lock, which the dev server's saves and
40
+ * the worker's rebase also take. Contention becomes a `SyncError`, so the CLI exits non-zero.
41
+ */
42
+ function lockBusyAsSyncError(err, branchName) {
43
+ if (!(err instanceof ContentWriteLockBusyError))
44
+ return err;
45
+ return new SyncError(err.outcome === 'not-run'
46
+ ? `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.`
47
+ : `Branch workspace "${branchName}" was being synced during this run, so it may be partly updated. Check its state before running sync again.`);
48
+ }
49
+ async function withWorkspaceLock(branchPath, branchName, fn) {
50
+ try {
51
+ return await withContentWriteLock(branchPath, fn);
52
+ }
53
+ catch (err) {
54
+ throw lockBusyAsSyncError(err, branchName);
55
+ }
56
+ }
57
+ /**
58
+ * `git merge --abort` in a workspace left mid-merge, re-checking under the lock that it still is.
59
+ * Returns whether a merge was aborted.
60
+ */
61
+ async function abortWorkspaceMerge(branchPath, branchName) {
62
+ return withWorkspaceLock(branchPath, branchName, async () => {
63
+ const wsGit = simpleGit({ baseDir: branchPath });
64
+ if ((await wsGit.status()).conflicted.length === 0)
65
+ return false;
66
+ await wsGit.merge(['--abort']);
67
+ // The abort rewrote the clone's working tree — tell ContentStore ID
68
+ // indexes (the dev server is a separate process; on-disk marker).
69
+ await invalidateBranchContentCaches(branchPath);
70
+ return true;
71
+ });
72
+ }
35
73
  /** Detect the current git branch name from the working tree. */
36
74
  async function detectCurrentBranch(projectDir) {
37
75
  const git = simpleGit({ baseDir: projectDir });
@@ -140,22 +178,21 @@ async function syncPush(options) {
140
178
  initialValue: false,
141
179
  });
142
180
  if (!p.isCancel(shouldAbort) && shouldAbort) {
143
- await wsGit.merge(['--abort']);
144
- // The abort rewrote the clone's working tree — tell ContentStore ID
145
- // indexes (the dev server is a separate process; on-disk marker).
146
- await invalidateBranchContentCaches(branchPath);
147
- p.log.success('Merge aborted. Workspace restored to pre-merge state.');
181
+ if (await abortWorkspaceMerge(branchPath, branchName)) {
182
+ p.log.success('Merge aborted. Workspace restored to pre-merge state.');
183
+ }
148
184
  }
149
185
  }
150
186
  return { fileCount: 0 };
151
187
  }
152
- if (status.files.length > 0 && !options.force) {
153
- p.log.warn(`Branch workspace has ${status.files.length} uncommitted change(s) that will be committed to history then overwritten:`);
154
- for (const file of status.files.slice(0, 10)) {
188
+ const editorChanges = status.files.filter((f) => !isCanopyInternalPath(f.path));
189
+ if (editorChanges.length > 0 && !options.force) {
190
+ p.log.warn(`Branch workspace has ${editorChanges.length} uncommitted change(s) that will be committed to history then overwritten:`);
191
+ for (const file of editorChanges.slice(0, 10)) {
155
192
  p.log.warn(` ${file.path}`);
156
193
  }
157
- if (status.files.length > 10) {
158
- p.log.warn(` ... and ${status.files.length - 10} more`);
194
+ if (editorChanges.length > 10) {
195
+ p.log.warn(` ... and ${editorChanges.length - 10} more`);
159
196
  }
160
197
  const confirm = await p.confirm({
161
198
  message: 'Continue? Editor changes will be preserved in git history.',
@@ -166,20 +203,26 @@ async function syncPush(options) {
166
203
  return { fileCount: 0 };
167
204
  }
168
205
  }
169
- // Auto-commit uncommitted workspace changes to preserve in history
170
- if (status.files.length > 0) {
171
- await wsGit.add('-A');
172
- await wsGit.commit('sync: save editor state before push');
173
- p.log.info('Committed editor changes to history before push');
174
- }
175
206
  p.log.step(`Pushing content to branch workspace: ${branchName}`);
176
- // Copy working-tree content → workspace content dir, commit, and tag the sync base.
177
- const { fileCount } = await pushContentToWorkspace({
178
- srcContentDir,
179
- branchPath,
180
- contentRoot,
181
- baseTag: SYNC_BASE_TAG,
182
- });
207
+ // Commit any editor changes (re-read under the lock, so a save landing after
208
+ // the listing above is preserved too), copy working-tree content → workspace
209
+ // content dir, commit, and tag the sync base.
210
+ let fileCount;
211
+ try {
212
+ const result = await pushContentToWorkspace({
213
+ srcContentDir,
214
+ branchPath,
215
+ contentRoot,
216
+ baseTag: SYNC_BASE_TAG,
217
+ saveEditorStateMessage: 'sync: save editor state before push',
218
+ });
219
+ fileCount = result.fileCount;
220
+ if (result.savedEditorState)
221
+ p.log.info('Committed editor changes to history before push');
222
+ }
223
+ catch (err) {
224
+ throw lockBusyAsSyncError(err, branchName);
225
+ }
183
226
  if (fileCount === 0) {
184
227
  p.log.info('Content is already up to date — nothing to push');
185
228
  }
@@ -321,16 +364,23 @@ async function syncBoth(options) {
321
364
  initialValue: false,
322
365
  });
323
366
  if (!p.isCancel(shouldAbort) && shouldAbort) {
324
- await wsGit.merge(['--abort']);
325
- await invalidateBranchContentCaches(branchPath);
326
- p.log.success('Merge aborted. Workspace restored to pre-merge state.');
367
+ if (await abortWorkspaceMerge(branchPath, branchName)) {
368
+ p.log.success('Merge aborted. Workspace restored to pre-merge state.');
369
+ }
327
370
  }
328
371
  }
329
372
  return { pushed: 0, pulled: 0 };
330
373
  }
374
+ return withWorkspaceLock(branchPath, branchName, () => mergeIntoWorkspace(options, { branchName, branchPath, srcContentDir, contentRoot }));
375
+ }
376
+ /** The locked body of {@link syncBoth}: everything that rewrites the workspace. */
377
+ async function mergeIntoWorkspace(options, ws) {
378
+ const { branchName, branchPath, srcContentDir, contentRoot } = ws;
379
+ const wsGit = simpleGit({ baseDir: branchPath });
380
+ const status = await wsGit.status();
331
381
  // Auto-commit uncommitted workspace changes (preserves editor work for the merge)
332
- if (status.files.length > 0) {
333
- await wsGit.add('-A');
382
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
383
+ await stageAllExceptCanopyState(wsGit);
334
384
  await wsGit.commit('sync: save editor state before merge');
335
385
  p.log.info('Committed editor changes before merge');
336
386
  }
@@ -365,9 +415,9 @@ async function syncBoth(options) {
365
415
  await wsGit.raw(['branch', '-D', incomingBranch]).catch(() => { });
366
416
  throw err;
367
417
  }
368
- await wsGit.add('-A');
369
- const incomingStatus = await wsGit.status();
370
- if (incomingStatus.files.length === 0) {
418
+ await stageAllExceptCanopyState(wsGit);
419
+ const incomingFiles = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
420
+ if (incomingFiles.length === 0) {
371
421
  await wsGit.checkout(currentBranch);
372
422
  await wsGit.raw(['branch', '-D', incomingBranch]);
373
423
  p.log.info('No working-tree changes to merge — pulling editor changes only');
@@ -410,7 +460,7 @@ async function syncBoth(options) {
410
460
  await wsGit.tag(['-f', SYNC_BASE_TAG]);
411
461
  p.log.success('Merged working-tree changes with editor changes');
412
462
  const pullResult = await syncPull({ ...options, branch: branchName, force: true });
413
- return { pushed: incomingStatus.files.length, pulled: pullResult.fileCount };
463
+ return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
414
464
  }
415
465
  finally {
416
466
  await invalidateBranchContentCaches(branchPath);
@@ -428,14 +478,10 @@ async function syncAbort(options) {
428
478
  return;
429
479
  const branchPath = path.join(branchesDir, branchName);
430
480
  assertWithinDir(branchPath, branchesDir, '--branch');
431
- const wsGit = simpleGit({ baseDir: branchPath });
432
- const status = await wsGit.status();
433
- if (status.conflicted.length === 0) {
481
+ if (!(await abortWorkspaceMerge(branchPath, branchName))) {
434
482
  p.log.info(`Branch workspace "${branchName}" is not in a merge state — nothing to abort.`);
435
483
  return;
436
484
  }
437
- await wsGit.merge(['--abort']);
438
- await invalidateBranchContentCaches(branchPath);
439
485
  p.log.success(`Merge aborted in branch workspace "${branchName}". Workspace restored to pre-merge state.`);
440
486
  }
441
487
  /**
@@ -73,5 +73,13 @@ COPY --from=builder /app/public ./public
73
73
  # Lambda's filesystem is read-only outside /tmp. Next may write to .next/cache
74
74
  # at runtime; symlink it to /tmp so those writes succeed instead of failing (EROFS).
75
75
  RUN rm -rf .next/cache && ln -s /tmp .next/cache
76
+ # The CMS reports this in System health. A build arg, not a Lambda environment
77
+ # variable: a Lambda variable names the infrastructure's commit and would hide
78
+ # exactly the image/infrastructure skew this exists to reveal. Declared after
79
+ # every build layer, so with a warm layer cache a new revision re-runs only the
80
+ # metadata steps from here down.
81
+ ARG CANOPY_SOURCE_SHA=
82
+ ENV CANOPY_SOURCE_SHA=$CANOPY_SOURCE_SHA
83
+ LABEL org.opencontainers.image.revision=$CANOPY_SOURCE_SHA
76
84
  ENV PORT=8080 AWS_LWA_PORT=8080 HOSTNAME=0.0.0.0 NODE_ENV=production
77
85
  CMD ["node", "server.js"]
@@ -126,4 +126,8 @@ new CmsStack(app, '{{STACK_NAME}}', {
126
126
  // point your own existing distribution at it.
127
127
  domainName: process.env.CMS_DOMAIN_NAME || undefined,
128
128
  hostedZoneDomain: process.env.CMS_HOSTED_ZONE_DOMAIN || undefined,
129
+
130
+ // Optional, and NOT `required()`: the commit the CMS image is built from,
131
+ // shown in System health. Unset, System health reports it as "not set".
132
+ sourceRevision: process.env.CANOPY_SOURCE_SHA || undefined,
129
133
  })
@@ -77,6 +77,8 @@ export interface CmsStackProps extends StackProps {
77
77
  domainName?: string
78
78
  /** Optional Route53 hosted zone domain, e.g. 'example.org'. */
79
79
  hostedZoneDomain?: string
80
+ /** Optional. Commit the CMS image is built from; shown in System health. */
81
+ sourceRevision?: string
80
82
  }
81
83
 
82
84
  export class CmsStack extends Stack {
@@ -128,6 +130,10 @@ export class CmsStack extends Stack {
128
130
  // auth. The image's own `next build` stays in dev mode either way --
129
131
  // see Dockerfile.cms.
130
132
  NEXT_PUBLIC_CANOPY_MODE: 'prod',
133
+ // CDK hashes build args into the asset, so every new commit rebuilds
134
+ // the image, even one changing only the dockerignored infrastructure/
135
+ // -- that is what keeps the revision the CMS reports honest.
136
+ ...(props.sourceRevision ? { CANOPY_SOURCE_SHA: props.sourceRevision } : {}),
131
137
  },
132
138
  }),
133
139
  // The one place the CMS image's CPU architecture is decided. CDK derives
@@ -241,4 +241,6 @@ jobs:
241
241
  CANOPYCMS_DEPLOYMENT_NAME: ${{ vars.CANOPYCMS_DEPLOYMENT_NAME }}
242
242
  CMS_DOMAIN_NAME: ${{ vars.CMS_DOMAIN_NAME }}
243
243
  CMS_HOSTED_ZONE_DOMAIN: ${{ vars.CMS_HOSTED_ZONE_DOMAIN }}
244
+ # The commit the CMS image is built from, reported in System health.
245
+ CANOPY_SOURCE_SHA: ${{ github.sha }}
244
246
  run: npx cdk deploy {{STACK_NAME}} --require-approval never
@@ -69,12 +69,13 @@ export declare const CanopyConfigSchema: z.ZodObject<{
69
69
  defaultRemoteUrl: z.ZodOptional<z.ZodString>;
70
70
  gitBotAuthorName: z.ZodString;
71
71
  gitBotAuthorEmail: z.ZodString;
72
+ gitEditedByTrailers: z.ZodOptional<z.ZodBoolean>;
73
+ gitCoAuthoredByTrailers: z.ZodOptional<z.ZodBoolean>;
72
74
  githubTokenEnvVar: z.ZodOptional<z.ZodDefault<z.ZodString>>;
73
75
  mode: z.ZodEnum<["prod", "dev"]>;
74
76
  deployedAs: z.ZodDefault<z.ZodEnum<["static", "server"]>>;
75
77
  allowNetworkRemoteInProd: z.ZodOptional<z.ZodBoolean>;
76
78
  settingsBranch: z.ZodOptional<z.ZodString>;
77
- autoCreateSettingsPR: z.ZodOptional<z.ZodBoolean>;
78
79
  deploymentName: z.ZodOptional<z.ZodDefault<z.ZodString>>;
79
80
  contentRoot: z.ZodDefault<z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>, string, string>>>;
80
81
  sourceRoot: z.ZodOptional<z.ZodOptional<z.ZodString>>;
@@ -84,6 +85,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
84
85
  subtitle: z.ZodOptional<z.ZodString>;
85
86
  theme: z.ZodOptional<z.ZodUnknown>;
86
87
  previewBase: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
88
+ previewPrefix: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
87
89
  onAccountClick: z.ZodOptional<z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodVoid>>;
88
90
  onLogoutClick: z.ZodOptional<z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodVoid>>;
89
91
  AccountComponent: z.ZodOptional<z.ZodType<import("react").ComponentType<{}>, z.ZodTypeDef, import("react").ComponentType<{}>>>;
@@ -93,6 +95,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
93
95
  subtitle?: string | undefined;
94
96
  theme?: unknown;
95
97
  previewBase?: Record<string, string> | undefined;
98
+ previewPrefix?: string | undefined;
96
99
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
97
100
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
98
101
  AccountComponent?: import("react").ComponentType<{}> | undefined;
@@ -102,6 +105,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
102
105
  subtitle?: string | undefined;
103
106
  theme?: unknown;
104
107
  previewBase?: Record<string, string> | undefined;
108
+ previewPrefix?: string | undefined;
105
109
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
106
110
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
107
111
  AccountComponent?: import("react").ComponentType<{}> | undefined;
@@ -152,9 +156,10 @@ export declare const CanopyConfigSchema: z.ZodObject<{
152
156
  defaultActiveBranch?: string | undefined;
153
157
  defaultRemoteName?: string | undefined;
154
158
  defaultRemoteUrl?: string | undefined;
159
+ gitEditedByTrailers?: boolean | undefined;
160
+ gitCoAuthoredByTrailers?: boolean | undefined;
155
161
  githubTokenEnvVar?: string | undefined;
156
162
  allowNetworkRemoteInProd?: boolean | undefined;
157
- autoCreateSettingsPR?: boolean | undefined;
158
163
  deploymentName?: string | undefined;
159
164
  sourceRoot?: string | undefined;
160
165
  basePath?: string | undefined;
@@ -163,6 +168,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
163
168
  subtitle?: string | undefined;
164
169
  theme?: unknown;
165
170
  previewBase?: Record<string, string> | undefined;
171
+ previewPrefix?: string | undefined;
166
172
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
167
173
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
168
174
  AccountComponent?: import("react").ComponentType<{}> | undefined;
@@ -204,10 +210,11 @@ export declare const CanopyConfigSchema: z.ZodObject<{
204
210
  defaultActiveBranch?: string | undefined;
205
211
  defaultRemoteName?: string | undefined;
206
212
  defaultRemoteUrl?: string | undefined;
213
+ gitEditedByTrailers?: boolean | undefined;
214
+ gitCoAuthoredByTrailers?: boolean | undefined;
207
215
  githubTokenEnvVar?: string | undefined;
208
216
  deployedAs?: "static" | "server" | undefined;
209
217
  allowNetworkRemoteInProd?: boolean | undefined;
210
- autoCreateSettingsPR?: boolean | undefined;
211
218
  deploymentName?: string | undefined;
212
219
  contentRoot?: string | undefined;
213
220
  sourceRoot?: string | undefined;
@@ -217,6 +224,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
217
224
  subtitle?: string | undefined;
218
225
  theme?: unknown;
219
226
  previewBase?: Record<string, string> | undefined;
227
+ previewPrefix?: string | undefined;
220
228
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
221
229
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
222
230
  AccountComponent?: import("react").ComponentType<{}> | undefined;
@@ -4,6 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  import { relativePathSchema } from './collection.js';
6
6
  import { mediaSchema } from './media.js';
7
+ import { previewPrefixSchema } from './url.js';
7
8
  const defaultBranchAccessSchema = z.enum(['allow', 'deny']).default('deny');
8
9
  const defaultPathAccessLevelSchema = z.enum(['allow', 'deny']);
9
10
  // Per-level object form: an omitted level stays undefined after parse (no per-field
@@ -51,6 +52,7 @@ const editorConfigSchema = z.object({
51
52
  subtitle: z.string().optional(),
52
53
  theme: z.unknown().optional(),
53
54
  previewBase: z.record(z.string()).optional(),
55
+ previewPrefix: previewPrefixSchema.optional(),
54
56
  // UI handler functions (runtime only, don't serialize)
55
57
  onAccountClick: z.function().returns(z.void()).optional(),
56
58
  onLogoutClick: z.function().returns(z.void()).optional(),
@@ -79,6 +81,9 @@ export const CanopyConfigSchema = z
79
81
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
80
82
  gitBotAuthorName: gitBotAuthorNameSchema,
81
83
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
84
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
85
+ gitEditedByTrailers: z.boolean().optional(),
86
+ gitCoAuthoredByTrailers: z.boolean().optional(),
82
87
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
83
88
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
84
89
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -89,7 +94,6 @@ export const CanopyConfigSchema = z
89
94
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
90
95
  allowNetworkRemoteInProd: z.boolean().optional(),
91
96
  settingsBranch: z.string().optional(),
92
- autoCreateSettingsPR: z.boolean().optional(),
93
97
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
94
98
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
95
99
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -81,6 +81,7 @@ const blockFieldSchema = fieldBaseSchema.extend({
81
81
  const objectFieldSchema = fieldBaseSchema.extend({
82
82
  type: z.literal('object'),
83
83
  fields: z.array(z.lazy(() => fieldHolder[0])).min(1),
84
+ itemTitleField: z.string().min(1).optional(),
84
85
  });
85
86
  // Inline group field: visual grouping only, no data nesting
86
87
  const inlineGroupFieldSchema = z.object({
@@ -35,3 +35,5 @@ export declare const uploadTargetUrlSchema: z.ZodEffects<z.ZodString, string, st
35
35
  * `publicBaseUrl: 'mailto:a@b.c'` and produce `/mailto:a@b.c/assets/…` at render time.
36
36
  */
37
37
  export declare const assetMountUrlSchema: z.ZodEffects<z.ZodString, string, string>;
38
+ /** `editor.previewPrefix`, by `uploadTargetUrlSchema`'s rule: `//host` leaves the scheme open. */
39
+ export declare const previewPrefixSchema: z.ZodEffects<z.ZodString, string, string>;
@@ -46,3 +46,10 @@ export const assetMountUrlSchema = z
46
46
  .refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
47
47
  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',
48
48
  });
49
+ /** `editor.previewPrefix`, by `uploadTargetUrlSchema`'s rule: `//host` leaves the scheme open. */
50
+ export const previewPrefixSchema = z
51
+ .string()
52
+ .trim()
53
+ .refine((value) => isHttpUrlOrSameOriginPath(value), {
54
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment',
55
+ });
@@ -132,6 +132,11 @@ export interface BlockFieldConfig extends BaseFieldConfig {
132
132
  export interface ObjectFieldConfig extends BaseFieldConfig {
133
133
  type: 'object';
134
134
  fields: FieldConfig[];
135
+ /**
136
+ * On a `list: true` object, a direct `string` or `number` child whose value titles each
137
+ * item's card; a blank or non-finite value falls back to "<label> #N".
138
+ */
139
+ itemTitleField?: string;
135
140
  }
136
141
  /**
137
142
  * Inline group field config: visually groups fields in the editor without creating
@@ -257,10 +262,25 @@ export interface CanopyEditorConfig {
257
262
  *
258
263
  * Values are **site-relative** and must NOT include the deployment `basePath` — that is applied
259
264
  * on top of whatever this yields (see `CanopyConfig.basePath`), so including it here would
260
- * prefix it twice. An absolute value (`https://…`) is passed through untouched instead, which
261
- * is the escape hatch for previewing against a different origin entirely.
265
+ * prefix it twice. An absolute value (`https://…`) gets only the slug and `?branch=`, with no
266
+ * `basePath`, `previewPrefix` or trailing-slash change: the escape hatch for previewing against a
267
+ * different origin entirely.
268
+ *
269
+ * A site-relative value is a route on the host site, so `previewPrefix` is applied to it too.
262
270
  */
263
271
  previewBase?: Record<string, string>;
272
+ /**
273
+ * Where the host mounts the pages the preview pane loads, put in front of every entry's preview
274
+ * URL (`'/preview'` makes `/blog/hello` load `/preview/blog/hello`). Set it when the site's
275
+ * public pages are a static export, which cannot render a requested branch, so the preview
276
+ * must load a route the CMS server renders.
277
+ *
278
+ * A path starting with `/`, without the deployment `basePath` (applied on top, as for
279
+ * `previewBase`), or an absolute `http(s)://` URL, which skips the `basePath`. Pages on another
280
+ * origin accept drafts only with `useCanopyPreview({ editorOrigin })`. An absolute `previewBase`
281
+ * value skips this prefix.
282
+ */
283
+ previewPrefix?: string;
264
284
  onAccountClick?: () => void;
265
285
  onLogoutClick?: () => void;
266
286
  AccountComponent?: React.ComponentType;
@@ -346,6 +366,8 @@ export interface CanopyConfig {
346
366
  defaultRemoteUrl?: DefaultRemoteUrl;
347
367
  gitBotAuthorName: GitBotAuthorName;
348
368
  gitBotAuthorEmail: GitBotAuthorEmail;
369
+ gitEditedByTrailers?: boolean;
370
+ gitCoAuthoredByTrailers?: boolean;
349
371
  githubTokenEnvVar?: GithubTokenEnvVar;
350
372
  mode: CanopyOperatingMode;
351
373
  /** How this build is deployed — see {@link CanopyConfigInput.deployedAs}. */
@@ -353,7 +375,6 @@ export interface CanopyConfig {
353
375
  /** Escape hatch for a prod host with real internet access — see {@link CanopyConfigInput.allowNetworkRemoteInProd}. */
354
376
  allowNetworkRemoteInProd?: boolean;
355
377
  settingsBranch?: string;
356
- autoCreateSettingsPR?: boolean;
357
378
  deploymentName?: string;
358
379
  contentRoot: ContentRoot;
359
380
  sourceRoot?: SourceRoot;
@@ -382,6 +403,10 @@ export interface CanopyConfigInput {
382
403
  defaultRemoteUrl?: string;
383
404
  gitBotAuthorName: string;
384
405
  gitBotAuthorEmail: string;
406
+ /** Add `Edited-by: Name (user id)` for the submitter to submit commits. Default true. */
407
+ gitEditedByTrailers?: boolean;
408
+ /** Also add `Co-authored-by: Name <email>`. Default false: emails in public history are public. */
409
+ gitCoAuthoredByTrailers?: boolean;
385
410
  githubTokenEnvVar?: string;
386
411
  /**
387
412
  * Operating mode: 'prod' or 'dev'. Required — no default (SEC-C1). A prod deploy that
@@ -402,7 +427,6 @@ export interface CanopyConfigInput {
402
427
  */
403
428
  allowNetworkRemoteInProd?: boolean;
404
429
  settingsBranch?: string;
405
- autoCreateSettingsPR?: boolean;
406
430
  deploymentName?: string;
407
431
  contentRoot?: string;
408
432
  sourceRoot?: string;