canopycms 0.0.67 → 0.0.68-int.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
package/README.md CHANGED
@@ -104,6 +104,7 @@ export default defineCanopyConfig({
104
104
  colors: { brand: '#4f46e5' },
105
105
  },
106
106
  // previewBase: { 'content/posts': '/blog' }, // optional overrides
107
+ // previewPrefix: '/preview', // optional, in front of every preview URL
107
108
  },
108
109
  // For prod mode, defaultRemoteUrl is required.
109
110
  // For dev, it's optional - if omitted, uses auto-initialized local remote at .canopy-dev/remote.git
@@ -323,7 +324,7 @@ name) works, but leaves the entry's real `urlPath` as
323
324
  URL-derived surface then has to be told about one at a time. The example app in this repo used to
324
325
  do that and no longer does.
325
326
 
326
- Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to your configured base branch. Both enforce the same branch/path access rules as the API handlers.
327
+ Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to the active branch: `defaultActiveBranch`, or when that is unset, git HEAD in `dev` and your base branch in `prod`. Any other branch must already exist and be readable by the current user, or it reads as not found. Both enforce the same branch/path access rules as the API handlers.
327
328
 
328
329
  **Index entries and URL resolution**
329
330
 
@@ -401,7 +402,7 @@ _TODO_ show real examples of what to do
401
402
 
402
403
  ### Preview branch awareness
403
404
 
404
- - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param automatically to `previewBaseByCollection`; your page loaders should read `searchParams.branch` and pass it into `createContentReader`.
405
+ - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param automatically to `previewBaseByCollection`; your page loaders should read `searchParams.branch` and pass it as the `branch` option to `getCanopy()`'s `read`/`readByUrlPath`.
405
406
  - For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
406
407
  - Likewise, include `branch` in your editor route (e.g., `/edit?branch=feature-foo`) and have your editor page pass it to `<Editor>` so reloads/links preserve the selected branch. The `Editor` will also reflect branch switches back into the query string.
407
408
 
@@ -13,6 +13,7 @@
13
13
  import { ContentStore } from '../content-store.js';
14
14
  import { BranchSchemaCache } from '../branch-schema-cache.js';
15
15
  import { getErrorMessage } from '../utils/error.js';
16
+ import { workerNotReadyResponse } from '../http/worker-not-ready.js';
16
17
  import { generateAIContent } from './generate.js';
17
18
  import { resolveBranchRoot } from './resolve-branch.js';
18
19
  /**
@@ -76,6 +77,13 @@ export function createAIContentHandler(options) {
76
77
  catch (error) {
77
78
  // Log the real error server-side; don't leak internals to unauthenticated callers
78
79
  console.error('AI content handler error:', getErrorMessage(error));
80
+ const notReady = workerNotReadyResponse(error);
81
+ if (notReady) {
82
+ return new Response(JSON.stringify({ error: notReady.body.error }), {
83
+ status: notReady.status,
84
+ headers: { 'Content-Type': 'application/json', ...notReady.headers },
85
+ });
86
+ }
79
87
  return new Response(JSON.stringify({ error: 'Internal server error' }), {
80
88
  status: 500,
81
89
  headers: { 'Content-Type': 'application/json' },
@@ -22,7 +22,7 @@ import { ContentIdIndex } from '../content-id-index.js';
22
22
  import { invalidateContentIndexesDurable } from '../content-index-generation.js';
23
23
  import { getDefaultBranchBase, sanitizeBranchName } from '../paths/index.js';
24
24
  import { withOccFileLock } from '../utils/occ-json-write.js';
25
- import { tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
25
+ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
26
26
  import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
27
27
  import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
28
28
  import { defineEndpoint } from './route-builder.js';
@@ -147,12 +147,17 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
147
147
  // True orphan (no branch.json, no load error) vs. corrupt-metadata
148
148
  // (loadOnly threw). Only true orphans are subject to the youth rail below.
149
149
  const isTrueOrphan = !hadLoadError;
150
- // [H1] freshness rail: a fresh init lock means provisioning may genuinely
151
- // be in progress; a stale one is just crash debris and does not block.
152
- const lockPath = path.join(baseRoot, `.${params.dirName}.init.lock`);
150
+ // [H1] freshness rail: a fresh init lock means provisioning, or the worker's
151
+ // sync of this branch, may genuinely be in progress; a stale one is just
152
+ // crash debris and does not block.
153
+ const lockPath = path.join(baseRoot, branchProvisioningLockName(params.dirName));
153
154
  const lockStat = await fs.stat(lockPath).catch(() => null);
154
155
  if (lockStat && Date.now() - lockStat.mtimeMs < PROVISIONING_LOCK_FRESH_MS) {
155
- return { ok: false, status: 409, error: 'Provisioning may be in progress' };
156
+ return {
157
+ ok: false,
158
+ status: 409,
159
+ error: 'Provisioning, or the worker syncing this branch, may be in progress',
160
+ };
156
161
  }
157
162
  // Youth rail: a brand-new orphan dir may be a clone that just hasn't
158
163
  // written branch.json yet. Corrupt-metadata dirs are exempt -- a
@@ -171,7 +176,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
171
176
  // contention instead of hanging the request for ~5 minutes.
172
177
  let releaseProvisioningLock;
173
178
  try {
174
- releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, `.${params.dirName}.init.lock`);
179
+ releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
175
180
  }
176
181
  catch (err) {
177
182
  if (isNodeError(err) && err.code === 'ELOCKED') {
@@ -279,7 +284,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
279
284
  // Held until the `finally` below, AFTER save() completes.
280
285
  let releaseProvisioningLock;
281
286
  try {
282
- releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, `.${params.dirName}.init.lock`);
287
+ releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
283
288
  }
284
289
  catch (err) {
285
290
  if (isNodeError(err) && err.code === 'ELOCKED') {
@@ -8,7 +8,7 @@
8
8
  import { z } from 'zod';
9
9
  import type { ApiResponse } from './types.js';
10
10
  import type { Task, QueueStats, CorruptTaskFile } from '../task-queue/cms-task-queue.js';
11
- import type { WorkerStatusReport } from '../types.js';
11
+ import type { BuildIdentity, WorkerStatusReport } from '../types.js';
12
12
  import type { OperatingMode } from '../operating-mode/index.js';
13
13
  export type { BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse, } from './admin-branch-health.js';
14
14
  type WorkerLivenessState = 'alive' | 'stale' | 'absent';
@@ -31,6 +31,18 @@ export interface AdminStatusData {
31
31
  worker: WorkerLiveness;
32
32
  workerStatus: WorkerStatusReport | null;
33
33
  statusReadError?: string;
34
+ /**
35
+ * Why this process cannot provision the settings workspace (groups and path rules), when it
36
+ * cannot. Its requests that resolve a user answer 503 meanwhile; bootstrap admins still reach
37
+ * /admin.
38
+ */
39
+ settingsWorkspaceError?: string;
40
+ /** Build of the API process answering this request. */
41
+ build: BuildIdentity;
42
+ /** Whether `media` is configured; without it every upload returns 501. */
43
+ assetStore: {
44
+ configured: boolean;
45
+ };
34
46
  }
35
47
  /** Response type for GET /admin/status */
36
48
  export type AdminStatusResponse = ApiResponse<AdminStatusData>;
@@ -54,10 +66,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
54
66
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
55
67
  limit: z.ZodOptional<z.ZodNumber>;
56
68
  }, "strip", z.ZodTypeAny, {
57
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
69
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
58
70
  limit?: number | undefined;
59
71
  }, {
60
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
72
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
61
73
  limit?: number | undefined;
62
74
  }>;
63
75
  export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
@@ -65,10 +77,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
65
77
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
66
78
  fileName: z.ZodEffects<z.ZodString, string, string>;
67
79
  }, "strip", z.ZodTypeAny, {
68
- status: "pending" | "failed" | "corrupt";
80
+ status: "failed" | "pending" | "corrupt";
69
81
  fileName: string;
70
82
  }, {
71
- status: "pending" | "failed" | "corrupt";
83
+ status: "failed" | "pending" | "corrupt";
72
84
  fileName: string;
73
85
  }>;
74
86
  export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
@@ -108,10 +120,10 @@ export declare const ADMIN_ROUTES: {
108
120
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
109
121
  limit: z.ZodOptional<z.ZodNumber>;
110
122
  }, "strip", z.ZodTypeAny, {
111
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
123
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
112
124
  limit?: number | undefined;
113
125
  }, {
114
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
126
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
115
127
  limit?: number | undefined;
116
128
  }>, undefined, AdminTasksResponse>;
117
129
  readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -125,10 +137,10 @@ export declare const ADMIN_ROUTES: {
125
137
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
126
138
  fileName: z.ZodEffects<z.ZodString, string, string>;
127
139
  }, "strip", z.ZodTypeAny, {
128
- status: "pending" | "failed" | "corrupt";
140
+ status: "failed" | "pending" | "corrupt";
129
141
  fileName: string;
130
142
  }, {
131
- status: "pending" | "failed" | "corrupt";
143
+ status: "failed" | "pending" | "corrupt";
132
144
  fileName: string;
133
145
  }>, undefined, AdminDeleteTaskResponse>;
134
146
  };
package/dist/api/admin.js CHANGED
@@ -12,7 +12,8 @@ import { getQueueStats, listTasks, listCorruptTaskFiles, requeueFailedTask, } fr
12
12
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
13
13
  import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
14
14
  import { defineEndpoint } from './route-builder.js';
15
- import { getErrorMessage, isNotFoundError } from '../utils/error.js';
15
+ import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/error.js';
16
+ import { getBuildIdentity } from '../build-identity.js';
16
17
  import { ADMIN_BRANCH_HEALTH_ROUTES } from './admin-branch-health.js';
17
18
  /**
18
19
  * 60_000 = DEFAULT_LOCK_STALE_MS in worker/cms-worker.ts, hardcoded rather
@@ -72,6 +73,15 @@ async function readWorkerStatus(taskDir) {
72
73
  return { workerStatus: null, statusReadError: getErrorMessage(err) };
73
74
  }
74
75
  }
76
+ async function readSettingsWorkspaceError(ctx) {
77
+ try {
78
+ await ctx.services.getSettingsBranchRoot();
79
+ return undefined;
80
+ }
81
+ catch (err) {
82
+ return redactCredentials(getErrorMessage(err));
83
+ }
84
+ }
75
85
  /** Age (ms) of the oldest file in pending/, or undefined if empty/missing. */
76
86
  async function getOldestPendingAgeMs(taskDir) {
77
87
  const pendingDir = path.join(taskDir, 'pending');
@@ -126,11 +136,12 @@ const deleteTaskParamsSchema = z.object({
126
136
  const getAdminStatusHandler = async (_gc, ctx, _req) => {
127
137
  const taskDir = getTaskQueueDir(ctx.services.config);
128
138
  try {
129
- const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }] = await Promise.all([
139
+ const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }, settingsWorkspaceError,] = await Promise.all([
130
140
  getQueueStats(taskDir),
131
141
  getOldestPendingAgeMs(taskDir),
132
142
  classifyWorkerLiveness(taskDir),
133
143
  readWorkerStatus(taskDir),
144
+ readSettingsWorkspaceError(ctx),
134
145
  ]);
135
146
  return {
136
147
  ok: true,
@@ -145,6 +156,9 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
145
156
  worker,
146
157
  workerStatus,
147
158
  ...(statusReadError ? { statusReadError } : {}),
159
+ ...(settingsWorkspaceError ? { settingsWorkspaceError } : {}),
160
+ build: getBuildIdentity(),
161
+ assetStore: { configured: !!ctx.assetStore },
148
162
  },
149
163
  };
150
164
  }
@@ -255,6 +269,8 @@ const getAdminStatus = defineEndpoint({
255
269
  queue: { pending: 0, processing: 0, completed: 0, failed: 0, corrupt: 0 },
256
270
  worker: { state: 'absent' },
257
271
  workerStatus: null,
272
+ build: { canopycmsVersion: '0.0.0' },
273
+ assetStore: { configured: false },
258
274
  },
259
275
  guards: ['admin'],
260
276
  handler: getAdminStatusHandler,
@@ -8,6 +8,8 @@ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/
8
8
  import { syncSubmitPr } from './github-sync.js';
9
9
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
10
10
  import { isNonFastForwardRejection } from '../utils/git.js';
11
+ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
12
+ import { submissionEditorFromUser } from '../submission-attribution.js';
11
13
  const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
12
14
  const { branchContext } = gc;
13
15
  return { ok: true, status: 200, data: { branch: branchContext.branch } };
@@ -49,10 +51,24 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
49
51
  };
50
52
  }
51
53
  // Commit and push changes
54
+ const submitter = submissionEditorFromUser(req.user);
55
+ let changedPaths;
52
56
  try {
53
- await ctx.services.submitBranch({ context: branchContext });
57
+ ;
58
+ ({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
54
59
  }
55
60
  catch (err) {
61
+ // Retriable either way: a resubmit commits nothing new and pushes only
62
+ // what has not reached the remote.
63
+ if (err instanceof ContentWriteLockBusyError) {
64
+ return {
65
+ ok: false,
66
+ status: 409,
67
+ error: err.outcome === 'not-run'
68
+ ? `Could not submit "${branchContext.branch.name}" right now: it is being synced with its base branch, or a save is in flight. Nothing was submitted; try again in a moment.`
69
+ : `"${branchContext.branch.name}" was being synced while it was submitted, so the submit may not have completed. Try submitting again.`,
70
+ };
71
+ }
56
72
  const message = getErrorMessage(err);
57
73
  // Full path detail (including branchRoot, an absolute path) to server logs
58
74
  // only; the client only ever sees the sanitized form (API-H2). Credentials
@@ -62,8 +78,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
62
78
  // A non-fast-forward rejection means this branch and the deployment's
63
79
  // local repository have diverged. Retrying the identical push can never
64
80
  // succeed (see isNonFastForwardRejection), so surface 409 instead of the
65
- // generic 500 below. Everything else (network, auth, lock contention)
66
- // keeps the existing 500 path unchanged.
81
+ // generic 500 below. Everything else (network, auth) keeps the 500 path.
67
82
  //
68
83
  // This push targets the deployment's OWN local origin (remote.git), not
69
84
  // GitHub, so the message deliberately states only the observable fact and
@@ -91,7 +106,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
91
106
  };
92
107
  }
93
108
  // Create or update PR (sync via githubService, or async via task queue)
94
- const prResult = await syncSubmitPr(ctx, branchContext);
109
+ const prResult = await syncSubmitPr(ctx, branchContext, { submitter, changedPaths });
95
110
  // Update metadata with status and PR info
96
111
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
97
112
  const updated = await meta.save({
@@ -10,7 +10,7 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
10
10
  import { isNotFoundError, getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
11
11
  import { filePathExists } from '../utils/fs.js';
12
12
  import { isNetworkRemoteUrl } from '../utils/git.js';
13
- import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from '../paths/index.js';
13
+ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from '../paths/index.js';
14
14
  import { GitManager } from '../git-manager.js';
15
15
  import { branchNameSchema, branchParamSchema } from './validators.js';
16
16
  const log = createDebugLogger({ prefix: 'BranchAPI' });
@@ -95,15 +95,12 @@ export const createBranchHandler = async (ctx, req, body) => {
95
95
  branchName,
96
96
  userId: req.user.userId,
97
97
  });
98
- // Scope note: the collision guards below (settings-branch collision,
99
- // reserved canopycms-settings- prefix, and the remote-mirror check
100
- // further down) apply ONLY to this user-facing creation path.
101
- // http/handler.ts's auto-create (base/active/settings branches) and
102
- // branch-workspace.ts's loadOrCreateBranchContext (reached from run-time
103
- // content reads and the AI pipeline; build-time reads return the checkout
104
- // and never provision) provision system/known branch names, not
105
- // user-chosen ones, and deliberately stay uncovered -- intentional, not
106
- // an oversight.
98
+ // Scope note: the remote-mirror check further down applies ONLY to this
99
+ // user-facing creation path; http/handler.ts's auto-create (base/active
100
+ // branches) and loadOrCreateBranchContext provision known names. The two
101
+ // settings-branch checks below give this path a specific 400; every
102
+ // provisioning path, this one included, also refuses a settings branch in
103
+ // BranchWorkspaceManager.openOrCreateBranch.
107
104
  // Prevent git branch name collision with the settings branch. Settings
108
105
  // live in a separate directory but share the same git remote, and
109
106
  // openOrCreateBranch (branch-workspace.ts) uses the SANITIZED name as the
@@ -337,7 +334,10 @@ export const listBranchesHandler = async (ctx, req) => {
337
334
  error: 'Branch registry not initialized — ensure the workspace has been initialized',
338
335
  };
339
336
  }
340
- const allBranches = await ctx.services.registry.list();
337
+ // A settings-branch workspace is never resolvable as a content branch (see
338
+ // http/handler.ts), so one left on disk is hidden rather than listed unopenable.
339
+ const settingsBranch = operatingStrategy(ctx.services.config.mode).getSettingsBranchName(ctx.services.config);
340
+ const allBranches = (await ctx.services.registry.list()).filter((context) => !isSettingsBranchName(context.branch.name, settingsBranch));
341
341
  // The branch the editor should open when none is pinned via URL/config.
342
342
  // Read per-request so dev-mode refreshActiveBranch() updates are reflected.
343
343
  // Sanitized: dev mode detects the RAW git HEAD name (e.g. 'claude/foo'),
@@ -25,6 +25,12 @@ export interface ApiClientOptions {
25
25
  baseUrl?: string;
26
26
  /** Custom fetch implementation, e.g. a mock in tests. */
27
27
  fetch?: typeof fetch;
28
+ /**
29
+ * End request paths with `/` (by `withTrailingSlash`'s rule), matching a Next host built with
30
+ * `trailingSlash: true`. Defaults to the value `withCanopy` inlines at build time (see
31
+ * `readTrailingSlashEnv`), else false.
32
+ */
33
+ trailingSlash?: boolean;
28
34
  /**
29
35
  * Called whenever a response comes back 401: the credential is no longer accepted. A
30
36
  * notification, not a retry; the editor's auth gate uses it to show sign-in.
@@ -44,6 +50,7 @@ export interface ApiClientOptions {
44
50
  export declare class CanopyApiClient {
45
51
  private baseUrl;
46
52
  private fetchFn;
53
+ private trailingSlash;
47
54
  private onUnauthorized;
48
55
  readonly branches: {
49
56
  /** GET /branches */
@@ -179,5 +186,10 @@ export declare class CanopyApiClient {
179
186
  private buildPath;
180
187
  private request;
181
188
  }
189
+ /**
190
+ * Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
191
+ * error page), so its status says nothing about the API itself.
192
+ */
193
+ export declare function isNonApiResponse(result: object): boolean;
182
194
  /** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
183
195
  export declare function createApiClient(options?: ApiClientOptions): CanopyApiClient;
@@ -6,6 +6,7 @@
6
6
  * (changes will be overwritten), edit the generator's templates instead.
7
7
  */
8
8
  import { computeContentSha256Hex } from './request-body-hash.js';
9
+ import { readTrailingSlashEnv, withTrailingSlash } from '../utils/url-prefix.js';
9
10
  /**
10
11
  * Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
11
12
  * returning that endpoint's ApiResponse.
@@ -260,6 +261,7 @@ export class CanopyApiClient {
260
261
  this.baseUrl = options.baseUrl ?? '/api/canopycms';
261
262
  // An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
262
263
  this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
264
+ this.trailingSlash = options.trailingSlash ?? readTrailingSlashEnv();
263
265
  this.onUnauthorized = options.onUnauthorized;
264
266
  }
265
267
  buildPath(template, params) {
@@ -292,7 +294,7 @@ export class CanopyApiClient {
292
294
  return result;
293
295
  }
294
296
  async request(method, path, body, headers = {}) {
295
- const url = `${this.baseUrl}${path}`;
297
+ const url = `${this.baseUrl}${this.trailingSlash ? withTrailingSlash(path) : path}`;
296
298
  const requestHeaders = {
297
299
  ...headers,
298
300
  };
@@ -325,21 +327,42 @@ export class CanopyApiClient {
325
327
  init.body = requestBody;
326
328
  }
327
329
  const response = await this.fetchFn(url, init);
330
+ // A body from in front of the API (a proxy or CDN error page) may be empty, not JSON, or
331
+ // not an ApiResponse; every such body becomes an `ok: false` ApiResponse, never a throw.
332
+ const parsed = await response.json().catch(() => undefined);
328
333
  if (response.status === 401) {
329
334
  this.onUnauthorized?.();
330
- // A 401 is an auth answer whatever its body: one from in front of the API (a proxy) may
331
- // not be an ApiResponse, or not JSON. Always return it as one rather than throwing.
332
- const body = await response.json().catch(() => undefined);
333
- const error = typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
334
- ? body.error
335
- : 'Unauthorized';
336
- return { ok: false, status: 401, error };
335
+ return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
337
336
  }
338
- const payload = await response.json();
339
- // All responses use ApiResponse format: { ok, status, data?, error? }
340
- return payload;
337
+ if (isApiResponseBody(parsed))
338
+ return parsed;
339
+ const converted = {
340
+ ok: false,
341
+ status: response.status,
342
+ error: errorFromBody(parsed) ?? `Unexpected response from server (HTTP ${response.status})`,
343
+ };
344
+ convertedResponses.add(converted);
345
+ return converted;
341
346
  }
342
347
  }
348
+ const convertedResponses = new WeakSet();
349
+ /**
350
+ * Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
351
+ * error page), so its status says nothing about the API itself.
352
+ */
353
+ export function isNonApiResponse(result) {
354
+ return convertedResponses.has(result);
355
+ }
356
+ /** The `error` string of a JSON body, when it has one. */
357
+ function errorFromBody(body) {
358
+ return typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
359
+ ? body.error
360
+ : undefined;
361
+ }
362
+ /** Whether a parsed body has the `{ ok, status, ... }` shape every handler returns. */
363
+ function isApiResponseBody(body) {
364
+ return typeof body === 'object' && body !== null && 'ok' in body && typeof body.ok === 'boolean';
365
+ }
343
366
  /** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
344
367
  export function createApiClient(options) {
345
368
  return new CanopyApiClient(options);
@@ -40,11 +40,12 @@ export interface WriteContentBody {
40
40
  data?: Record<string, unknown>;
41
41
  body?: string;
42
42
  /**
43
- * OCC / create-intent token. Omit for a blind write (no opinion). A number
44
- * from a prior read/write response rejects the write with 409 if the file
45
- * has changed since. `null` means "this entry must not already exist" —
46
- * the create path uses this so a create against an existing slug is
47
- * rejected with 409 instead of silently overwriting it.
43
+ * OCC token. A number from a prior read/write response makes this an update,
44
+ * rejected with 409 if the file's mtime no longer matches (a file deleted
45
+ * since is written anew). `null` or omitted makes it
46
+ * a create, rejected with 409 if the entry already exists. There is no blind
47
+ * write: without a token, "no conflict detection" would be indistinguishable
48
+ * from "lost the token", so an update has to prove which version it read.
48
49
  */
49
50
  expectedVersion?: number | null;
50
51
  }