canopycms 0.0.67 → 0.0.68-int.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- 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 +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- 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/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- 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.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- 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 +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- 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/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- 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 +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -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/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- 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/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -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 +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- 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-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- 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 +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- 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.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- 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 +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
package/dist/api/client.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* AUTO-GENERATED by scripts/generate-client.ts; do not edit this file manually
|
|
6
6
|
* (changes will be overwritten), edit the generator's templates instead.
|
|
7
7
|
*/
|
|
8
|
-
import type { BranchDeleteResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
|
|
8
|
+
import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
|
|
9
9
|
import type { BranchMergeResponse } from './branch-status.js';
|
|
10
10
|
import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
|
|
11
11
|
import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
|
|
@@ -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,12 +50,13 @@ 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 */
|
|
50
57
|
list: () => Promise<BranchListResponse>;
|
|
51
58
|
/** POST /branches */
|
|
52
|
-
create: (body: CreateBranchBody) => Promise<
|
|
59
|
+
create: (body: CreateBranchBody) => Promise<BranchListItemResponse>;
|
|
53
60
|
/** DELETE /:branch */
|
|
54
61
|
delete: (params: Record<string, string>) => Promise<BranchDeleteResponse>;
|
|
55
62
|
/** PATCH /:branch/access */
|
|
@@ -57,17 +64,17 @@ export declare class CanopyApiClient {
|
|
|
57
64
|
};
|
|
58
65
|
readonly workflow: {
|
|
59
66
|
/** POST /:branch/withdraw */
|
|
60
|
-
withdraw: (params: Record<string, string>) => Promise<
|
|
67
|
+
withdraw: (params: Record<string, string>) => Promise<BranchListItemResponse>;
|
|
61
68
|
/** POST /:branch/request-changes */
|
|
62
|
-
requestChanges: (params: Record<string, string>) => Promise<
|
|
69
|
+
requestChanges: (params: Record<string, string>) => Promise<BranchListItemResponse>;
|
|
63
70
|
/** POST /:branch/approve */
|
|
64
|
-
approve: (params: Record<string, string>) => Promise<
|
|
71
|
+
approve: (params: Record<string, string>) => Promise<BranchListItemResponse>;
|
|
65
72
|
/** POST /:branch/mark-merged */
|
|
66
73
|
markMerged: (params: Record<string, string>) => Promise<BranchMergeResponse>;
|
|
67
74
|
/** GET /:branch/status */
|
|
68
75
|
getStatus: (params: Record<string, string>) => Promise<BranchResponse>;
|
|
69
76
|
/** POST /:branch/submit */
|
|
70
|
-
submit: (params: Record<string, string>) => Promise<
|
|
77
|
+
submit: (params: Record<string, string>) => Promise<BranchListItemResponse>;
|
|
71
78
|
};
|
|
72
79
|
readonly comments: {
|
|
73
80
|
/** GET /:branch/comments */
|
|
@@ -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
|
};
|
|
@@ -324,21 +326,86 @@ export class CanopyApiClient {
|
|
|
324
326
|
if (requestBody !== undefined) {
|
|
325
327
|
init.body = requestBody;
|
|
326
328
|
}
|
|
327
|
-
|
|
329
|
+
let 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
|
+
let parsed = await response.json().catch(() => undefined);
|
|
333
|
+
// A 429 no handler wrote is a throttle in front of the API, such as Lambda's concurrency
|
|
334
|
+
// cap, which answers before the function runs. The request was never handled, so
|
|
335
|
+
// resending it is safe for writes too.
|
|
336
|
+
for (let attempt = 0; response.status === 429 && !isApiResponseBody(parsed); attempt++) {
|
|
337
|
+
const delay = throttleRetryDelayMs(response, attempt);
|
|
338
|
+
if (delay === undefined)
|
|
339
|
+
break;
|
|
340
|
+
await sleep(delay);
|
|
341
|
+
response = await this.fetchFn(url, init);
|
|
342
|
+
parsed = await response.json().catch(() => undefined);
|
|
343
|
+
}
|
|
328
344
|
if (response.status === 401) {
|
|
329
345
|
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 };
|
|
346
|
+
return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
|
|
337
347
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
348
|
+
if (isApiResponseBody(parsed))
|
|
349
|
+
return parsed;
|
|
350
|
+
const converted = {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: response.status,
|
|
353
|
+
error: errorFromBody(parsed) ?? `Unexpected response from server (HTTP ${response.status})`,
|
|
354
|
+
};
|
|
355
|
+
convertedResponses.add(converted);
|
|
356
|
+
return converted;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Base wait before each resend of a throttled request. A throttle from a burst of cold starts
|
|
361
|
+
* clears as they finish, so the waits span a few seconds.
|
|
362
|
+
*/
|
|
363
|
+
const THROTTLE_RETRY_DELAYS_MS = [250, 1000, 3000];
|
|
364
|
+
/** The longest `Retry-After` honoured; a throttle asking for longer is reported, not resent. */
|
|
365
|
+
const MAX_RETRY_AFTER_MS = 5000;
|
|
366
|
+
/**
|
|
367
|
+
* The wait before resend `attempt`, or undefined when there is to be no resend: the attempts
|
|
368
|
+
* are used up, or `Retry-After` asks for longer than {@link MAX_RETRY_AFTER_MS}. The base delay,
|
|
369
|
+
* or `Retry-After` when longer, plus up to half the base again, so requests throttled in one
|
|
370
|
+
* burst do not resend in lockstep.
|
|
371
|
+
*/
|
|
372
|
+
function throttleRetryDelayMs(response, attempt) {
|
|
373
|
+
const base = THROTTLE_RETRY_DELAYS_MS[attempt];
|
|
374
|
+
if (base === undefined)
|
|
375
|
+
return undefined;
|
|
376
|
+
// Delta-seconds only; an HTTP-date or a blank value falls back to the base delay. A custom
|
|
377
|
+
// `fetch` option may return a Response-like without headers.
|
|
378
|
+
const header = response.headers?.get('retry-after')?.trim();
|
|
379
|
+
const retryAfter = header ? Number(header) : Number.NaN;
|
|
380
|
+
let wait = base;
|
|
381
|
+
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
|
|
382
|
+
const ms = retryAfter * 1000;
|
|
383
|
+
if (ms > MAX_RETRY_AFTER_MS)
|
|
384
|
+
return undefined;
|
|
385
|
+
wait = Math.max(ms, base);
|
|
341
386
|
}
|
|
387
|
+
return wait + Math.random() * base * 0.5;
|
|
388
|
+
}
|
|
389
|
+
function sleep(ms) {
|
|
390
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
391
|
+
}
|
|
392
|
+
const convertedResponses = new WeakSet();
|
|
393
|
+
/**
|
|
394
|
+
* Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
|
|
395
|
+
* error page), so its status says nothing about the API itself.
|
|
396
|
+
*/
|
|
397
|
+
export function isNonApiResponse(result) {
|
|
398
|
+
return convertedResponses.has(result);
|
|
399
|
+
}
|
|
400
|
+
/** The `error` string of a JSON body, when it has one. */
|
|
401
|
+
function errorFromBody(body) {
|
|
402
|
+
return typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
|
|
403
|
+
? body.error
|
|
404
|
+
: undefined;
|
|
405
|
+
}
|
|
406
|
+
/** Whether a parsed body has the `{ ok, status, ... }` shape every handler returns. */
|
|
407
|
+
function isApiResponseBody(body) {
|
|
408
|
+
return typeof body === 'object' && body !== null && 'ok' in body && typeof body.ok === 'boolean';
|
|
342
409
|
}
|
|
343
410
|
/** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
|
|
344
411
|
export function createApiClient(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
|
}
|
package/dist/api/content.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ReferenceValidator } from '../validation/reference-validator.js';
|
|
|
5
5
|
import { findUnknownKeys, mergeBodyIntoData, normalizeReferenceValues, validateEntryData, } from '../validation/entry-validator.js';
|
|
6
6
|
import { validateEntryLinks } from '../validation/entry-link-validator.js';
|
|
7
7
|
import { branchNameSchema, logicalPathSchema, slugSchema } from './validators.js';
|
|
8
|
-
import { parseSlug } from '../paths/index.js';
|
|
8
|
+
import { entryLogicalPath, parseSlug } from '../paths/index.js';
|
|
9
9
|
import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
10
10
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
11
11
|
/**
|
|
@@ -52,7 +52,7 @@ const writeContentBodySchema = z.object({
|
|
|
52
52
|
format: z.enum(['json', 'md', 'mdx', 'yaml']),
|
|
53
53
|
data: boundedContentDataSchema.optional(),
|
|
54
54
|
body: z.string().max(MAX_CONTENT_BODY_CHARS).optional(),
|
|
55
|
-
// null = create
|
|
55
|
+
// null or omitted = create ("must not already exist"); see WriteContentBody.
|
|
56
56
|
expectedVersion: z.number().nullish(),
|
|
57
57
|
});
|
|
58
58
|
const validateReferencesParamsSchema = z.object({
|
|
@@ -80,24 +80,27 @@ const readContentHandler = async (gc, ctx, req, params) => {
|
|
|
80
80
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
81
81
|
let schemaItem;
|
|
82
82
|
let slug;
|
|
83
|
-
let relativePath;
|
|
84
83
|
try {
|
|
85
84
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
86
85
|
schemaItem = resolved.schemaItem;
|
|
87
86
|
slug = resolved.slug;
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
88
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
90
89
|
}
|
|
91
90
|
catch (err) {
|
|
92
91
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
93
92
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
94
93
|
}
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
// One checker for the entry and every reference target it resolves, so a reference cannot
|
|
95
|
+
// carry a target's data past the rules that would refuse a direct read of it.
|
|
96
|
+
const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
|
|
97
|
+
if (!checkAccess(entryLogicalPath(schemaItem.logicalPath, slug), 'read').allowed) {
|
|
97
98
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
98
99
|
}
|
|
99
100
|
try {
|
|
100
|
-
const doc = await store.read(schemaItem.logicalPath, slug
|
|
101
|
+
const doc = await store.read(schemaItem.logicalPath, slug, {
|
|
102
|
+
referenceAccess: (targetPath) => checkAccess(targetPath, 'read').allowed,
|
|
103
|
+
});
|
|
101
104
|
return { ok: true, status: 200, data: doc };
|
|
102
105
|
}
|
|
103
106
|
catch (err) {
|
|
@@ -117,19 +120,18 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
117
120
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
118
121
|
let schemaItem;
|
|
119
122
|
let slug;
|
|
120
|
-
let relativePath;
|
|
121
123
|
try {
|
|
122
124
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
123
125
|
schemaItem = resolved.schemaItem;
|
|
124
126
|
slug = resolved.slug;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
128
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
127
129
|
}
|
|
128
130
|
catch (err) {
|
|
129
131
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
130
132
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
131
133
|
}
|
|
132
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
134
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'edit');
|
|
133
135
|
if (!access.allowed) {
|
|
134
136
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
135
137
|
}
|
|
@@ -198,20 +200,21 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
198
200
|
}
|
|
199
201
|
const data = body.data ?? {};
|
|
200
202
|
const isDataOnly = isDataOnlyFormat(body.format);
|
|
203
|
+
// The store's own `undefined` (blind write) is never reachable from here: an omitted token is
|
|
204
|
+
// a create, so an update that lost its token 409s instead of overwriting unchecked.
|
|
205
|
+
const expectedVersion = body.expectedVersion ?? null;
|
|
206
|
+
const createConflictError = body.expectedVersion === null
|
|
207
|
+
? `An entry with slug "${slug}" already exists`
|
|
208
|
+
: `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
|
|
201
209
|
try {
|
|
202
210
|
const exists = await store.documentExists(schemaItem.logicalPath, slug);
|
|
203
|
-
// Create
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
ok: false,
|
|
212
|
-
status: 409,
|
|
213
|
-
error: `An entry with slug "${slug}" already exists`,
|
|
214
|
-
};
|
|
211
|
+
// Create guard: a create against a slug that already has content must never silently
|
|
212
|
+
// overwrite it — short-circuit with 409 before field validation runs, so the error names the
|
|
213
|
+
// real problem instead of "field is required" or a bare conflict. store.write() re-enforces
|
|
214
|
+
// this itself inside its per-entry lock against a fresh stat (the race-safe authoritative
|
|
215
|
+
// check); this is just a cheaper fast path for the common case.
|
|
216
|
+
if (expectedVersion === null && exists) {
|
|
217
|
+
return { ok: false, status: 409, error: createConflictError };
|
|
215
218
|
}
|
|
216
219
|
// [SLUG] Create-only routability check: `writeContentParamsSchema.path`'s `parseLogicalPath`
|
|
217
220
|
// has no charset rule, and `ContentStore.resolvePath` only lowercases the slug — so this is
|
|
@@ -360,13 +363,13 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
360
363
|
? {
|
|
361
364
|
format: body.format,
|
|
362
365
|
data: normalizedData ?? {},
|
|
363
|
-
expectedVersion
|
|
366
|
+
expectedVersion,
|
|
364
367
|
}
|
|
365
368
|
: {
|
|
366
369
|
format: body.format,
|
|
367
370
|
data: normalizedData,
|
|
368
371
|
body: body.body ?? '',
|
|
369
|
-
expectedVersion
|
|
372
|
+
expectedVersion,
|
|
370
373
|
};
|
|
371
374
|
// Pass the resolved entryTypeName (not the raw, possibly-omitted
|
|
372
375
|
// params.entryType) so the store's own format check agrees with the type
|
|
@@ -412,12 +415,8 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
412
415
|
}
|
|
413
416
|
// Race-safe fallback: the early `exists` check above catches the common case; this covers a
|
|
414
417
|
// collision that lands between that check and store.write()'s in-lock stat.
|
|
415
|
-
if (
|
|
416
|
-
return {
|
|
417
|
-
ok: false,
|
|
418
|
-
status: 409,
|
|
419
|
-
error: `An entry with slug "${slug}" already exists`,
|
|
420
|
-
};
|
|
418
|
+
if (expectedVersion === null) {
|
|
419
|
+
return { ok: false, status: 409, error: createConflictError };
|
|
421
420
|
}
|
|
422
421
|
return {
|
|
423
422
|
ok: false,
|
|
@@ -443,19 +442,19 @@ const validateReferencesHandler = async (gc, ctx, req, params, body) => {
|
|
|
443
442
|
const contentRoot = ctx.services.config.contentRoot || 'content';
|
|
444
443
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
445
444
|
let schemaItem;
|
|
446
|
-
let
|
|
445
|
+
let slug;
|
|
447
446
|
try {
|
|
448
447
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
449
448
|
schemaItem = resolved.schemaItem;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
449
|
+
slug = resolved.slug;
|
|
450
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
451
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, slug);
|
|
453
452
|
}
|
|
454
453
|
catch (err) {
|
|
455
454
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
456
455
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
457
456
|
}
|
|
458
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
457
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
|
|
459
458
|
if (!access.allowed) {
|
|
460
459
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
461
460
|
}
|
|
@@ -510,22 +509,26 @@ const renameEntryHandler = async (gc, ctx, req, params, body) => {
|
|
|
510
509
|
const logicalPathSegments = parseApiPath(params.path, contentRoot);
|
|
511
510
|
let schemaItem;
|
|
512
511
|
let currentSlug;
|
|
513
|
-
let relativePath;
|
|
514
512
|
try {
|
|
515
513
|
const resolved = store.resolvePath(logicalPathSegments);
|
|
516
514
|
schemaItem = resolved.schemaItem;
|
|
517
515
|
currentSlug = resolved.slug;
|
|
518
|
-
|
|
519
|
-
|
|
516
|
+
// Runs the store's slug and traversal checks before the permission check.
|
|
517
|
+
await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
|
|
520
518
|
}
|
|
521
519
|
catch (err) {
|
|
522
520
|
const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
|
|
523
521
|
return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
|
|
524
522
|
}
|
|
525
|
-
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
523
|
+
const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, currentSlug), req.user, 'edit');
|
|
526
524
|
if (!access.allowed) {
|
|
527
525
|
return { ok: false, status: 403, error: 'Forbidden' };
|
|
528
526
|
}
|
|
527
|
+
// A rename creates the entry at its new path, so that path needs edit access too.
|
|
528
|
+
const destinationAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, body.newSlug), req.user, 'edit');
|
|
529
|
+
if (!destinationAccess.allowed) {
|
|
530
|
+
return { ok: false, status: 403, error: 'Forbidden' };
|
|
531
|
+
}
|
|
529
532
|
try {
|
|
530
533
|
const result = await store.renameEntry(schemaItem.logicalPath, currentSlug, body.newSlug);
|
|
531
534
|
return { ok: true, status: 200, data: { newPath: result.newPath } };
|
package/dist/api/entries.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, } from '../content-store.js';
|
|
4
4
|
import { defineEndpoint } from './route-builder.js';
|
|
5
|
-
import { normalizeFilesystemPath, parseSlug, parseLogicalPath } from '../paths/index.js';
|
|
5
|
+
import { entryLogicalPath, normalizeFilesystemPath, parseSlug, parseLogicalPath } from '../paths/index.js';
|
|
6
6
|
import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
7
7
|
import { createDebugLogger } from '../utils/debug.js';
|
|
8
8
|
import { resolveEntryTitle } from '../utils/title-field.js';
|
|
@@ -81,7 +81,7 @@ const listCollectionEntriesRecursive = async (root, targetPath, flatCollections)
|
|
|
81
81
|
const filterWithAccessControl = (items, checkAccess, search) => {
|
|
82
82
|
const results = [];
|
|
83
83
|
for (const item of items) {
|
|
84
|
-
const readAccess = checkAccess(item.
|
|
84
|
+
const readAccess = checkAccess(item.logicalPath, 'read');
|
|
85
85
|
if (!readAccess.allowed)
|
|
86
86
|
continue;
|
|
87
87
|
if (search) {
|
|
@@ -90,7 +90,7 @@ const filterWithAccessControl = (items, checkAccess, search) => {
|
|
|
90
90
|
continue;
|
|
91
91
|
}
|
|
92
92
|
// Compute edit access only for items that survive the read + search filters.
|
|
93
|
-
const editAccess = checkAccess(item.
|
|
93
|
+
const editAccess = checkAccess(item.logicalPath, 'edit');
|
|
94
94
|
results.push({ ...item, canEdit: editAccess.allowed });
|
|
95
95
|
}
|
|
96
96
|
return results;
|
|
@@ -255,11 +255,9 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
257
|
const entrySlug = slugResult.slug;
|
|
258
|
-
//
|
|
259
|
-
let physicalPath;
|
|
258
|
+
// Resolution runs the store's slug and traversal checks before the permission check.
|
|
260
259
|
try {
|
|
261
|
-
|
|
262
|
-
physicalPath = resolved.relativePath;
|
|
260
|
+
await contentStore.resolveDocumentPath(collectionLogicalPath, entrySlug);
|
|
263
261
|
}
|
|
264
262
|
catch (err) {
|
|
265
263
|
if (isNotFoundError(err)) {
|
|
@@ -272,7 +270,7 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
272
270
|
}
|
|
273
271
|
throw err;
|
|
274
272
|
}
|
|
275
|
-
const editAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot,
|
|
273
|
+
const editAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(collection.logicalPath, entrySlug), req.user, 'edit');
|
|
276
274
|
if (!editAccess.allowed) {
|
|
277
275
|
return {
|
|
278
276
|
ok: false,
|
|
@@ -301,13 +299,16 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
|
|
|
301
299
|
// C6: order cleanup is best-effort hygiene after the entry is already deleted, so no
|
|
302
300
|
// failure here may turn an otherwise-successful delete into an error (a retry would
|
|
303
301
|
// just 404). Surface it as a warning instead (mirrors content.ts's `validationWarnings`).
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
302
|
+
// An `'unknown'` outcome means the update ran to completion: nothing to warn of.
|
|
303
|
+
if (!(err instanceof SchemaStoreBusyError && err.outcome === 'unknown')) {
|
|
304
|
+
const reason = err instanceof SchemaStoreBusyError ? 'schema is busy' : getErrorMessage(err);
|
|
305
|
+
log.warn('delete-entry', 'Skipped order cleanup', {
|
|
306
|
+
collectionPath,
|
|
307
|
+
contentId,
|
|
308
|
+
error: getErrorMessage(err),
|
|
309
|
+
});
|
|
310
|
+
orderCleanupWarning = `Entry deleted, but the collection order list could not be updated (${sanitizeErrorMessage(reason)}); it will still list this entry until the next schema change.`;
|
|
311
|
+
}
|
|
311
312
|
}
|
|
312
313
|
}
|
|
313
314
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ApiContext } from './types.js';
|
|
2
2
|
import type { BranchContext, SyncStatus } from '../types.js';
|
|
3
|
+
import { type SubmissionEditor } from '../submission-attribution.js';
|
|
3
4
|
/**
|
|
4
5
|
* The caller uses this to update branch metadata.
|
|
5
6
|
*/
|
|
@@ -8,12 +9,29 @@ export interface GitHubSyncResult {
|
|
|
8
9
|
prNumber?: number;
|
|
9
10
|
syncStatus?: SyncStatus;
|
|
10
11
|
}
|
|
12
|
+
/** What the PR body records about a submit. */
|
|
13
|
+
export interface SubmissionRecord {
|
|
14
|
+
submitter?: SubmissionEditor;
|
|
15
|
+
changedPaths: readonly string[];
|
|
16
|
+
}
|
|
11
17
|
/**
|
|
12
18
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
19
|
+
*
|
|
20
|
+
* The body is the canopycms PR section (submission-attribution.ts): an existing
|
|
21
|
+
* PR keeps whatever a human wrote outside it.
|
|
13
22
|
*/
|
|
14
|
-
export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext): Promise<GitHubSyncResult>;
|
|
23
|
+
export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext, submission: SubmissionRecord): Promise<GitHubSyncResult>;
|
|
15
24
|
/**
|
|
16
25
|
* Used by withdraw and request-changes. Uses githubService directly if
|
|
17
26
|
* available, otherwise queues a task.
|
|
18
27
|
*/
|
|
19
28
|
export declare function syncConvertToDraft(ctx: ApiContext, context: BranchContext): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
|
|
31
|
+
* put it there: a content branch reaches GitHub only through submit, which always opens a PR,
|
|
32
|
+
* so a recorded PR number is the proof. Without one, a same-named GitHub branch is someone
|
|
33
|
+
* else's (the create-time collision check is best-effort) and is never touched.
|
|
34
|
+
*
|
|
35
|
+
* Returns a client-facing warning when the delete could not be done or queued; never throws.
|
|
36
|
+
*/
|
|
37
|
+
export declare function syncDeleteRemoteBranch(ctx: ApiContext, context: BranchContext): Promise<string | undefined>;
|
package/dist/api/github-sync.js
CHANGED
|
@@ -3,14 +3,23 @@ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
|
3
3
|
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
4
4
|
import { getErrorMessage } from '../utils/error.js';
|
|
5
5
|
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
6
|
+
import { buildPrSection, mergePrSection } from '../submission-attribution.js';
|
|
7
|
+
import { isRefAlreadyGoneError } from '../github-service.js';
|
|
6
8
|
/**
|
|
7
9
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
10
|
+
*
|
|
11
|
+
* The body is the canopycms PR section (submission-attribution.ts): an existing
|
|
12
|
+
* PR keeps whatever a human wrote outside it.
|
|
8
13
|
*/
|
|
9
|
-
export async function syncSubmitPr(ctx, context) {
|
|
14
|
+
export async function syncSubmitPr(ctx, context, submission) {
|
|
10
15
|
const { githubService } = ctx.services;
|
|
11
16
|
const mode = ctx.services.config.mode;
|
|
12
17
|
const prTitle = context.branch.title || `Submit ${context.branch.name}`;
|
|
13
|
-
const
|
|
18
|
+
const prSection = buildPrSection({
|
|
19
|
+
description: context.branch.description,
|
|
20
|
+
submitter: submission.submitter,
|
|
21
|
+
changedPaths: submission.changedPaths,
|
|
22
|
+
});
|
|
14
23
|
// Target the fork point recorded at branch creation when available.
|
|
15
24
|
const baseBranch = context.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
|
|
16
25
|
if (!clientOperatingStrategy(mode).supportsPullRequests()) {
|
|
@@ -29,9 +38,12 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
29
38
|
if (githubService) {
|
|
30
39
|
try {
|
|
31
40
|
if (context.branch.pullRequestNumber) {
|
|
41
|
+
// Read before writing: the update keeps the human text around the
|
|
42
|
+
// canopycms section, and the same read says whether the PR is a draft.
|
|
43
|
+
const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
|
|
32
44
|
await githubService.updatePullRequest(context.branch.pullRequestNumber, {
|
|
33
45
|
title: prTitle,
|
|
34
|
-
body:
|
|
46
|
+
body: mergePrSection(pr.body, prSection),
|
|
35
47
|
});
|
|
36
48
|
// Best-effort draft->ready conversion. This branch updates a known
|
|
37
49
|
// PR number directly (not through createOrUpdatePR), so it doesn't
|
|
@@ -42,7 +54,6 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
42
54
|
// consistent with createOrUpdatePullRequest's best-effort handling
|
|
43
55
|
// in github-service.ts.
|
|
44
56
|
try {
|
|
45
|
-
const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
|
|
46
57
|
if (pr.draft) {
|
|
47
58
|
await githubService.convertToReady(context.branch.pullRequestNumber);
|
|
48
59
|
}
|
|
@@ -73,8 +84,9 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
73
84
|
head: context.branch.name,
|
|
74
85
|
base: baseBranch,
|
|
75
86
|
title: prTitle,
|
|
76
|
-
body:
|
|
87
|
+
body: prSection,
|
|
77
88
|
markReadyIfDraft: true,
|
|
89
|
+
mergeSectionIntoBody: true,
|
|
78
90
|
});
|
|
79
91
|
return {
|
|
80
92
|
prUrl: result.url,
|
|
@@ -105,13 +117,12 @@ export async function syncSubmitPr(ctx, context) {
|
|
|
105
117
|
payload: {
|
|
106
118
|
branch: context.branch.name,
|
|
107
119
|
title: prTitle,
|
|
108
|
-
body:
|
|
120
|
+
body: prSection,
|
|
121
|
+
mergeSectionIntoBody: true,
|
|
109
122
|
baseBranch,
|
|
110
123
|
pullRequestNumber: context.branch.pullRequestNumber,
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// path (services.ts commitToSettingsBranch), which enqueues the same
|
|
114
|
-
// action without this flag.
|
|
124
|
+
// A content submit is an explicit "ready for review" action: convert a
|
|
125
|
+
// pre-existing draft PR to ready.
|
|
115
126
|
markReadyIfDraft: true,
|
|
116
127
|
},
|
|
117
128
|
});
|
|
@@ -146,6 +157,42 @@ export async function syncConvertToDraft(ctx, context) {
|
|
|
146
157
|
});
|
|
147
158
|
}
|
|
148
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
|
|
162
|
+
* put it there: a content branch reaches GitHub only through submit, which always opens a PR,
|
|
163
|
+
* so a recorded PR number is the proof. Without one, a same-named GitHub branch is someone
|
|
164
|
+
* else's (the create-time collision check is best-effort) and is never touched.
|
|
165
|
+
*
|
|
166
|
+
* Returns a client-facing warning when the delete could not be done or queued; never throws.
|
|
167
|
+
*/
|
|
168
|
+
export async function syncDeleteRemoteBranch(ctx, context) {
|
|
169
|
+
if (!context.branch.pullRequestNumber)
|
|
170
|
+
return undefined;
|
|
171
|
+
const { githubService } = ctx.services;
|
|
172
|
+
const branch = context.branch.name;
|
|
173
|
+
if (githubService) {
|
|
174
|
+
try {
|
|
175
|
+
await githubService.deleteBranch(branch);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (isRefAlreadyGoneError(err))
|
|
179
|
+
return undefined;
|
|
180
|
+
console.error(`CanopyCMS: Failed to delete GitHub branch for ${branch}:`, getErrorMessage(err));
|
|
181
|
+
return 'The branch could not be deleted on GitHub; delete it there by hand';
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
const mode = ctx.services.config.mode;
|
|
186
|
+
if (!clientOperatingStrategy(mode).supportsPullRequests())
|
|
187
|
+
return undefined;
|
|
188
|
+
const result = await enqueueGitHubTask(ctx, context, {
|
|
189
|
+
action: 'delete-remote-branch',
|
|
190
|
+
payload: { branch, pullRequestNumber: context.branch.pullRequestNumber },
|
|
191
|
+
});
|
|
192
|
+
return result.syncStatus === 'sync-failed'
|
|
193
|
+
? 'Deleting the branch on GitHub could not be queued; delete it there by hand'
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
149
196
|
async function enqueueGitHubTask(ctx, context, task) {
|
|
150
197
|
const taskDir = getTaskQueueDir(ctx.services.config);
|
|
151
198
|
try {
|
package/dist/api/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { ApiResponse, ApiRequest, ApiContext } from './types.js';
|
|
2
|
-
export type { BranchResponse, BranchListResponse, BranchDeleteResponse, CreateBranchBody, UpdateBranchAccessBody, } from './branch.js';
|
|
2
|
+
export type { BranchResponse, BranchListItemResponse, BranchListResponse, BranchDeleteResponse, CreateBranchBody, UpdateBranchAccessBody, } from './branch.js';
|
|
3
3
|
export type { BranchMergeResponse } from './branch-merge.js';
|
|
4
4
|
export type { CommentsResponse, AddCommentResponse, ResolveCommentResponse } from './comments.js';
|
|
5
5
|
export type { EntriesResponse, ListEntriesResponse, CollectionItem, ListEntriesParams, DeleteEntryResponse, } from './entries.js';
|