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.
- package/README.md +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- 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
|
|
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
|
|
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
|
|
package/dist/ai/handler.js
CHANGED
|
@@ -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
|
|
151
|
-
// be in progress; a stale one is just
|
|
152
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
287
|
+
releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
|
|
283
288
|
}
|
|
284
289
|
catch (err) {
|
|
285
290
|
if (isNodeError(err) && err.code === 'ELOCKED') {
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -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: "
|
|
69
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
58
70
|
limit?: number | undefined;
|
|
59
71
|
}, {
|
|
60
|
-
status: "
|
|
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: "
|
|
80
|
+
status: "failed" | "pending" | "corrupt";
|
|
69
81
|
fileName: string;
|
|
70
82
|
}, {
|
|
71
|
-
status: "
|
|
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: "
|
|
123
|
+
status: "failed" | "pending" | "processing" | "completed" | "corrupt";
|
|
112
124
|
limit?: number | undefined;
|
|
113
125
|
}, {
|
|
114
|
-
status: "
|
|
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: "
|
|
140
|
+
status: "failed" | "pending" | "corrupt";
|
|
129
141
|
fileName: string;
|
|
130
142
|
}, {
|
|
131
|
-
status: "
|
|
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
|
-
|
|
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
|
|
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({
|
package/dist/api/branch.js
CHANGED
|
@@ -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
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
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'),
|
package/dist/api/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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);
|
package/dist/api/content.d.ts
CHANGED
|
@@ -40,11 +40,12 @@ export interface WriteContentBody {
|
|
|
40
40
|
data?: Record<string, unknown>;
|
|
41
41
|
body?: string;
|
|
42
42
|
/**
|
|
43
|
-
* OCC
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
}
|