canopycms 0.0.67 → 0.0.68-int.100
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 +21 -14
- 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 +111 -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 +10 -1
- package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- 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 +14 -1
- 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 +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- 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/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 +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- 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 +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- 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 +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- 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 +8 -0
- package/dist/github-service.js +5 -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/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 +24 -9
- package/dist/server.js +22 -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-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/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 +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
|
@@ -9,6 +9,9 @@ var __export = (target, all) => {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
// dist/utils/logger.js
|
|
12
|
+
function canopyLog(...args) {
|
|
13
|
+
active.log(...args);
|
|
14
|
+
}
|
|
12
15
|
function canopyLogWarn(...args) {
|
|
13
16
|
active.warn(...args);
|
|
14
17
|
}
|
|
@@ -36,7 +39,7 @@ function isImplicitlyOffOrigin(url) {
|
|
|
36
39
|
return false;
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
|
-
function isHttpUrlOrSameOriginPath(value
|
|
42
|
+
function isHttpUrlOrSameOriginPath(value) {
|
|
40
43
|
if (value === "")
|
|
41
44
|
return false;
|
|
42
45
|
for (let index = 0; index < value.length; index++) {
|
|
@@ -60,9 +63,8 @@ function isHttpUrlOrSameOriginPath(value, opts = {}) {
|
|
|
60
63
|
return false;
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
|
-
if (isImplicitlyOffOrigin(value))
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
+
if (isImplicitlyOffOrigin(value))
|
|
67
|
+
return false;
|
|
66
68
|
return value.startsWith("/") && value[1] !== "/";
|
|
67
69
|
}
|
|
68
70
|
var SENTINEL_BASE, DOT_SEGMENT;
|
|
@@ -75,11 +77,11 @@ var init_sanitize_href = __esm({
|
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
// dist/paths/normalize.js
|
|
78
|
-
function normalizeFilesystemPath(
|
|
79
|
-
return
|
|
80
|
+
function normalizeFilesystemPath(path24) {
|
|
81
|
+
return path24.split(/[\\/]+/).filter(Boolean).join("/");
|
|
80
82
|
}
|
|
81
|
-
function hasTraversalSequence(
|
|
82
|
-
const normalized = normalizeFilesystemPath(
|
|
83
|
+
function hasTraversalSequence(path24) {
|
|
84
|
+
const normalized = normalizeFilesystemPath(path24);
|
|
83
85
|
return normalized.includes("..");
|
|
84
86
|
}
|
|
85
87
|
function createLogicalPath(...segments) {
|
|
@@ -89,14 +91,17 @@ function createLogicalPath(...segments) {
|
|
|
89
91
|
}
|
|
90
92
|
return normalized;
|
|
91
93
|
}
|
|
92
|
-
function
|
|
94
|
+
function entryLogicalPath(collectionLogicalPath, slug) {
|
|
95
|
+
return collectionLogicalPath ? `${collectionLogicalPath}/${slug}` : slug;
|
|
96
|
+
}
|
|
97
|
+
function trimSlashes(path24) {
|
|
93
98
|
let start = 0;
|
|
94
|
-
let end =
|
|
95
|
-
while (start < end &&
|
|
99
|
+
let end = path24.length;
|
|
100
|
+
while (start < end && path24[start] === "/")
|
|
96
101
|
start++;
|
|
97
|
-
while (end > start &&
|
|
102
|
+
while (end > start && path24[end - 1] === "/")
|
|
98
103
|
end--;
|
|
99
|
-
return
|
|
104
|
+
return path24.slice(start, end);
|
|
100
105
|
}
|
|
101
106
|
var init_normalize = __esm({
|
|
102
107
|
"dist/paths/normalize.js"() {
|
|
@@ -310,7 +315,8 @@ var init_field = __esm({
|
|
|
310
315
|
});
|
|
311
316
|
objectFieldSchema = fieldBaseSchema.extend({
|
|
312
317
|
type: z2.literal("object"),
|
|
313
|
-
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
318
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1),
|
|
319
|
+
itemTitleField: z2.string().min(1).optional()
|
|
314
320
|
});
|
|
315
321
|
inlineGroupFieldSchema = z2.object({
|
|
316
322
|
type: z2.literal("group"),
|
|
@@ -375,7 +381,7 @@ var init_collection = __esm({
|
|
|
375
381
|
|
|
376
382
|
// dist/config/schemas/url.js
|
|
377
383
|
import { z as z4 } from "zod";
|
|
378
|
-
var uploadTargetUrlSchema,
|
|
384
|
+
var uploadTargetUrlSchema, previewPrefixSchema;
|
|
379
385
|
var init_url = __esm({
|
|
380
386
|
"dist/config/schemas/url.js"() {
|
|
381
387
|
"use strict";
|
|
@@ -383,8 +389,8 @@ var init_url = __esm({
|
|
|
383
389
|
uploadTargetUrlSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
|
|
384
390
|
message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
|
|
385
391
|
});
|
|
386
|
-
|
|
387
|
-
message: 'must be an absolute http(s) URL
|
|
392
|
+
previewPrefixSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
|
|
393
|
+
message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
|
|
388
394
|
});
|
|
389
395
|
}
|
|
390
396
|
});
|
|
@@ -400,7 +406,6 @@ var init_media = __esm({
|
|
|
400
406
|
mediaSchema = z5.discriminatedUnion("adapter", [
|
|
401
407
|
z5.object({
|
|
402
408
|
adapter: z5.literal("local"),
|
|
403
|
-
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
404
409
|
/** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
|
|
405
410
|
directory: relativePathSchema.optional()
|
|
406
411
|
}).strict(),
|
|
@@ -408,7 +413,6 @@ var init_media = __esm({
|
|
|
408
413
|
adapter: z5.literal("s3"),
|
|
409
414
|
bucket: z5.string().min(1),
|
|
410
415
|
region: z5.string().min(1),
|
|
411
|
-
publicBaseUrl: assetMountUrlSchema.optional(),
|
|
412
416
|
/**
|
|
413
417
|
* Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
|
|
414
418
|
* site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
|
|
@@ -418,18 +422,17 @@ var init_media = __esm({
|
|
|
418
422
|
* signature is unaffected — a presigned POST's string-to-sign is the base64 policy
|
|
419
423
|
* alone, so the host never enters it (pinned by store-s3.test.ts).
|
|
420
424
|
*
|
|
421
|
-
* NOT a prefix
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
+
* NOT a prefix: nothing is joined onto this value; it replaces the POST target outright
|
|
426
|
+
* and is passed through byte-for-byte. Do not route it through `joinUrlPrefix` "for
|
|
427
|
+
* consistency" — that would strip the trailing slash and silently stop a CDN path
|
|
428
|
+
* pattern like `/asset-upload/*` from matching.
|
|
425
429
|
*/
|
|
426
430
|
uploadUrl: uploadTargetUrlSchema.optional(),
|
|
427
431
|
/** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
|
|
428
432
|
maxUploadBytes: z5.number().int().positive().optional()
|
|
429
433
|
}).strict(),
|
|
430
434
|
z5.object({
|
|
431
|
-
adapter: z5.literal("lfs")
|
|
432
|
-
publicBaseUrl: assetMountUrlSchema.optional()
|
|
435
|
+
adapter: z5.literal("lfs")
|
|
433
436
|
}).strict()
|
|
434
437
|
]);
|
|
435
438
|
}
|
|
@@ -443,6 +446,7 @@ var init_config = __esm({
|
|
|
443
446
|
"use strict";
|
|
444
447
|
init_collection();
|
|
445
448
|
init_media();
|
|
449
|
+
init_url();
|
|
446
450
|
defaultBranchAccessSchema = z6.enum(["allow", "deny"]).default("deny");
|
|
447
451
|
defaultPathAccessLevelSchema = z6.enum(["allow", "deny"]);
|
|
448
452
|
defaultPathAccessLevelsSchema = z6.object({
|
|
@@ -473,7 +477,8 @@ var init_config = __esm({
|
|
|
473
477
|
title: z6.string().optional(),
|
|
474
478
|
subtitle: z6.string().optional(),
|
|
475
479
|
theme: z6.unknown().optional(),
|
|
476
|
-
previewBase: z6.record(z6.string()).optional(),
|
|
480
|
+
previewBase: z6.record(z6.union([z6.string(), z6.literal(false)])).optional(),
|
|
481
|
+
previewPrefix: previewPrefixSchema.optional(),
|
|
477
482
|
// UI handler functions (runtime only, don't serialize)
|
|
478
483
|
onAccountClick: z6.function().returns(z6.void()).optional(),
|
|
479
484
|
onLogoutClick: z6.function().returns(z6.void()).optional(),
|
|
@@ -501,6 +506,9 @@ var init_config = __esm({
|
|
|
501
506
|
defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
|
|
502
507
|
gitBotAuthorName: gitBotAuthorNameSchema,
|
|
503
508
|
gitBotAuthorEmail: gitBotAuthorEmailSchema,
|
|
509
|
+
// Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
|
|
510
|
+
gitEditedByTrailers: z6.boolean().optional(),
|
|
511
|
+
gitCoAuthoredByTrailers: z6.boolean().optional(),
|
|
504
512
|
githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
|
|
505
513
|
// Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
|
|
506
514
|
// validation loudly rather than silently running header-trusting dev auth semantics.
|
|
@@ -512,7 +520,6 @@ var init_config = __esm({
|
|
|
512
520
|
// Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
|
|
513
521
|
allowNetworkRemoteInProd: z6.boolean().optional(),
|
|
514
522
|
settingsBranch: z6.string().optional(),
|
|
515
|
-
autoCreateSettingsPR: z6.boolean().optional(),
|
|
516
523
|
// `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
|
|
517
524
|
// `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
|
|
518
525
|
// env > config > modeDefault precedence chain `resolveDeploymentName`
|
|
@@ -528,7 +535,7 @@ var init_config = __esm({
|
|
|
528
535
|
validateEntry: z6.custom().optional(),
|
|
529
536
|
dev: devConfigSchema.optional()
|
|
530
537
|
}).strict();
|
|
531
|
-
DEFAULT_PROD_WORKSPACE = "/mnt/efs
|
|
538
|
+
DEFAULT_PROD_WORKSPACE = "/mnt/efs";
|
|
532
539
|
}
|
|
533
540
|
});
|
|
534
541
|
|
|
@@ -835,9 +842,6 @@ var init_client_unsafe_strategy = __esm({
|
|
|
835
842
|
throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
|
|
836
843
|
}
|
|
837
844
|
}
|
|
838
|
-
shouldCreateSettingsPR(config) {
|
|
839
|
-
return config.autoCreateSettingsPR ?? true;
|
|
840
|
-
}
|
|
841
845
|
};
|
|
842
846
|
DevStrategy = class extends DevClientSafeStrategy {
|
|
843
847
|
getWorkspaceRoot(sourceRoot) {
|
|
@@ -884,9 +888,6 @@ var init_client_unsafe_strategy = __esm({
|
|
|
884
888
|
}
|
|
885
889
|
validateConfig(_config) {
|
|
886
890
|
}
|
|
887
|
-
shouldCreateSettingsPR(_config) {
|
|
888
|
-
return false;
|
|
889
|
-
}
|
|
890
891
|
};
|
|
891
892
|
strategyCache = /* @__PURE__ */ new Map();
|
|
892
893
|
}
|
|
@@ -914,12 +915,12 @@ var init_operating_mode = __esm({
|
|
|
914
915
|
});
|
|
915
916
|
|
|
916
917
|
// dist/cli/generate-ai-content.js
|
|
917
|
-
import
|
|
918
|
+
import path23 from "node:path";
|
|
918
919
|
import { createJiti } from "jiti";
|
|
919
920
|
|
|
920
921
|
// dist/build/generate-ai-content.js
|
|
921
|
-
import
|
|
922
|
-
import
|
|
922
|
+
import fs20 from "node:fs/promises";
|
|
923
|
+
import path22 from "node:path";
|
|
923
924
|
|
|
924
925
|
// dist/utils/error.js
|
|
925
926
|
function getErrorMessage(err) {
|
|
@@ -931,6 +932,15 @@ function getErrorMessage(err) {
|
|
|
931
932
|
}
|
|
932
933
|
return String(err);
|
|
933
934
|
}
|
|
935
|
+
function redactCredentials(message) {
|
|
936
|
+
let result = message;
|
|
937
|
+
result = result.replace(/(:\/\/)[^/\s@]+@/g, "$1***@");
|
|
938
|
+
result = result.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}/g, "***");
|
|
939
|
+
result = result.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1***");
|
|
940
|
+
result = result.replace(/-----BEGIN [A-Z]{0,9} ?PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z]{0,9} ?PRIVATE KEY-----|$)/g, "<private-key>");
|
|
941
|
+
result = result.replace(/(?<![\w-])eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/g, "***");
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
934
944
|
function isNodeError(err) {
|
|
935
945
|
return err instanceof Error && "code" in err;
|
|
936
946
|
}
|
|
@@ -942,7 +952,7 @@ function isFileExistsError(err) {
|
|
|
942
952
|
}
|
|
943
953
|
|
|
944
954
|
// dist/content-store.js
|
|
945
|
-
import
|
|
955
|
+
import fs9 from "node:fs/promises";
|
|
946
956
|
import path11 from "node:path";
|
|
947
957
|
import matter2 from "gray-matter";
|
|
948
958
|
import { parse as yamlParse } from "yaml";
|
|
@@ -1193,12 +1203,15 @@ import fs2 from "node:fs/promises";
|
|
|
1193
1203
|
import path2 from "node:path";
|
|
1194
1204
|
import lockfile from "proper-lockfile";
|
|
1195
1205
|
init_logger();
|
|
1196
|
-
|
|
1206
|
+
var PROVISIONING_LOCK_STALE_MS = 9e4;
|
|
1207
|
+
function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
|
|
1197
1208
|
return {
|
|
1198
1209
|
lockfilePath: lockPath,
|
|
1199
1210
|
realpath: false,
|
|
1200
1211
|
retries,
|
|
1201
|
-
stale:
|
|
1212
|
+
stale: staleMs,
|
|
1213
|
+
// Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
|
|
1214
|
+
update: 15e3,
|
|
1202
1215
|
// proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
|
|
1203
1216
|
// an uncaught exception that kills the process. Call sites are told not to throw (see
|
|
1204
1217
|
// OnLockCompromised); this makes it structural rather than a convention, and also covers the
|
|
@@ -1234,37 +1247,68 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
|
|
|
1234
1247
|
}
|
|
1235
1248
|
};
|
|
1236
1249
|
}
|
|
1250
|
+
function branchProvisioningLockName(dirName) {
|
|
1251
|
+
return `.${dirName}.init.lock`;
|
|
1252
|
+
}
|
|
1237
1253
|
async function acquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
|
|
1238
1254
|
await fs2.mkdir(lockTargetDir2, { recursive: true });
|
|
1239
1255
|
const lockPath = path2.join(lockTargetDir2, lockName);
|
|
1240
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
|
|
1256
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
|
|
1241
1257
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
1242
1258
|
}
|
|
1243
|
-
async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
|
|
1244
|
-
|
|
1259
|
+
async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS, createParents = true) {
|
|
1260
|
+
if (createParents) {
|
|
1261
|
+
await fs2.mkdir(lockTargetDir2, { recursive: true });
|
|
1262
|
+
} else {
|
|
1263
|
+
await fs2.mkdir(lockTargetDir2).catch((err) => {
|
|
1264
|
+
if (!isNodeError(err) || err.code !== "EEXIST")
|
|
1265
|
+
throw err;
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1245
1268
|
const lockPath = path2.join(lockTargetDir2, lockName);
|
|
1246
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
|
|
1269
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
|
|
1247
1270
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
1248
1271
|
}
|
|
1272
|
+
var sleep = (ms) => new Promise((resolve2) => {
|
|
1273
|
+
setTimeout(resolve2, ms);
|
|
1274
|
+
});
|
|
1275
|
+
async function acquireProvisioningLockWithin(lockTargetDir2, lockName, waitMs, onCompromised) {
|
|
1276
|
+
const startedAt = Date.now();
|
|
1277
|
+
for (let attempt = 0; ; attempt++) {
|
|
1278
|
+
try {
|
|
1279
|
+
return await tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised);
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
if (!isNodeError(err) || err.code !== "ELOCKED")
|
|
1282
|
+
throw err;
|
|
1283
|
+
const remaining = waitMs - (Date.now() - startedAt);
|
|
1284
|
+
if (remaining <= 0)
|
|
1285
|
+
throw err;
|
|
1286
|
+
const base = Math.min(50 * 2 ** attempt, 500);
|
|
1287
|
+
await sleep(Math.min(base * (0.5 + Math.random()), remaining));
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1249
1291
|
|
|
1250
1292
|
// dist/utils/content-write-lock.js
|
|
1251
1293
|
var META_DIR = ".canopy-meta";
|
|
1252
1294
|
var CONTENT_WRITE_LOCK_NAME = "content-write.lock";
|
|
1253
1295
|
var DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2e3;
|
|
1254
1296
|
var ContentWriteLockBusyError = class extends Error {
|
|
1255
|
-
constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.") {
|
|
1297
|
+
constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.", outcome = "not-run") {
|
|
1256
1298
|
super(message);
|
|
1299
|
+
this.outcome = outcome;
|
|
1257
1300
|
this.name = "ContentWriteLockBusyError";
|
|
1258
1301
|
}
|
|
1259
1302
|
};
|
|
1260
1303
|
function lockTargetDir(branchRoot) {
|
|
1261
1304
|
return path3.join(path3.resolve(branchRoot), META_DIR);
|
|
1262
1305
|
}
|
|
1263
|
-
var
|
|
1306
|
+
var sleep2 = (ms) => new Promise((resolve2) => {
|
|
1264
1307
|
setTimeout(resolve2, ms);
|
|
1265
1308
|
});
|
|
1309
|
+
var CONTENT_WRITE_LOCK_STALE_MS = 3e4;
|
|
1266
1310
|
function tryAcquireContentWriteLock(branchRoot, onCompromised) {
|
|
1267
|
-
return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
|
|
1311
|
+
return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS, false);
|
|
1268
1312
|
}
|
|
1269
1313
|
async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, onCompromised) {
|
|
1270
1314
|
const startedAt = Date.now();
|
|
@@ -1278,7 +1322,7 @@ async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRIT
|
|
|
1278
1322
|
if (remaining <= 0)
|
|
1279
1323
|
throw new ContentWriteLockBusyError();
|
|
1280
1324
|
const base = Math.min(40 * 2 ** attempt, 250);
|
|
1281
|
-
await
|
|
1325
|
+
await sleep2(Math.min(base * (0.5 + Math.random()), remaining));
|
|
1282
1326
|
}
|
|
1283
1327
|
}
|
|
1284
1328
|
}
|
|
@@ -1295,7 +1339,7 @@ async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRI
|
|
|
1295
1339
|
await release();
|
|
1296
1340
|
}
|
|
1297
1341
|
if (compromised) {
|
|
1298
|
-
throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.");
|
|
1342
|
+
throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.", "unknown");
|
|
1299
1343
|
}
|
|
1300
1344
|
return result;
|
|
1301
1345
|
}
|
|
@@ -1345,8 +1389,21 @@ function buildResolvedReference(data, meta, body) {
|
|
|
1345
1389
|
resolved.slug = meta.slug;
|
|
1346
1390
|
resolved.collection = meta.collection;
|
|
1347
1391
|
resolved.urlPath = meta.urlPath;
|
|
1392
|
+
delete resolved[RESTRICTED_REFERENCE_MARKER];
|
|
1348
1393
|
return resolved;
|
|
1349
1394
|
}
|
|
1395
|
+
var RESTRICTED_REFERENCE_MARKER = "unavailable";
|
|
1396
|
+
function buildRestrictedReference(meta, title) {
|
|
1397
|
+
return {
|
|
1398
|
+
id: meta.id,
|
|
1399
|
+
slug: meta.slug,
|
|
1400
|
+
collection: meta.collection,
|
|
1401
|
+
urlPath: meta.urlPath,
|
|
1402
|
+
title,
|
|
1403
|
+
unavailable: true,
|
|
1404
|
+
reason: "restricted"
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1350
1407
|
var SEO_GROUP_FIELDS = [
|
|
1351
1408
|
{
|
|
1352
1409
|
name: DEFAULT_SEO_FIELD_NAMES.title,
|
|
@@ -1399,6 +1456,50 @@ var SEO_GROUP_FIELDS = [
|
|
|
1399
1456
|
}
|
|
1400
1457
|
];
|
|
1401
1458
|
|
|
1459
|
+
// dist/utils/title-field.js
|
|
1460
|
+
function isGroupField(field) {
|
|
1461
|
+
return field.type === "group";
|
|
1462
|
+
}
|
|
1463
|
+
function extractTitleFromSchema(fields, data) {
|
|
1464
|
+
return findTitleValue(fields, data);
|
|
1465
|
+
}
|
|
1466
|
+
function findTitleValue(fields, data) {
|
|
1467
|
+
for (const field of fields) {
|
|
1468
|
+
if (isGroupField(field)) {
|
|
1469
|
+
const result = findTitleValue(field.fields, data);
|
|
1470
|
+
if (result !== void 0)
|
|
1471
|
+
return result;
|
|
1472
|
+
} else if ("isTitle" in field && field.isTitle) {
|
|
1473
|
+
const value = data[field.name];
|
|
1474
|
+
return typeof value === "string" ? value : void 0;
|
|
1475
|
+
} else if (field.type === "object" && "fields" in field && field.fields && !field.list) {
|
|
1476
|
+
const nested = data[field.name];
|
|
1477
|
+
if (nested != null && typeof nested === "object" && !Array.isArray(nested)) {
|
|
1478
|
+
const result = findTitleValue(field.fields, nested);
|
|
1479
|
+
if (result !== void 0)
|
|
1480
|
+
return result;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return void 0;
|
|
1485
|
+
}
|
|
1486
|
+
function humanizeSlug(slug) {
|
|
1487
|
+
return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
|
|
1488
|
+
}
|
|
1489
|
+
function resolveEntryTitle(data, options) {
|
|
1490
|
+
if (options?.schema) {
|
|
1491
|
+
const schemaTitle = extractTitleFromSchema(options.schema, data);
|
|
1492
|
+
if (schemaTitle)
|
|
1493
|
+
return schemaTitle;
|
|
1494
|
+
}
|
|
1495
|
+
const title = data.title ?? data.name;
|
|
1496
|
+
if (typeof title === "string")
|
|
1497
|
+
return title;
|
|
1498
|
+
if (options?.entryTypeLabel)
|
|
1499
|
+
return options.entryTypeLabel;
|
|
1500
|
+
return options?.slug ? humanizeSlug(options.slug) : "Untitled";
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1402
1503
|
// dist/utils/entry-url.js
|
|
1403
1504
|
init_normalize();
|
|
1404
1505
|
function isIndexSlug(slug) {
|
|
@@ -1416,8 +1517,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
|
|
|
1416
1517
|
if (slug && !isIndexSlug(slug)) {
|
|
1417
1518
|
segments.push(slug);
|
|
1418
1519
|
}
|
|
1419
|
-
const
|
|
1420
|
-
return
|
|
1520
|
+
const path24 = segments.length > 0 ? `/${segments.join("/")}` : "/";
|
|
1521
|
+
return path24.toLowerCase();
|
|
1421
1522
|
}
|
|
1422
1523
|
|
|
1423
1524
|
// dist/url-collision.js
|
|
@@ -1790,13 +1891,13 @@ function extractIdFromFilename(filename) {
|
|
|
1790
1891
|
return null;
|
|
1791
1892
|
}
|
|
1792
1893
|
async function resolveCollectionPath(root, logicalPath) {
|
|
1793
|
-
const
|
|
1794
|
-
const
|
|
1894
|
+
const fs21 = await import("node:fs/promises");
|
|
1895
|
+
const path24 = await import("node:path");
|
|
1795
1896
|
const segments = logicalPath.split("/").filter(Boolean);
|
|
1796
1897
|
let currentPath = root;
|
|
1797
1898
|
for (const segment of segments) {
|
|
1798
1899
|
try {
|
|
1799
|
-
const entries = await
|
|
1900
|
+
const entries = await fs21.readdir(currentPath, { withFileTypes: true });
|
|
1800
1901
|
const matchingDir = entries.find((entry) => {
|
|
1801
1902
|
if (!entry.isDirectory())
|
|
1802
1903
|
return false;
|
|
@@ -1804,7 +1905,7 @@ async function resolveCollectionPath(root, logicalPath) {
|
|
|
1804
1905
|
return logicalName === segment.toLowerCase();
|
|
1805
1906
|
});
|
|
1806
1907
|
if (matchingDir) {
|
|
1807
|
-
currentPath =
|
|
1908
|
+
currentPath = path24.join(currentPath, matchingDir.name);
|
|
1808
1909
|
} else {
|
|
1809
1910
|
return null;
|
|
1810
1911
|
}
|
|
@@ -2048,6 +2149,10 @@ var DebugLogger = class {
|
|
|
2048
2149
|
throw new Error(errorMsg);
|
|
2049
2150
|
}
|
|
2050
2151
|
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Keyed by label alone, so two overlapping timers with one label overwrite each other;
|
|
2154
|
+
* anything that can run concurrently uses {@link timed}.
|
|
2155
|
+
*/
|
|
2051
2156
|
time(label) {
|
|
2052
2157
|
this.timers.set(label, Date.now());
|
|
2053
2158
|
}
|
|
@@ -2062,12 +2167,16 @@ var DebugLogger = class {
|
|
|
2062
2167
|
this.debug(category, `${label} completed`, { durationMs: duration });
|
|
2063
2168
|
return duration;
|
|
2064
2169
|
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
|
|
2172
|
+
* call, so concurrent spans with the same label on one logger never overwrite each other.
|
|
2173
|
+
*/
|
|
2065
2174
|
async timed(category, label, fn) {
|
|
2066
|
-
|
|
2175
|
+
const start = Date.now();
|
|
2067
2176
|
try {
|
|
2068
2177
|
return await fn();
|
|
2069
2178
|
} finally {
|
|
2070
|
-
this.
|
|
2179
|
+
this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
|
|
2071
2180
|
}
|
|
2072
2181
|
}
|
|
2073
2182
|
};
|
|
@@ -2393,9 +2502,80 @@ var STATIC_DEPLOY_USER = Object.freeze({
|
|
|
2393
2502
|
name: "Static Deploy"
|
|
2394
2503
|
});
|
|
2395
2504
|
|
|
2505
|
+
// dist/utils/request-timing.js
|
|
2506
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2507
|
+
var storage = new AsyncLocalStorage();
|
|
2508
|
+
var log2 = createDebugLogger({ prefix: "CanopyCMS" });
|
|
2509
|
+
async function timeRequestPhase(phase, fn) {
|
|
2510
|
+
const parent = storage.getStore();
|
|
2511
|
+
if (!parent)
|
|
2512
|
+
return fn();
|
|
2513
|
+
const key = parent.path ? `${parent.path}>${phase}` : phase;
|
|
2514
|
+
let total = parent.phases.get(key);
|
|
2515
|
+
if (!total) {
|
|
2516
|
+
total = { count: 0, ms: 0 };
|
|
2517
|
+
parent.phases.set(key, total);
|
|
2518
|
+
}
|
|
2519
|
+
const start = performance.now();
|
|
2520
|
+
try {
|
|
2521
|
+
return await storage.run({ ...parent, path: key }, fn);
|
|
2522
|
+
} finally {
|
|
2523
|
+
total.count += 1;
|
|
2524
|
+
total.ms += performance.now() - start;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// dist/utils/git.js
|
|
2529
|
+
import { simpleGit } from "simple-git";
|
|
2530
|
+
var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
|
|
2531
|
+
var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
|
|
2532
|
+
var LEADING_OPTION_PATTERN = /^-/;
|
|
2533
|
+
var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
|
|
2534
|
+
var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
|
|
2535
|
+
function isNetworkRemoteUrl(url) {
|
|
2536
|
+
if (/^file:\/\//i.test(url))
|
|
2537
|
+
return false;
|
|
2538
|
+
if (NETWORK_SCHEME_PATTERN.test(url))
|
|
2539
|
+
return true;
|
|
2540
|
+
if (TRANSPORT_HELPER_PATTERN.test(url))
|
|
2541
|
+
return true;
|
|
2542
|
+
if (LEADING_OPTION_PATTERN.test(url))
|
|
2543
|
+
return true;
|
|
2544
|
+
if (WINDOWS_DRIVE_PATTERN.test(url))
|
|
2545
|
+
return false;
|
|
2546
|
+
return COLON_BEFORE_SLASH_PATTERN.test(url);
|
|
2547
|
+
}
|
|
2548
|
+
var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
|
|
2549
|
+
function isMissingRemoteRefFailure(message) {
|
|
2550
|
+
return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
|
|
2551
|
+
}
|
|
2552
|
+
var CANOPY_META_DIR = ".canopy-meta";
|
|
2553
|
+
async function stageAllExceptCanopyState(git) {
|
|
2554
|
+
await git.raw(["add", "-A", "--sparse"]);
|
|
2555
|
+
await git.raw(["reset", "-q", "--", CANOPY_META_DIR]);
|
|
2556
|
+
}
|
|
2557
|
+
async function detectHeadBranch(repoRoot, fallback = "main") {
|
|
2558
|
+
try {
|
|
2559
|
+
const git = simpleGit({ baseDir: repoRoot });
|
|
2560
|
+
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
2561
|
+
return head && head !== "HEAD" ? head : fallback;
|
|
2562
|
+
} catch {
|
|
2563
|
+
return fallback;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
async function resolveBaseBranch(options) {
|
|
2567
|
+
if (options.defaultBaseBranch)
|
|
2568
|
+
return options.defaultBaseBranch;
|
|
2569
|
+
if (options.mode === "dev") {
|
|
2570
|
+
return detectHeadBranch(options.detectFrom ?? process.cwd());
|
|
2571
|
+
}
|
|
2572
|
+
return "main";
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2396
2575
|
// dist/branch-schema-cache.js
|
|
2397
2576
|
var SCHEMA_CACHE_VERSION = 3;
|
|
2398
2577
|
var MTIME_CHECK_DEBOUNCE_MS = 1e3;
|
|
2578
|
+
var SCHEMA_CACHE_FILE = "schema-cache.json";
|
|
2399
2579
|
var SCHEMA_GENERATION_RESOURCE = "schema";
|
|
2400
2580
|
async function isStaleByMtime(dir, cachedAt) {
|
|
2401
2581
|
let entries;
|
|
@@ -2418,6 +2598,11 @@ async function isStaleByMtime(dir, cachedAt) {
|
|
|
2418
2598
|
}
|
|
2419
2599
|
return false;
|
|
2420
2600
|
}
|
|
2601
|
+
async function schemaCacheDir(branchRoot) {
|
|
2602
|
+
const gitDir = path8.join(branchRoot, ".git");
|
|
2603
|
+
const isClone = await fs7.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
|
|
2604
|
+
return isClone ? path8.join(gitDir, "canopycms") : path8.join(branchRoot, CANOPY_META_DIR);
|
|
2605
|
+
}
|
|
2421
2606
|
var BranchSchemaCache = class {
|
|
2422
2607
|
constructor(mode = "prod") {
|
|
2423
2608
|
this.lastMtimeCheck = /* @__PURE__ */ new Map();
|
|
@@ -2426,7 +2611,7 @@ var BranchSchemaCache = class {
|
|
|
2426
2611
|
/**
|
|
2427
2612
|
* Whether to skip the on-disk cache for this branchRoot.
|
|
2428
2613
|
*
|
|
2429
|
-
* Never write
|
|
2614
|
+
* Never write a schema cache at the project root, whichever entrypoint
|
|
2430
2615
|
* produced the cwd branchRoot. branchRoot equals process.cwd() only in the
|
|
2431
2616
|
* synthetic contexts static deployments and build phases use; a real branch
|
|
2432
2617
|
* root is always nested under the workspace.
|
|
@@ -2436,7 +2621,7 @@ var BranchSchemaCache = class {
|
|
|
2436
2621
|
}
|
|
2437
2622
|
/** Get schema for a branch, from the cache when fresh, else resolved fresh. */
|
|
2438
2623
|
async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
|
|
2439
|
-
return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
|
|
2624
|
+
return timeRequestPhase("schema", () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
|
|
2440
2625
|
}
|
|
2441
2626
|
/**
|
|
2442
2627
|
* Resolve the schema from disk. Protected rather than a direct resolveSchema
|
|
@@ -2450,8 +2635,7 @@ var BranchSchemaCache = class {
|
|
|
2450
2635
|
const contentRoot = path8.join(branchRoot, contentRootName);
|
|
2451
2636
|
const skipDiskCache = this.skipDiskCache(branchRoot);
|
|
2452
2637
|
if (!skipDiskCache) {
|
|
2453
|
-
const
|
|
2454
|
-
const cachePath = path8.join(cacheDir, "schema-cache.json");
|
|
2638
|
+
const cachePath = path8.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
|
|
2455
2639
|
let cacheData = null;
|
|
2456
2640
|
try {
|
|
2457
2641
|
const cacheContent = await fs7.readFile(cachePath, "utf-8");
|
|
@@ -2493,7 +2677,7 @@ var BranchSchemaCache = class {
|
|
|
2493
2677
|
const { skipDiskCache } = options;
|
|
2494
2678
|
const contentRoot = path8.join(branchRoot, contentRootName);
|
|
2495
2679
|
const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
2496
|
-
const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
|
|
2680
|
+
const result = await timeRequestPhase("resolve", () => this.resolveFresh(contentRoot, entrySchemaRegistry));
|
|
2497
2681
|
if (!isValidSchema(result.schema)) {
|
|
2498
2682
|
throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
|
|
2499
2683
|
}
|
|
@@ -2504,9 +2688,9 @@ var BranchSchemaCache = class {
|
|
|
2504
2688
|
}
|
|
2505
2689
|
const flatSchema = flattenSchema(result.schema, contentRootName);
|
|
2506
2690
|
if (!skipDiskCache) {
|
|
2507
|
-
const cacheDir =
|
|
2508
|
-
const cachePath = path8.join(cacheDir,
|
|
2509
|
-
await fs7.unlink(path8.join(
|
|
2691
|
+
const cacheDir = await schemaCacheDir(branchRoot);
|
|
2692
|
+
const cachePath = path8.join(cacheDir, SCHEMA_CACHE_FILE);
|
|
2693
|
+
await fs7.unlink(path8.join(branchRoot, CANOPY_META_DIR, "schema-cache.stale")).catch(() => {
|
|
2510
2694
|
});
|
|
2511
2695
|
if (read && read.ok) {
|
|
2512
2696
|
const newCache = {
|
|
@@ -2634,7 +2818,6 @@ function validateAndNormalizePath(root, target) {
|
|
|
2634
2818
|
|
|
2635
2819
|
// dist/paths/branch.js
|
|
2636
2820
|
init_operating_mode();
|
|
2637
|
-
import fs9 from "node:fs/promises";
|
|
2638
2821
|
import path10 from "node:path";
|
|
2639
2822
|
|
|
2640
2823
|
// dist/paths/branch-name.js
|
|
@@ -2644,6 +2827,11 @@ function sanitizeBranchName(branchName) {
|
|
|
2644
2827
|
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
2645
2828
|
return trimmedDots || "branch";
|
|
2646
2829
|
}
|
|
2830
|
+
var RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
|
|
2831
|
+
function isSettingsBranchName(branchName, settingsBranchName) {
|
|
2832
|
+
const sanitized = sanitizeBranchName(branchName);
|
|
2833
|
+
return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
|
|
2834
|
+
}
|
|
2647
2835
|
|
|
2648
2836
|
// dist/paths/branch.js
|
|
2649
2837
|
var BranchPathError = class extends Error {
|
|
@@ -2655,6 +2843,9 @@ function resolveBranchPath(options) {
|
|
|
2655
2843
|
if (options.branchName.includes("..")) {
|
|
2656
2844
|
throw new BranchPathError("Branch name cannot contain traversal segments");
|
|
2657
2845
|
}
|
|
2846
|
+
if (isSettingsBranchName(options.branchName)) {
|
|
2847
|
+
throw new BranchPathError("Settings branches are not content branches");
|
|
2848
|
+
}
|
|
2658
2849
|
const safeBranch = sanitizeBranchName(options.branchName);
|
|
2659
2850
|
const strategy = operatingStrategy(options.mode);
|
|
2660
2851
|
const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
|
|
@@ -2670,11 +2861,6 @@ function resolveBranchPath(options) {
|
|
|
2670
2861
|
}
|
|
2671
2862
|
return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
|
|
2672
2863
|
}
|
|
2673
|
-
async function ensureBranchRoot(options) {
|
|
2674
|
-
const result = resolveBranchPath(options);
|
|
2675
|
-
await fs9.mkdir(result.branchRoot, { recursive: true });
|
|
2676
|
-
return result;
|
|
2677
|
-
}
|
|
2678
2864
|
|
|
2679
2865
|
// dist/content-store.js
|
|
2680
2866
|
async function withLocks(keys, fn) {
|
|
@@ -2884,7 +3070,7 @@ var ContentStore = class {
|
|
|
2884
3070
|
async findEntryPathsById(dir, id) {
|
|
2885
3071
|
let entries;
|
|
2886
3072
|
try {
|
|
2887
|
-
entries = await
|
|
3073
|
+
entries = await fs9.readdir(dir, { withFileTypes: true });
|
|
2888
3074
|
} catch (err) {
|
|
2889
3075
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
2890
3076
|
return [];
|
|
@@ -2903,11 +3089,11 @@ var ContentStore = class {
|
|
|
2903
3089
|
getSchemaItems() {
|
|
2904
3090
|
return this.schemaIndex.values();
|
|
2905
3091
|
}
|
|
2906
|
-
assertSchemaItem(
|
|
2907
|
-
const normalized = normalizeFilesystemPath(
|
|
3092
|
+
assertSchemaItem(path24) {
|
|
3093
|
+
const normalized = normalizeFilesystemPath(path24);
|
|
2908
3094
|
const item = this.schemaIndex.get(normalized);
|
|
2909
3095
|
if (!item) {
|
|
2910
|
-
throw new ContentStoreError(`Unknown schema item: ${
|
|
3096
|
+
throw new ContentStoreError(`Unknown schema item: ${path24}`, "NO_SCHEMA_ITEM");
|
|
2911
3097
|
}
|
|
2912
3098
|
return item;
|
|
2913
3099
|
}
|
|
@@ -3082,7 +3268,7 @@ var ContentStore = class {
|
|
|
3082
3268
|
let existingEntryType;
|
|
3083
3269
|
let foundExisting = false;
|
|
3084
3270
|
if (!id) {
|
|
3085
|
-
const entries = await
|
|
3271
|
+
const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
3086
3272
|
const existingFile = entries.find((entry) => {
|
|
3087
3273
|
if (entry.isDirectory())
|
|
3088
3274
|
return false;
|
|
@@ -3116,6 +3302,7 @@ var ContentStore = class {
|
|
|
3116
3302
|
return {
|
|
3117
3303
|
absolutePath: resolved,
|
|
3118
3304
|
relativePath: path11.relative(this.root, resolved),
|
|
3305
|
+
logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
|
|
3119
3306
|
id,
|
|
3120
3307
|
entryTypeName: finalEntryTypeName,
|
|
3121
3308
|
existed
|
|
@@ -3145,11 +3332,15 @@ var ContentStore = class {
|
|
|
3145
3332
|
const schemaItem = this.assertSchemaItem(schemaPath);
|
|
3146
3333
|
return await this.buildPaths(schemaItem, slug);
|
|
3147
3334
|
}
|
|
3335
|
+
/**
|
|
3336
|
+
* Read one entry. References resolve unless `resolveReferences: false`; a target that
|
|
3337
|
+
* `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
|
|
3338
|
+
*/
|
|
3148
3339
|
async read(collectionPath, slug = "", options = {}) {
|
|
3149
3340
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
3150
3341
|
const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
3151
|
-
const stat = await
|
|
3152
|
-
const raw = await
|
|
3342
|
+
const stat = await fs9.stat(absolutePath);
|
|
3343
|
+
const raw = await fs9.readFile(absolutePath, "utf8");
|
|
3153
3344
|
let doc;
|
|
3154
3345
|
let format;
|
|
3155
3346
|
let fields;
|
|
@@ -3206,7 +3397,7 @@ var ContentStore = class {
|
|
|
3206
3397
|
};
|
|
3207
3398
|
}
|
|
3208
3399
|
if (options.resolveReferences !== false) {
|
|
3209
|
-
doc.data = await this.resolveReferencesInData(doc.data, fields);
|
|
3400
|
+
doc.data = await this.resolveReferencesInData(doc.data, fields, void 0, options.referenceAccess);
|
|
3210
3401
|
}
|
|
3211
3402
|
doc.version = stat.mtimeMs;
|
|
3212
3403
|
return doc;
|
|
@@ -3275,10 +3466,10 @@ var ContentStore = class {
|
|
|
3275
3466
|
}
|
|
3276
3467
|
await this.assertUrlPathAvailable(path11.dirname(absolutePath), chosenSlug);
|
|
3277
3468
|
}
|
|
3278
|
-
await
|
|
3469
|
+
await fs9.mkdir(path11.dirname(absolutePath), { recursive: true });
|
|
3279
3470
|
if (input.expectedVersion !== void 0) {
|
|
3280
3471
|
try {
|
|
3281
|
-
const existing = await
|
|
3472
|
+
const existing = await fs9.stat(absolutePath);
|
|
3282
3473
|
if (input.expectedVersion === null) {
|
|
3283
3474
|
throw new ContentConflictError("An entry with this slug already exists");
|
|
3284
3475
|
}
|
|
@@ -3335,13 +3526,13 @@ var ContentStore = class {
|
|
|
3335
3526
|
}
|
|
3336
3527
|
}
|
|
3337
3528
|
if (staleOldAbsPath) {
|
|
3338
|
-
await
|
|
3529
|
+
await fs9.unlink(staleOldAbsPath).catch((err) => {
|
|
3339
3530
|
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
3340
3531
|
throw err;
|
|
3341
3532
|
});
|
|
3342
3533
|
}
|
|
3343
3534
|
await this.recordOwnMutation(liveIndex);
|
|
3344
|
-
const afterStat = await
|
|
3535
|
+
const afterStat = await fs9.stat(absolutePath);
|
|
3345
3536
|
const base = {
|
|
3346
3537
|
collection: schemaItem.logicalPath,
|
|
3347
3538
|
collectionName: schemaItem.name,
|
|
@@ -3418,7 +3609,7 @@ var ContentStore = class {
|
|
|
3418
3609
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
3419
3610
|
const { absolutePath } = await this.buildPaths(schemaItem, slug);
|
|
3420
3611
|
try {
|
|
3421
|
-
await
|
|
3612
|
+
await fs9.access(absolutePath);
|
|
3422
3613
|
return true;
|
|
3423
3614
|
} catch (err) {
|
|
3424
3615
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
@@ -3453,7 +3644,7 @@ var ContentStore = class {
|
|
|
3453
3644
|
const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
|
|
3454
3645
|
if (!collectionRoot)
|
|
3455
3646
|
return 0;
|
|
3456
|
-
const entries = await
|
|
3647
|
+
const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
3457
3648
|
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
3458
3649
|
}
|
|
3459
3650
|
/**
|
|
@@ -3471,7 +3662,7 @@ var ContentStore = class {
|
|
|
3471
3662
|
const collection = this.assertCollection(collectionPath);
|
|
3472
3663
|
const prePass = await this.buildPaths(collection, slug);
|
|
3473
3664
|
if (!prePass.existed) {
|
|
3474
|
-
await
|
|
3665
|
+
await fs9.unlink(prePass.absolutePath);
|
|
3475
3666
|
return;
|
|
3476
3667
|
}
|
|
3477
3668
|
let lockKey = this.entryLockKey(collection, slug, prePass);
|
|
@@ -3487,7 +3678,7 @@ var ContentStore = class {
|
|
|
3487
3678
|
return RETRY_KEY;
|
|
3488
3679
|
}
|
|
3489
3680
|
const { absolutePath, relativePath } = inLock;
|
|
3490
|
-
await
|
|
3681
|
+
await fs9.unlink(absolutePath);
|
|
3491
3682
|
const liveIndex = this._idIndex;
|
|
3492
3683
|
const id = liveIndex.findByPath(relativePath);
|
|
3493
3684
|
if (id) {
|
|
@@ -3559,7 +3750,7 @@ var ContentStore = class {
|
|
|
3559
3750
|
const parentDir = path11.dirname(currentPath);
|
|
3560
3751
|
const newPath = path11.join(parentDir, newFilename);
|
|
3561
3752
|
try {
|
|
3562
|
-
const entries = await
|
|
3753
|
+
const entries = await fs9.readdir(parentDir, { withFileTypes: true });
|
|
3563
3754
|
for (const entry of entries) {
|
|
3564
3755
|
if (entry.isDirectory())
|
|
3565
3756
|
continue;
|
|
@@ -3574,14 +3765,14 @@ var ContentStore = class {
|
|
|
3574
3765
|
}
|
|
3575
3766
|
await this.assertUrlPathAvailable(parentDir, safeNewSlug);
|
|
3576
3767
|
try {
|
|
3577
|
-
await
|
|
3768
|
+
await fs9.link(currentPath, newPath);
|
|
3578
3769
|
} catch (err) {
|
|
3579
3770
|
if (isNodeError(err) && err.code === "EEXIST") {
|
|
3580
3771
|
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
3581
3772
|
}
|
|
3582
3773
|
throw err;
|
|
3583
3774
|
}
|
|
3584
|
-
await
|
|
3775
|
+
await fs9.unlink(currentPath);
|
|
3585
3776
|
const newRelativePath = path11.relative(this.root, newPath);
|
|
3586
3777
|
const liveIndex = this._idIndex;
|
|
3587
3778
|
const entryId = liveIndex.findByPath(currentRelPath);
|
|
@@ -3660,20 +3851,25 @@ var ContentStore = class {
|
|
|
3660
3851
|
* a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
|
|
3661
3852
|
* unmemoized behavior.
|
|
3662
3853
|
*
|
|
3663
|
-
*
|
|
3664
|
-
* `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
|
|
3665
|
-
* may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
|
|
3666
|
-
* matched here on purpose so one rule covers both.
|
|
3854
|
+
* `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
|
|
3667
3855
|
*/
|
|
3668
|
-
async resolveReferences(data, fields, cache) {
|
|
3669
|
-
return this.resolveReferencesInData(data, fields, cache);
|
|
3856
|
+
async resolveReferences(data, fields, cache, access) {
|
|
3857
|
+
return this.resolveReferencesInData(data, fields, cache, access);
|
|
3670
3858
|
}
|
|
3671
|
-
|
|
3859
|
+
/**
|
|
3860
|
+
* Resolve one reference id the way a reference field resolves it, for the editor's
|
|
3861
|
+
* live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
|
|
3862
|
+
* when the id names no entry. No body is embedded, since there is no field to ask for one.
|
|
3863
|
+
*/
|
|
3864
|
+
async resolveReferenceTarget(id, access) {
|
|
3865
|
+
return this.resolveSingleReference(id, await this.idIndex(), false, void 0, access);
|
|
3866
|
+
}
|
|
3867
|
+
async resolveReferencesInData(data, fields, cache, access) {
|
|
3672
3868
|
const resolved = { ...data };
|
|
3673
3869
|
const idIndex = await this.idIndex();
|
|
3674
3870
|
for (const field of fields) {
|
|
3675
3871
|
if (field.type === "group") {
|
|
3676
|
-
const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache);
|
|
3872
|
+
const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache, access);
|
|
3677
3873
|
Object.assign(resolved, groupResolved);
|
|
3678
3874
|
continue;
|
|
3679
3875
|
}
|
|
@@ -3681,18 +3877,18 @@ var ContentStore = class {
|
|
|
3681
3877
|
if (field.type === "reference") {
|
|
3682
3878
|
const includeBody = field.includeBody === true;
|
|
3683
3879
|
if (typeof value === "string" && value) {
|
|
3684
|
-
resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
|
|
3880
|
+
resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache, access);
|
|
3685
3881
|
} else if (field.list && Array.isArray(value)) {
|
|
3686
|
-
resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache) : null));
|
|
3882
|
+
resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache, access) : null));
|
|
3687
3883
|
}
|
|
3688
3884
|
} else if (field.type === "object" && value) {
|
|
3689
3885
|
const objectField = field;
|
|
3690
3886
|
if (!objectField.fields)
|
|
3691
3887
|
continue;
|
|
3692
3888
|
if (objectField.list && Array.isArray(value)) {
|
|
3693
|
-
resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache) : item));
|
|
3889
|
+
resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache, access) : item));
|
|
3694
3890
|
} else if (typeof value === "object") {
|
|
3695
|
-
resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
|
|
3891
|
+
resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache, access);
|
|
3696
3892
|
}
|
|
3697
3893
|
} else if (field.type === "block" && Array.isArray(value)) {
|
|
3698
3894
|
const blockField = field;
|
|
@@ -3705,7 +3901,7 @@ var ContentStore = class {
|
|
|
3705
3901
|
return block;
|
|
3706
3902
|
return {
|
|
3707
3903
|
...b,
|
|
3708
|
-
value: await this.resolveReferencesInData(b.value, template.fields, cache)
|
|
3904
|
+
value: await this.resolveReferencesInData(b.value, template.fields, cache, access)
|
|
3709
3905
|
};
|
|
3710
3906
|
}));
|
|
3711
3907
|
}
|
|
@@ -3736,13 +3932,13 @@ var ContentStore = class {
|
|
|
3736
3932
|
* `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
|
|
3737
3933
|
* cloned once per referencing entry.
|
|
3738
3934
|
*/
|
|
3739
|
-
resolveSingleReference(id, idIndex, includeBody, cache) {
|
|
3935
|
+
resolveSingleReference(id, idIndex, includeBody, cache, access) {
|
|
3740
3936
|
if (!cache)
|
|
3741
|
-
return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
|
|
3937
|
+
return this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
|
|
3742
3938
|
const key = includeBody ? `${id}:body` : id;
|
|
3743
3939
|
let pending = cache.get(key);
|
|
3744
3940
|
if (!pending) {
|
|
3745
|
-
pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
|
|
3941
|
+
pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
|
|
3746
3942
|
cache.set(key, pending);
|
|
3747
3943
|
}
|
|
3748
3944
|
return pending.then((resolved) => resolved === null ? null : structuredClone(resolved));
|
|
@@ -3760,15 +3956,27 @@ var ContentStore = class {
|
|
|
3760
3956
|
* A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
|
|
3761
3957
|
* still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
|
|
3762
3958
|
*/
|
|
3763
|
-
async resolveSingleReferenceUncached(id, idIndex, includeBody) {
|
|
3764
|
-
const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
|
|
3959
|
+
async resolveSingleReferenceUncached(id, idIndex, includeBody, access) {
|
|
3960
|
+
const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody, access);
|
|
3765
3961
|
if (first !== STALE_LOOKUP)
|
|
3766
3962
|
return first;
|
|
3767
3963
|
await this.refreshIndexForSuspiciousLookup();
|
|
3768
|
-
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
|
|
3964
|
+
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody, access);
|
|
3769
3965
|
return second === STALE_LOOKUP ? null : second;
|
|
3770
3966
|
}
|
|
3771
|
-
|
|
3967
|
+
/**
|
|
3968
|
+
* The entry type an entry file was written as, by its filename's type token, falling back to
|
|
3969
|
+
* the collection's default the way `read()` does for a legacy untyped file.
|
|
3970
|
+
*/
|
|
3971
|
+
entryTypeOf(collectionPath, relativePath) {
|
|
3972
|
+
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
3973
|
+
if (schemaItem.type === "entry-type")
|
|
3974
|
+
return schemaItem;
|
|
3975
|
+
const entries = schemaItem.entries;
|
|
3976
|
+
const typeName = extractEntryTypeFromFilename(path11.basename(relativePath));
|
|
3977
|
+
return entries?.find((e) => e.name === typeName) ?? getDefaultEntryType(schemaItem.entries);
|
|
3978
|
+
}
|
|
3979
|
+
async resolveSingleReferenceOnce(id, idIndex, includeBody, access) {
|
|
3772
3980
|
try {
|
|
3773
3981
|
const location = idIndex.findById(id);
|
|
3774
3982
|
if (!location)
|
|
@@ -3779,13 +3987,22 @@ var ContentStore = class {
|
|
|
3779
3987
|
const doc = await this.read(location.collection, location.slug, {
|
|
3780
3988
|
resolveReferences: false
|
|
3781
3989
|
});
|
|
3782
|
-
const
|
|
3783
|
-
return buildResolvedReference(doc.data, {
|
|
3990
|
+
const meta = {
|
|
3784
3991
|
id,
|
|
3785
3992
|
slug: location.slug,
|
|
3786
3993
|
collection: location.collection,
|
|
3787
3994
|
urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName)
|
|
3788
|
-
}
|
|
3995
|
+
};
|
|
3996
|
+
if (access && !access(entryLogicalPath(location.collection, location.slug))) {
|
|
3997
|
+
const entryType = this.entryTypeOf(location.collection, location.relativePath);
|
|
3998
|
+
return buildRestrictedReference(meta, resolveEntryTitle(doc.data, {
|
|
3999
|
+
schema: entryType?.schema,
|
|
4000
|
+
entryTypeLabel: entryType?.label,
|
|
4001
|
+
slug: location.slug
|
|
4002
|
+
}));
|
|
4003
|
+
}
|
|
4004
|
+
const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
|
|
4005
|
+
return buildResolvedReference(doc.data, meta, bodyForEmbed);
|
|
3789
4006
|
} catch (error) {
|
|
3790
4007
|
if (isNodeError(error) && error.code === "ENOENT")
|
|
3791
4008
|
return STALE_LOOKUP;
|
|
@@ -3797,7 +4014,7 @@ var ContentStore = class {
|
|
|
3797
4014
|
|
|
3798
4015
|
// dist/ai/generate.js
|
|
3799
4016
|
import path12 from "node:path";
|
|
3800
|
-
import
|
|
4017
|
+
import fs10 from "node:fs/promises";
|
|
3801
4018
|
import { minimatch } from "minimatch";
|
|
3802
4019
|
|
|
3803
4020
|
// dist/ai/strip-mdx.js
|
|
@@ -4353,7 +4570,7 @@ function yamlValue(value) {
|
|
|
4353
4570
|
}
|
|
4354
4571
|
|
|
4355
4572
|
// dist/entry-link-resolver.js
|
|
4356
|
-
var
|
|
4573
|
+
var log3 = createDebugLogger({ prefix: "EntryLinks" });
|
|
4357
4574
|
var BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
|
|
4358
4575
|
var ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
|
|
4359
4576
|
function resolveEntryUrl(location, contentRoot) {
|
|
@@ -4367,7 +4584,7 @@ function resolveEntryLinksInText(text, idIndex, contentRoot, customResolver) {
|
|
|
4367
4584
|
return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
|
|
4368
4585
|
const location = idIndex.findById(id);
|
|
4369
4586
|
if (!location || location.type !== "entry" || !location.collection || !location.slug) {
|
|
4370
|
-
|
|
4587
|
+
log3.warn("resolve", `Entry link target not found: entry:${id}`);
|
|
4371
4588
|
return anchor ?? "#";
|
|
4372
4589
|
}
|
|
4373
4590
|
let url;
|
|
@@ -4669,10 +4886,10 @@ function makeReadSibling(dir) {
|
|
|
4669
4886
|
if (path12.dirname(abs) !== resolvedDir)
|
|
4670
4887
|
return null;
|
|
4671
4888
|
try {
|
|
4672
|
-
const stats = await
|
|
4889
|
+
const stats = await fs10.lstat(abs);
|
|
4673
4890
|
if (!stats.isFile())
|
|
4674
4891
|
return null;
|
|
4675
|
-
return await
|
|
4892
|
+
return await fs10.readFile(abs, "utf8");
|
|
4676
4893
|
} catch (err) {
|
|
4677
4894
|
if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
|
|
4678
4895
|
return null;
|
|
@@ -4723,18 +4940,19 @@ function matchesBundleFilter(entry, filter, contentRoot) {
|
|
|
4723
4940
|
}
|
|
4724
4941
|
|
|
4725
4942
|
// dist/branch-workspace.js
|
|
4726
|
-
import
|
|
4943
|
+
import fs18 from "node:fs/promises";
|
|
4944
|
+
import path20 from "node:path";
|
|
4727
4945
|
|
|
4728
4946
|
// dist/branch-metadata.js
|
|
4729
|
-
import
|
|
4947
|
+
import fs14 from "node:fs/promises";
|
|
4730
4948
|
import path16 from "node:path";
|
|
4731
4949
|
|
|
4732
4950
|
// dist/branch-registry.js
|
|
4733
|
-
import
|
|
4951
|
+
import fs12 from "node:fs/promises";
|
|
4734
4952
|
import path14 from "node:path";
|
|
4735
4953
|
|
|
4736
4954
|
// dist/branch-metadata-file.js
|
|
4737
|
-
import
|
|
4955
|
+
import fs11 from "node:fs/promises";
|
|
4738
4956
|
import path13 from "node:path";
|
|
4739
4957
|
var BRANCH_META_DIR = ".canopy-meta";
|
|
4740
4958
|
var BRANCH_META_FILE = "branch.json";
|
|
@@ -4750,7 +4968,7 @@ var branchMetadataFilePath = (branchRoot) => path13.join(path13.resolve(branchRo
|
|
|
4750
4968
|
async function readBranchMetadataFile(branchRoot) {
|
|
4751
4969
|
const resolvedRoot = path13.resolve(branchRoot);
|
|
4752
4970
|
try {
|
|
4753
|
-
const raw = await
|
|
4971
|
+
const raw = await fs11.readFile(branchMetadataFilePath(resolvedRoot), "utf8");
|
|
4754
4972
|
return JSON.parse(raw);
|
|
4755
4973
|
} catch (err) {
|
|
4756
4974
|
if (isNotFoundError(err)) {
|
|
@@ -4765,7 +4983,7 @@ async function readBranchMetadataFile(branchRoot) {
|
|
|
4765
4983
|
|
|
4766
4984
|
// dist/branch-registry.js
|
|
4767
4985
|
init_logger();
|
|
4768
|
-
var
|
|
4986
|
+
var log4 = createDebugLogger({ prefix: "BranchRegistry" });
|
|
4769
4987
|
var REGISTRY_FILE = "branches.json";
|
|
4770
4988
|
var REGISTRY_STALE_FILE = "branches.stale.json";
|
|
4771
4989
|
var REGISTRY_TEMP_FILE = "branches.tmp.json";
|
|
@@ -4788,7 +5006,7 @@ var BranchRegistry = class {
|
|
|
4788
5006
|
async list() {
|
|
4789
5007
|
let parsed;
|
|
4790
5008
|
try {
|
|
4791
|
-
const raw = await
|
|
5009
|
+
const raw = await fs12.readFile(this.registryPath, "utf8");
|
|
4792
5010
|
parsed = JSON.parse(raw);
|
|
4793
5011
|
} catch (err) {
|
|
4794
5012
|
if (isNotFoundError(err)) {
|
|
@@ -4845,7 +5063,7 @@ var BranchRegistry = class {
|
|
|
4845
5063
|
});
|
|
4846
5064
|
await this.regenerate();
|
|
4847
5065
|
} catch (err) {
|
|
4848
|
-
|
|
5066
|
+
log4.warn("invalidate", "Eager regeneration after invalidate() failed", {
|
|
4849
5067
|
error: getErrorMessage(err)
|
|
4850
5068
|
});
|
|
4851
5069
|
}
|
|
@@ -4870,7 +5088,7 @@ var BranchRegistry = class {
|
|
|
4870
5088
|
async performRegenerate() {
|
|
4871
5089
|
const read = await readResourceGeneration(this.root, RESOURCE2);
|
|
4872
5090
|
const branches = await this.scanBranchDirectories();
|
|
4873
|
-
await
|
|
5091
|
+
await fs12.unlink(this.stalePath).catch(() => {
|
|
4874
5092
|
});
|
|
4875
5093
|
if (!read.ok) {
|
|
4876
5094
|
return branches;
|
|
@@ -4881,12 +5099,12 @@ var BranchRegistry = class {
|
|
|
4881
5099
|
generation: read.token
|
|
4882
5100
|
};
|
|
4883
5101
|
const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
4884
|
-
await
|
|
4885
|
-
await
|
|
5102
|
+
await fs12.mkdir(this.root, { recursive: true });
|
|
5103
|
+
await fs12.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
4886
5104
|
try {
|
|
4887
|
-
await
|
|
5105
|
+
await fs12.rename(uniqueTempPath, this.registryPath);
|
|
4888
5106
|
} catch (err) {
|
|
4889
|
-
await
|
|
5107
|
+
await fs12.unlink(uniqueTempPath).catch(() => {
|
|
4890
5108
|
});
|
|
4891
5109
|
throw err;
|
|
4892
5110
|
}
|
|
@@ -4900,7 +5118,7 @@ var BranchRegistry = class {
|
|
|
4900
5118
|
async scanBranchDirectories() {
|
|
4901
5119
|
const branches = [];
|
|
4902
5120
|
try {
|
|
4903
|
-
const entries = await
|
|
5121
|
+
const entries = await fs12.readdir(this.root, { withFileTypes: true });
|
|
4904
5122
|
for (const entry of entries) {
|
|
4905
5123
|
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
|
4906
5124
|
continue;
|
|
@@ -4932,11 +5150,11 @@ var BranchRegistry = class {
|
|
|
4932
5150
|
};
|
|
4933
5151
|
|
|
4934
5152
|
// dist/utils/occ-json-write.js
|
|
4935
|
-
import
|
|
5153
|
+
import fs13 from "node:fs/promises";
|
|
4936
5154
|
import path15 from "node:path";
|
|
4937
5155
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
4938
5156
|
import lockfile2 from "proper-lockfile";
|
|
4939
|
-
var
|
|
5157
|
+
var log5 = createDebugLogger({ prefix: "OccJsonWrite" });
|
|
4940
5158
|
var OccWriteConflictError = class extends Error {
|
|
4941
5159
|
constructor(message = "Concurrent modification detected") {
|
|
4942
5160
|
super(message);
|
|
@@ -4950,28 +5168,28 @@ async function writeOccJsonFile(filePath, payload, options) {
|
|
|
4950
5168
|
const data = { ...payload, version: newVersion, writeId };
|
|
4951
5169
|
const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
|
|
4952
5170
|
const dir = path15.dirname(filePath);
|
|
4953
|
-
await
|
|
5171
|
+
await fs13.mkdir(dir, { recursive: true });
|
|
4954
5172
|
const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
4955
|
-
await
|
|
5173
|
+
await fs13.writeFile(tempPath, content, "utf-8");
|
|
4956
5174
|
if (expectedVersion === null) {
|
|
4957
5175
|
try {
|
|
4958
|
-
await
|
|
5176
|
+
await fs13.link(tempPath, filePath);
|
|
4959
5177
|
} catch (err) {
|
|
4960
|
-
await
|
|
5178
|
+
await fs13.unlink(tempPath).catch(() => {
|
|
4961
5179
|
});
|
|
4962
5180
|
if (isFileExistsError(err)) {
|
|
4963
5181
|
throw new OccWriteConflictError();
|
|
4964
5182
|
}
|
|
4965
5183
|
throw err;
|
|
4966
5184
|
}
|
|
4967
|
-
await
|
|
5185
|
+
await fs13.unlink(tempPath).catch(() => {
|
|
4968
5186
|
});
|
|
4969
5187
|
return { version: newVersion, writeId };
|
|
4970
5188
|
}
|
|
4971
5189
|
try {
|
|
4972
5190
|
let currentVersion = null;
|
|
4973
5191
|
try {
|
|
4974
|
-
const current = JSON.parse(await
|
|
5192
|
+
const current = JSON.parse(await fs13.readFile(filePath, "utf-8"));
|
|
4975
5193
|
currentVersion = current.version ?? 0;
|
|
4976
5194
|
} catch {
|
|
4977
5195
|
currentVersion = null;
|
|
@@ -4979,16 +5197,16 @@ async function writeOccJsonFile(filePath, payload, options) {
|
|
|
4979
5197
|
if (currentVersion !== expectedVersion) {
|
|
4980
5198
|
throw new OccWriteConflictError();
|
|
4981
5199
|
}
|
|
4982
|
-
await
|
|
5200
|
+
await fs13.rename(tempPath, filePath);
|
|
4983
5201
|
if (settleMs > 0) {
|
|
4984
5202
|
await new Promise((resolve2) => setTimeout(resolve2, settleMs));
|
|
4985
5203
|
}
|
|
4986
|
-
const afterWrite = JSON.parse(await
|
|
5204
|
+
const afterWrite = JSON.parse(await fs13.readFile(filePath, "utf-8"));
|
|
4987
5205
|
if (afterWrite.writeId !== writeId) {
|
|
4988
5206
|
throw new OccWriteConflictError();
|
|
4989
5207
|
}
|
|
4990
5208
|
} catch (err) {
|
|
4991
|
-
await
|
|
5209
|
+
await fs13.unlink(tempPath).catch(() => {
|
|
4992
5210
|
});
|
|
4993
5211
|
throw err;
|
|
4994
5212
|
}
|
|
@@ -5014,8 +5232,13 @@ async function withOccRetry(operation, options) {
|
|
|
5014
5232
|
throw new Error("Unreachable");
|
|
5015
5233
|
}
|
|
5016
5234
|
async function withOccFileLock(filePath, fn) {
|
|
5017
|
-
|
|
5018
|
-
|
|
5235
|
+
try {
|
|
5236
|
+
await fs13.mkdir(path15.dirname(filePath));
|
|
5237
|
+
} catch (err) {
|
|
5238
|
+
if (!isNodeError(err) || err.code !== "EEXIST") {
|
|
5239
|
+
throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5019
5242
|
const MAX_ATTEMPTS = 14;
|
|
5020
5243
|
let release = null;
|
|
5021
5244
|
for (let attempt = 1; release === null; attempt++) {
|
|
@@ -5031,7 +5254,7 @@ async function withOccFileLock(filePath, fn) {
|
|
|
5031
5254
|
// Our critical sections are short and idempotent-on-conflict (OCC
|
|
5032
5255
|
// verify inside); log and let the section finish.
|
|
5033
5256
|
onCompromised: (err) => {
|
|
5034
|
-
|
|
5257
|
+
log5.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
|
|
5035
5258
|
error: getErrorMessage(err)
|
|
5036
5259
|
});
|
|
5037
5260
|
}
|
|
@@ -5053,7 +5276,7 @@ async function withOccFileLock(filePath, fn) {
|
|
|
5053
5276
|
await release().catch((err) => {
|
|
5054
5277
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
5055
5278
|
return;
|
|
5056
|
-
|
|
5279
|
+
log5.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
|
|
5057
5280
|
});
|
|
5058
5281
|
}
|
|
5059
5282
|
}
|
|
@@ -5086,7 +5309,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
5086
5309
|
}
|
|
5087
5310
|
async load() {
|
|
5088
5311
|
try {
|
|
5089
|
-
const raw = await
|
|
5312
|
+
const raw = await fs14.readFile(this.filePath, "utf8");
|
|
5090
5313
|
const parsed = JSON.parse(raw);
|
|
5091
5314
|
const version = parsed.version ?? 0;
|
|
5092
5315
|
return { meta: parsed, version };
|
|
@@ -5132,15 +5355,13 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
5132
5355
|
* entry with no clone behind it. It runs BEFORE the lock stack so a doomed
|
|
5133
5356
|
* save fails fast instead of paying for a lock.
|
|
5134
5357
|
*
|
|
5135
|
-
*
|
|
5136
|
-
*
|
|
5137
|
-
*
|
|
5138
|
-
* lockfile taken next lives INSIDE `branchRoot`, so it can promise no more
|
|
5139
|
-
* than "the directory existed a moment ago".
|
|
5358
|
+
* A save that passes the check just before a delete still cannot write: the
|
|
5359
|
+
* delete renames the tree away under the same lockfile, and that lockfile
|
|
5360
|
+
* creates only its own directory (`withOccFileLock`), so the save fails there.
|
|
5140
5361
|
*/
|
|
5141
5362
|
async save(incoming) {
|
|
5142
5363
|
try {
|
|
5143
|
-
await
|
|
5364
|
+
await fs14.stat(this.branchRoot);
|
|
5144
5365
|
} catch (err) {
|
|
5145
5366
|
if (isNotFoundError(err)) {
|
|
5146
5367
|
throw new BranchMetadataConflictError("Branch no longer exists");
|
|
@@ -5151,37 +5372,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
5151
5372
|
try {
|
|
5152
5373
|
saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
|
|
5153
5374
|
const { meta: existing, version } = await this.load();
|
|
5154
|
-
const
|
|
5155
|
-
const defaults = {
|
|
5156
|
-
name: "unknown",
|
|
5157
|
-
status: "editing",
|
|
5158
|
-
access: {},
|
|
5159
|
-
createdBy: "unknown",
|
|
5160
|
-
createdAt: now,
|
|
5161
|
-
updatedAt: now
|
|
5162
|
-
};
|
|
5163
|
-
const merged = {
|
|
5164
|
-
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
5165
|
-
version: version ?? 0,
|
|
5166
|
-
branch: {
|
|
5167
|
-
...defaults,
|
|
5168
|
-
...existing?.branch,
|
|
5169
|
-
...incoming.branch,
|
|
5170
|
-
access: {
|
|
5171
|
-
...existing?.branch?.access,
|
|
5172
|
-
...incoming.branch?.access
|
|
5173
|
-
},
|
|
5174
|
-
// Immutable after creation
|
|
5175
|
-
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
5176
|
-
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
5177
|
-
// Fork point is recorded once at creation; later saves must not move it
|
|
5178
|
-
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
|
|
5179
|
-
// Always stamped fresh; the spreads above would otherwise let
|
|
5180
|
-
// the creation-time value win forever, freezing the timestamp
|
|
5181
|
-
// the editor's Branches panel sorts and displays by
|
|
5182
|
-
updatedAt: now
|
|
5183
|
-
}
|
|
5184
|
-
};
|
|
5375
|
+
const merged = mergeBranchMetadata(existing, version, incoming);
|
|
5185
5376
|
const written = await this.write(merged, version);
|
|
5186
5377
|
merged.version = written.version;
|
|
5187
5378
|
merged.writeId = written.writeId;
|
|
@@ -5202,9 +5393,40 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
5202
5393
|
await registry2.invalidate();
|
|
5203
5394
|
}
|
|
5204
5395
|
};
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5396
|
+
function mergeBranchMetadata(existing, version, incoming, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
5397
|
+
const defaults = {
|
|
5398
|
+
name: "unknown",
|
|
5399
|
+
status: "editing",
|
|
5400
|
+
access: {},
|
|
5401
|
+
createdBy: "unknown",
|
|
5402
|
+
createdAt: now,
|
|
5403
|
+
updatedAt: now
|
|
5404
|
+
};
|
|
5405
|
+
return {
|
|
5406
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
5407
|
+
version: version ?? 0,
|
|
5408
|
+
branch: {
|
|
5409
|
+
...defaults,
|
|
5410
|
+
...existing?.branch,
|
|
5411
|
+
...incoming.branch,
|
|
5412
|
+
access: {
|
|
5413
|
+
...existing?.branch?.access,
|
|
5414
|
+
...incoming.branch?.access
|
|
5415
|
+
},
|
|
5416
|
+
// Immutable after creation
|
|
5417
|
+
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
5418
|
+
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
5419
|
+
// Fork point is recorded once at creation; later saves must not move it
|
|
5420
|
+
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
|
|
5421
|
+
// Always stamped fresh; the spreads above would otherwise let the creation-time value win
|
|
5422
|
+
// forever, freezing the timestamp the editor's Branches panel sorts and displays by
|
|
5423
|
+
updatedAt: now
|
|
5424
|
+
}
|
|
5425
|
+
};
|
|
5426
|
+
}
|
|
5427
|
+
function buildInitialBranchMetadata(incoming, now) {
|
|
5428
|
+
return mergeBranchMetadata(null, null, incoming, now);
|
|
5429
|
+
}
|
|
5208
5430
|
var loadBranchContext = async (options) => {
|
|
5209
5431
|
const { branchRoot, baseRoot } = resolveBranchPath({
|
|
5210
5432
|
branchName: options.branchName,
|
|
@@ -5222,58 +5444,17 @@ var loadBranchContext = async (options) => {
|
|
|
5222
5444
|
};
|
|
5223
5445
|
};
|
|
5224
5446
|
|
|
5225
|
-
// dist/branch-
|
|
5226
|
-
|
|
5447
|
+
// dist/branch-provisioning.js
|
|
5448
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5449
|
+
import fs16 from "node:fs/promises";
|
|
5450
|
+
import path18 from "node:path";
|
|
5227
5451
|
|
|
5228
5452
|
// dist/git-manager.js
|
|
5229
|
-
import
|
|
5453
|
+
import fs15 from "node:fs/promises";
|
|
5230
5454
|
import path17 from "node:path";
|
|
5231
5455
|
import { simpleGit as simpleGit2 } from "simple-git";
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
import { simpleGit } from "simple-git";
|
|
5235
|
-
var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
|
|
5236
|
-
var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
|
|
5237
|
-
var LEADING_OPTION_PATTERN = /^-/;
|
|
5238
|
-
var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
|
|
5239
|
-
var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
|
|
5240
|
-
function isNetworkRemoteUrl(url) {
|
|
5241
|
-
if (/^file:\/\//i.test(url))
|
|
5242
|
-
return false;
|
|
5243
|
-
if (NETWORK_SCHEME_PATTERN.test(url))
|
|
5244
|
-
return true;
|
|
5245
|
-
if (TRANSPORT_HELPER_PATTERN.test(url))
|
|
5246
|
-
return true;
|
|
5247
|
-
if (LEADING_OPTION_PATTERN.test(url))
|
|
5248
|
-
return true;
|
|
5249
|
-
if (WINDOWS_DRIVE_PATTERN.test(url))
|
|
5250
|
-
return false;
|
|
5251
|
-
return COLON_BEFORE_SLASH_PATTERN.test(url);
|
|
5252
|
-
}
|
|
5253
|
-
var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
|
|
5254
|
-
function isMissingRemoteRefFailure(message) {
|
|
5255
|
-
return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
|
|
5256
|
-
}
|
|
5257
|
-
async function detectHeadBranch(repoRoot, fallback = "main") {
|
|
5258
|
-
try {
|
|
5259
|
-
const git = simpleGit({ baseDir: repoRoot });
|
|
5260
|
-
const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
5261
|
-
return head && head !== "HEAD" ? head : fallback;
|
|
5262
|
-
} catch {
|
|
5263
|
-
return fallback;
|
|
5264
|
-
}
|
|
5265
|
-
}
|
|
5266
|
-
async function resolveBaseBranch(options) {
|
|
5267
|
-
if (options.defaultBaseBranch)
|
|
5268
|
-
return options.defaultBaseBranch;
|
|
5269
|
-
if (options.mode === "dev") {
|
|
5270
|
-
return detectHeadBranch(options.detectFrom ?? process.cwd());
|
|
5271
|
-
}
|
|
5272
|
-
return "main";
|
|
5273
|
-
}
|
|
5274
|
-
|
|
5275
|
-
// dist/git-manager.js
|
|
5276
|
-
var log5 = createDebugLogger({ prefix: "GitManager" });
|
|
5456
|
+
init_logger();
|
|
5457
|
+
var log6 = createDebugLogger({ prefix: "GitManager" });
|
|
5277
5458
|
var GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|GIT_TERMINAL_PROMPT|GIT_TRACE[0-9A-Z_]*)$/;
|
|
5278
5459
|
var FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
|
|
5279
5460
|
function gitChildEnv(overrides) {
|
|
@@ -5284,27 +5465,119 @@ function gitChildEnv(overrides) {
|
|
|
5284
5465
|
}
|
|
5285
5466
|
return { ...env, ...FORCE_C_LOCALE, ...overrides };
|
|
5286
5467
|
}
|
|
5468
|
+
var GIT_NETWORK_ENV_PASSTHROUGH = /^((HTTPS?|ALL)_PROXY|(https?|all)_proxy|NO_PROXY|no_proxy|GIT_SSL_(CAINFO|CAPATH|NO_VERIFY|VERSION)|CURL_CA_BUNDLE|SSL_CERT_(FILE|DIR)|REQUESTS_CA_BUNDLE|NODE_EXTRA_CA_CERTS)$/;
|
|
5469
|
+
function gitNetworkChildEnv() {
|
|
5470
|
+
const env = {};
|
|
5471
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
5472
|
+
if (value === void 0)
|
|
5473
|
+
continue;
|
|
5474
|
+
if (GIT_ENV_PASSTHROUGH.test(key) || GIT_NETWORK_ENV_PASSTHROUGH.test(key))
|
|
5475
|
+
env[key] = value;
|
|
5476
|
+
}
|
|
5477
|
+
return { ...env, ...FORCE_C_LOCALE };
|
|
5478
|
+
}
|
|
5479
|
+
var NO_AUTO_GC_CONFIG = ["gc.auto=0", "maintenance.auto=false"];
|
|
5480
|
+
var REMOTE_GIT_CONFIG = [
|
|
5481
|
+
["gc.auto", "0"],
|
|
5482
|
+
["receive.autogc", "false"],
|
|
5483
|
+
["maintenance.auto", "false"],
|
|
5484
|
+
["transfer.unpackLimit", "1"]
|
|
5485
|
+
];
|
|
5486
|
+
async function ensureRemoteGitConfig(gitDir) {
|
|
5487
|
+
const git = simpleGit2().env(gitChildEnv({}));
|
|
5488
|
+
const current = /* @__PURE__ */ new Map();
|
|
5489
|
+
const listed = await git.raw(["--git-dir", gitDir, "config", "--local", "--list"]);
|
|
5490
|
+
for (const line of listed.split("\n")) {
|
|
5491
|
+
const eq = line.indexOf("=");
|
|
5492
|
+
if (eq > 0)
|
|
5493
|
+
current.set(line.slice(0, eq).toLowerCase(), line.slice(eq + 1));
|
|
5494
|
+
}
|
|
5495
|
+
for (const [key, value] of REMOTE_GIT_CONFIG) {
|
|
5496
|
+
if (current.get(key.toLowerCase()) === value)
|
|
5497
|
+
continue;
|
|
5498
|
+
await git.raw(["--git-dir", gitDir, "config", "--local", key, value]);
|
|
5499
|
+
}
|
|
5500
|
+
}
|
|
5501
|
+
var REMOTE_GIT_MAX_LOOSE_OBJECTS = 50;
|
|
5502
|
+
var REMOTE_GIT_MAX_PACKS = 6;
|
|
5503
|
+
async function countObjects(git, gitDir) {
|
|
5504
|
+
const output = await git.raw(["--git-dir", gitDir, "count-objects", "-v"]);
|
|
5505
|
+
const fields = new Map(output.split("\n").map((line) => {
|
|
5506
|
+
const [name, value = ""] = line.split(": ");
|
|
5507
|
+
return [name, value.trim()];
|
|
5508
|
+
}));
|
|
5509
|
+
const field = (name) => {
|
|
5510
|
+
const value = fields.get(name);
|
|
5511
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
5512
|
+
throw new Error(`count-objects -v printed no '${name}' count: ${output.trim()}`);
|
|
5513
|
+
}
|
|
5514
|
+
return Number(value);
|
|
5515
|
+
};
|
|
5516
|
+
return { loose: field("count"), packs: field("packs") };
|
|
5517
|
+
}
|
|
5518
|
+
async function repackBareRemoteIfNeeded(gitDir) {
|
|
5519
|
+
const git = simpleGit2().env(gitChildEnv({}));
|
|
5520
|
+
const before = await countObjects(git, gitDir);
|
|
5521
|
+
if (before.loose <= REMOTE_GIT_MAX_LOOSE_OBJECTS && before.packs <= REMOTE_GIT_MAX_PACKS) {
|
|
5522
|
+
return { repacked: false, before };
|
|
5523
|
+
}
|
|
5524
|
+
const startedAt = Date.now();
|
|
5525
|
+
await git.raw(["--git-dir", gitDir, "repack", "-a", "-d", "--cruft", "-q"]);
|
|
5526
|
+
await git.raw(["--git-dir", gitDir, "pack-refs", "--all"]);
|
|
5527
|
+
const after = await countObjects(git, gitDir);
|
|
5528
|
+
return { repacked: true, before, after, ms: Date.now() - startedAt };
|
|
5529
|
+
}
|
|
5530
|
+
function failOnSignalExit(error, result) {
|
|
5531
|
+
if (error)
|
|
5532
|
+
return error;
|
|
5533
|
+
if (result.exitCode === null) {
|
|
5534
|
+
return new Error("git was killed by a signal before it finished");
|
|
5535
|
+
}
|
|
5536
|
+
return void 0;
|
|
5537
|
+
}
|
|
5538
|
+
var PARALLEL_CHECKOUT_ARGS = [
|
|
5539
|
+
"-c",
|
|
5540
|
+
"checkout.workers=8",
|
|
5541
|
+
"-c",
|
|
5542
|
+
"checkout.thresholdForParallelism=1"
|
|
5543
|
+
];
|
|
5544
|
+
async function dirState(dir) {
|
|
5545
|
+
try {
|
|
5546
|
+
return (await fs15.readdir(dir)).length === 0 ? "empty" : "occupied";
|
|
5547
|
+
} catch (err) {
|
|
5548
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
5549
|
+
return "absent";
|
|
5550
|
+
throw err;
|
|
5551
|
+
}
|
|
5552
|
+
}
|
|
5553
|
+
function managedWorkspaceConfig(gitBotAuthorName, gitBotAuthorEmail) {
|
|
5554
|
+
return {
|
|
5555
|
+
"canopycms.managed": "true",
|
|
5556
|
+
"user.name": gitBotAuthorName,
|
|
5557
|
+
"user.email": gitBotAuthorEmail
|
|
5558
|
+
};
|
|
5559
|
+
}
|
|
5287
5560
|
var remoteInitLocks = /* @__PURE__ */ new Map();
|
|
5288
5561
|
var GITHUB_TRACKING_REF_PREFIX = "refs/remotes/github/";
|
|
5289
5562
|
async function ensureGitExcludePattern(repoPath, pattern) {
|
|
5290
5563
|
const excludePath = path17.join(repoPath, ".git", "info", "exclude");
|
|
5291
|
-
await
|
|
5564
|
+
await fs15.mkdir(path17.dirname(excludePath), { recursive: true });
|
|
5292
5565
|
let content = "";
|
|
5293
5566
|
try {
|
|
5294
|
-
content = await
|
|
5567
|
+
content = await fs15.readFile(excludePath, "utf-8");
|
|
5295
5568
|
} catch (err) {
|
|
5296
5569
|
if (!isNotFoundError(err))
|
|
5297
5570
|
throw err;
|
|
5298
5571
|
}
|
|
5299
5572
|
const lines = content.split("\n");
|
|
5300
5573
|
if (lines.some((line) => line.trim() === pattern)) {
|
|
5301
|
-
|
|
5574
|
+
log6.debug("git", "Pattern already in .git/info/exclude", { pattern });
|
|
5302
5575
|
return;
|
|
5303
5576
|
}
|
|
5304
5577
|
const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
|
|
5305
5578
|
const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
|
|
5306
|
-
await
|
|
5307
|
-
|
|
5579
|
+
await fs15.writeFile(excludePath, newContent, "utf-8");
|
|
5580
|
+
log6.debug("git", "Added pattern to .git/info/exclude", { pattern });
|
|
5308
5581
|
}
|
|
5309
5582
|
var GitConflictError = class extends Error {
|
|
5310
5583
|
constructor(conflictedFiles) {
|
|
@@ -5322,24 +5595,95 @@ var GitRemoteRefMissingError = class extends Error {
|
|
|
5322
5595
|
this.name = "GitRemoteRefMissingError";
|
|
5323
5596
|
}
|
|
5324
5597
|
};
|
|
5598
|
+
var RemoteNotReadyError = class extends Error {
|
|
5599
|
+
constructor(expectedRemotePath) {
|
|
5600
|
+
super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
|
|
5601
|
+
this.expectedRemotePath = expectedRemotePath;
|
|
5602
|
+
this.name = "RemoteNotReadyError";
|
|
5603
|
+
}
|
|
5604
|
+
};
|
|
5605
|
+
var SettingsRemoteUnreadableError = class extends Error {
|
|
5606
|
+
constructor(branch, remote, gitError) {
|
|
5607
|
+
super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
|
|
5608
|
+
this.branch = branch;
|
|
5609
|
+
this.remote = remote;
|
|
5610
|
+
this.gitError = gitError;
|
|
5611
|
+
this.name = "SettingsRemoteUnreadableError";
|
|
5612
|
+
}
|
|
5613
|
+
};
|
|
5614
|
+
var SettingsBranchDivergedError = class extends Error {
|
|
5615
|
+
constructor(branch, workspacePath, localWork) {
|
|
5616
|
+
super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy is chosen. To keep the remote's settings: copy anything you need out of ${workspacePath}, move it aside, and restart; the next start checks out the remote's '${branch}', and copied changes can then be re-applied in the editor.`);
|
|
5617
|
+
this.branch = branch;
|
|
5618
|
+
this.workspacePath = workspacePath;
|
|
5619
|
+
this.localWork = localWork;
|
|
5620
|
+
this.name = "SettingsBranchDivergedError";
|
|
5621
|
+
}
|
|
5622
|
+
};
|
|
5623
|
+
var SETTINGS_BRANCH_FILES = /* @__PURE__ */ new Set(["permissions.json", "groups.json"]);
|
|
5624
|
+
var SettingsBranchHasContentHistoryError = class extends Error {
|
|
5625
|
+
constructor(branch, remote, evidence) {
|
|
5626
|
+
super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote from a copy of the settings (it may hold only permissions.json and groups.json), then restart.`);
|
|
5627
|
+
this.branch = branch;
|
|
5628
|
+
this.remote = remote;
|
|
5629
|
+
this.evidence = evidence;
|
|
5630
|
+
this.name = "SettingsBranchHasContentHistoryError";
|
|
5631
|
+
}
|
|
5632
|
+
};
|
|
5325
5633
|
var GitManager = class _GitManager {
|
|
5326
5634
|
constructor(options, gitOptions) {
|
|
5327
5635
|
this.repoPath = path17.resolve(options.repoPath);
|
|
5328
5636
|
this.baseBranch = options.baseBranch ?? "main";
|
|
5329
5637
|
this.remote = options.remote ?? "origin";
|
|
5330
5638
|
this.skipIndexMarker = options.skipIndexMarker ?? false;
|
|
5331
|
-
this.git = simpleGit2({
|
|
5639
|
+
this.git = simpleGit2({
|
|
5640
|
+
baseDir: this.repoPath,
|
|
5641
|
+
errors: failOnSignalExit,
|
|
5642
|
+
...gitOptions,
|
|
5643
|
+
config: [...NO_AUTO_GC_CONFIG, ...gitOptions?.config ?? []]
|
|
5644
|
+
});
|
|
5332
5645
|
this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(this.repoPath) }));
|
|
5333
5646
|
}
|
|
5334
|
-
|
|
5335
|
-
|
|
5647
|
+
/**
|
|
5648
|
+
* Single-branch clone of `baseBranch`, retried once: a clone can lose a race
|
|
5649
|
+
* with the worker's repack of `remote.git` deleting a pack it had just
|
|
5650
|
+
* listed. Before the retry it clears only what it created itself, so a
|
|
5651
|
+
* target that held files beforehand is never touched.
|
|
5652
|
+
*/
|
|
5653
|
+
static async cloneRepo(remoteUrl, targetPath, baseBranch = "main", options = {}) {
|
|
5654
|
+
log6.debug("git", "Cloning repository", {
|
|
5336
5655
|
remoteUrl,
|
|
5337
5656
|
targetPath,
|
|
5338
5657
|
baseBranch
|
|
5339
5658
|
});
|
|
5340
|
-
const
|
|
5341
|
-
|
|
5342
|
-
|
|
5659
|
+
const args = ["--single-branch", "--branch", baseBranch];
|
|
5660
|
+
if (options.noCheckout)
|
|
5661
|
+
args.push("--no-checkout");
|
|
5662
|
+
if (options.remoteName)
|
|
5663
|
+
args.push("--origin", options.remoteName);
|
|
5664
|
+
for (const [key, value] of Object.entries(options.config ?? {})) {
|
|
5665
|
+
args.push("-c", `${key}=${value}`);
|
|
5666
|
+
}
|
|
5667
|
+
for (const setting of NO_AUTO_GC_CONFIG)
|
|
5668
|
+
args.push("-c", setting);
|
|
5669
|
+
const git = simpleGit2({ config: [...NO_AUTO_GC_CONFIG], errors: failOnSignalExit });
|
|
5670
|
+
git.env({
|
|
5671
|
+
...isNetworkRemoteUrl(remoteUrl) ? gitNetworkChildEnv() : gitChildEnv({}),
|
|
5672
|
+
GIT_CEILING_DIRECTORIES: path17.dirname(targetPath)
|
|
5673
|
+
});
|
|
5674
|
+
const before = await dirState(targetPath);
|
|
5675
|
+
try {
|
|
5676
|
+
await git.clone(remoteUrl, targetPath, args);
|
|
5677
|
+
} catch (err) {
|
|
5678
|
+
if (before === "occupied")
|
|
5679
|
+
throw err;
|
|
5680
|
+
canopyLogWarn(`[canopy] clone into ${targetPath} failed, retrying once: ` + redactCredentials(getErrorMessage(err)));
|
|
5681
|
+
await fs15.rm(targetPath, { recursive: true, force: true });
|
|
5682
|
+
if (before === "empty")
|
|
5683
|
+
await fs15.mkdir(targetPath);
|
|
5684
|
+
await git.clone(remoteUrl, targetPath, args);
|
|
5685
|
+
}
|
|
5686
|
+
log6.debug("git", "Clone complete");
|
|
5343
5687
|
}
|
|
5344
5688
|
/**
|
|
5345
5689
|
* Initializes a local bare git repository to simulate a remote for dev mode,
|
|
@@ -5353,15 +5697,15 @@ var GitManager = class _GitManager {
|
|
|
5353
5697
|
static async ensureLocalSimulatedRemote(options) {
|
|
5354
5698
|
const existingLock = remoteInitLocks.get(options.remotePath);
|
|
5355
5699
|
if (existingLock) {
|
|
5356
|
-
|
|
5700
|
+
log6.debug("git", "Waiting for existing remote initialization", {
|
|
5357
5701
|
remotePath: options.remotePath
|
|
5358
5702
|
});
|
|
5359
5703
|
await existingLock;
|
|
5360
5704
|
}
|
|
5361
|
-
const lockPromise =
|
|
5705
|
+
const lockPromise = log6.timed("git", "ensureLocalSimulatedRemote", async () => {
|
|
5362
5706
|
let releaseLock;
|
|
5363
5707
|
try {
|
|
5364
|
-
|
|
5708
|
+
log6.debug("git", "Initializing local simulated remote", {
|
|
5365
5709
|
remotePath: options.remotePath,
|
|
5366
5710
|
baseBranch: options.baseBranch
|
|
5367
5711
|
});
|
|
@@ -5369,14 +5713,26 @@ var GitManager = class _GitManager {
|
|
|
5369
5713
|
releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
|
|
5370
5714
|
let remoteExists = false;
|
|
5371
5715
|
try {
|
|
5372
|
-
const stat = await
|
|
5716
|
+
const stat = await fs15.stat(options.remotePath);
|
|
5373
5717
|
remoteExists = stat.isDirectory();
|
|
5374
5718
|
} catch (err) {
|
|
5375
5719
|
if (!isNotFoundError(err))
|
|
5376
5720
|
throw err;
|
|
5377
5721
|
}
|
|
5722
|
+
if (remoteExists) {
|
|
5723
|
+
try {
|
|
5724
|
+
const repack = await repackBareRemoteIfNeeded(options.remotePath);
|
|
5725
|
+
if (repack.repacked)
|
|
5726
|
+
log6.debug("git", "Repacked local simulated remote", repack);
|
|
5727
|
+
} catch (err) {
|
|
5728
|
+
log6.debug("git", "Could not repack local simulated remote", {
|
|
5729
|
+
error: getErrorMessage(err)
|
|
5730
|
+
});
|
|
5731
|
+
}
|
|
5732
|
+
}
|
|
5378
5733
|
if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
|
|
5379
|
-
|
|
5734
|
+
log6.debug("git", "Remote already has base branch, skipping");
|
|
5735
|
+
await ensureRemoteGitConfig(options.remotePath);
|
|
5380
5736
|
return;
|
|
5381
5737
|
}
|
|
5382
5738
|
let gitRoot = options.sourcePath;
|
|
@@ -5408,12 +5764,12 @@ var GitManager = class _GitManager {
|
|
|
5408
5764
|
throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
|
|
5409
5765
|
}
|
|
5410
5766
|
if (remoteExists) {
|
|
5411
|
-
|
|
5767
|
+
log6.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
|
|
5412
5768
|
remotePath: options.remotePath,
|
|
5413
5769
|
baseBranch: options.baseBranch
|
|
5414
5770
|
});
|
|
5415
5771
|
} else {
|
|
5416
|
-
|
|
5772
|
+
log6.debug("git", "Creating bare remote repository");
|
|
5417
5773
|
await simpleGit2().raw([
|
|
5418
5774
|
"init",
|
|
5419
5775
|
"--bare",
|
|
@@ -5421,19 +5777,20 @@ var GitManager = class _GitManager {
|
|
|
5421
5777
|
options.remotePath
|
|
5422
5778
|
]);
|
|
5423
5779
|
}
|
|
5780
|
+
await ensureRemoteGitConfig(options.remotePath);
|
|
5424
5781
|
await _GitManager.pushBranchToLocalRemote({
|
|
5425
5782
|
sourceGit,
|
|
5426
5783
|
remotePath: options.remotePath,
|
|
5427
5784
|
baseBranch: options.baseBranch,
|
|
5428
5785
|
subdirectory: options.subdirectory
|
|
5429
5786
|
});
|
|
5430
|
-
|
|
5787
|
+
log6.debug("git", "Remote initialization complete");
|
|
5431
5788
|
} finally {
|
|
5432
5789
|
if (releaseLock) {
|
|
5433
5790
|
try {
|
|
5434
5791
|
await releaseLock();
|
|
5435
5792
|
} catch (err) {
|
|
5436
|
-
|
|
5793
|
+
log6.debug("git", "Failed to release remote-init lock", { err });
|
|
5437
5794
|
}
|
|
5438
5795
|
}
|
|
5439
5796
|
remoteInitLocks.delete(options.remotePath);
|
|
@@ -5501,7 +5858,7 @@ var GitManager = class _GitManager {
|
|
|
5501
5858
|
* deleteBranchHandler: a head left in `remote.git` forever makes the
|
|
5502
5859
|
* create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
|
|
5503
5860
|
* the reused branch's first publish non-fast-forward against the stale head
|
|
5504
|
-
* (`GitManager.push()` pushes
|
|
5861
|
+
* (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
|
|
5505
5862
|
* not an ancestor of the new branch), and a retried submit then skips the
|
|
5506
5863
|
* local push on a clean tree and enqueues the worker push of the STALE head,
|
|
5507
5864
|
* resurrecting the deleted branch's content on GitHub as an apparent success.
|
|
@@ -5601,7 +5958,7 @@ var GitManager = class _GitManager {
|
|
|
5601
5958
|
*/
|
|
5602
5959
|
static async validateGitRepoExists(repoPath) {
|
|
5603
5960
|
try {
|
|
5604
|
-
const stat = await
|
|
5961
|
+
const stat = await fs15.stat(path17.join(repoPath, ".git"));
|
|
5605
5962
|
if (!stat.isDirectory()) {
|
|
5606
5963
|
throw new Error(`Expected git repo at ${repoPath}`);
|
|
5607
5964
|
}
|
|
@@ -5632,6 +5989,12 @@ var GitManager = class _GitManager {
|
|
|
5632
5989
|
return;
|
|
5633
5990
|
throw new Error(`CanopyCMS: refusing to use a network remote URL in prod mode (from ${source}: "${url}"). The standard AWS Lambda+EC2-worker topology runs the CMS Lambda with no internet access \u2014 the EC2 worker owns all network git I/O, and the Lambda is expected to reach the EFS-local bare repo ({workspace}/remote.git) via auto-detect instead. Pointing a network URL here would make the internet-less Lambda try to clone/fetch/push it directly and hang until timeout. If this prod host genuinely has internet access and intentionally runs git against a network remote (e.g. a single-VM deployment), set config.allowNetworkRemoteInProd: true to acknowledge this.`);
|
|
5634
5991
|
}
|
|
5992
|
+
/** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
|
|
5993
|
+
static async remoteNotReadyError(mode) {
|
|
5994
|
+
const { operatingStrategy: operatingStrategy2 } = await Promise.resolve().then(() => (init_operating_mode(), operating_mode_exports));
|
|
5995
|
+
const { autoDetectRemotePath } = operatingStrategy2(mode).getRemoteUrlConfig();
|
|
5996
|
+
return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : void 0;
|
|
5997
|
+
}
|
|
5635
5998
|
/**
|
|
5636
5999
|
* Resolves the remote URL for git operations following the priority:
|
|
5637
6000
|
* 1. Explicit remoteUrl parameter
|
|
@@ -5666,9 +6029,9 @@ var GitManager = class _GitManager {
|
|
|
5666
6029
|
}
|
|
5667
6030
|
if (config.autoDetectRemotePath) {
|
|
5668
6031
|
try {
|
|
5669
|
-
const stat = await
|
|
6032
|
+
const stat = await fs15.stat(config.autoDetectRemotePath);
|
|
5670
6033
|
if (stat.isDirectory()) {
|
|
5671
|
-
|
|
6034
|
+
log6.debug("git", "Auto-detected local remote", {
|
|
5672
6035
|
path: config.autoDetectRemotePath
|
|
5673
6036
|
});
|
|
5674
6037
|
return config.autoDetectRemotePath;
|
|
@@ -5691,6 +6054,31 @@ var GitManager = class _GitManager {
|
|
|
5691
6054
|
}
|
|
5692
6055
|
return void 0;
|
|
5693
6056
|
}
|
|
6057
|
+
/**
|
|
6058
|
+
* {@link resolveRemoteUrl} for a workspace about to be cloned, which needs a
|
|
6059
|
+
* remote: none resolving is the worker-not-ready error in a mode whose
|
|
6060
|
+
* remote the worker creates, and a configuration error otherwise.
|
|
6061
|
+
*/
|
|
6062
|
+
static async resolveCloneRemoteUrl(options) {
|
|
6063
|
+
const remoteUrl = await _GitManager.resolveRemoteUrl(options);
|
|
6064
|
+
if (remoteUrl)
|
|
6065
|
+
return remoteUrl;
|
|
6066
|
+
const notReady = await _GitManager.remoteNotReadyError(options.mode);
|
|
6067
|
+
if (notReady)
|
|
6068
|
+
throw notReady;
|
|
6069
|
+
throw new Error("CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace");
|
|
6070
|
+
}
|
|
6071
|
+
/**
|
|
6072
|
+
* {@link cloneRepo} for a workspace, failing with one message that names the
|
|
6073
|
+
* workspace, the remote and the base branch, which git's own error mixes.
|
|
6074
|
+
*/
|
|
6075
|
+
static async cloneWorkspace(remoteUrl, workspacePath, baseBranch, options) {
|
|
6076
|
+
try {
|
|
6077
|
+
await _GitManager.cloneRepo(remoteUrl, workspacePath, baseBranch, options);
|
|
6078
|
+
} catch (err) {
|
|
6079
|
+
throw new Error(`Failed to clone branch workspace at ${workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
|
|
6080
|
+
}
|
|
6081
|
+
}
|
|
5694
6082
|
/**
|
|
5695
6083
|
* Check whether a git repository is already initialized at `workspacePath`.
|
|
5696
6084
|
*
|
|
@@ -5730,21 +6118,24 @@ var GitManager = class _GitManager {
|
|
|
5730
6118
|
if (!repoExists) {
|
|
5731
6119
|
const gitPath = path17.join(options.workspacePath, ".git");
|
|
5732
6120
|
try {
|
|
5733
|
-
const stat = await
|
|
6121
|
+
const stat = await fs15.stat(gitPath);
|
|
5734
6122
|
if (stat.isDirectory()) {
|
|
5735
|
-
|
|
6123
|
+
log6.debug("git", "Removing corrupt .git directory", {
|
|
5736
6124
|
workspacePath: options.workspacePath
|
|
5737
6125
|
});
|
|
5738
|
-
await
|
|
6126
|
+
await fs15.rm(gitPath, { recursive: true });
|
|
5739
6127
|
}
|
|
5740
6128
|
} catch (cleanupErr) {
|
|
5741
6129
|
if (!isNotFoundError(cleanupErr))
|
|
5742
6130
|
throw cleanupErr;
|
|
5743
6131
|
}
|
|
5744
6132
|
}
|
|
6133
|
+
const step = (name, run) => options.provisionLog ? options.provisionLog.step(name, run) : run();
|
|
6134
|
+
const identity = managedWorkspaceConfig(options.gitBotAuthorName, options.gitBotAuthorEmail);
|
|
5745
6135
|
let justCloned = false;
|
|
6136
|
+
let resolvedRemoteUrl;
|
|
5746
6137
|
if (!repoExists) {
|
|
5747
|
-
const remoteUrl = await _GitManager.
|
|
6138
|
+
const remoteUrl = await _GitManager.resolveCloneRemoteUrl({
|
|
5748
6139
|
mode: options.mode,
|
|
5749
6140
|
remoteUrl: options.remoteUrl,
|
|
5750
6141
|
defaultRemoteUrl: options.defaultRemoteUrl,
|
|
@@ -5752,20 +6143,13 @@ var GitManager = class _GitManager {
|
|
|
5752
6143
|
sourceRoot: options.sourceRoot,
|
|
5753
6144
|
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
|
|
5754
6145
|
});
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
} catch (err) {
|
|
5761
|
-
throw new Error(`Failed to clone branch workspace at ${options.workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
|
|
5762
|
-
}
|
|
6146
|
+
await step("clone", () => _GitManager.cloneWorkspace(remoteUrl, options.workspacePath, baseBranch, {
|
|
6147
|
+
remoteName,
|
|
6148
|
+
noCheckout: options.branchType === "content",
|
|
6149
|
+
config: identity
|
|
6150
|
+
}));
|
|
5763
6151
|
justCloned = true;
|
|
5764
|
-
|
|
5765
|
-
freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(options.workspacePath) }));
|
|
5766
|
-
await freshGit.addConfig("canopycms.managed", "true");
|
|
5767
|
-
await freshGit.addConfig("user.name", options.gitBotAuthorName);
|
|
5768
|
-
await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
|
|
6152
|
+
resolvedRemoteUrl = remoteUrl;
|
|
5769
6153
|
}
|
|
5770
6154
|
const git = new _GitManager({
|
|
5771
6155
|
repoPath: options.workspacePath,
|
|
@@ -5773,32 +6157,38 @@ var GitManager = class _GitManager {
|
|
|
5773
6157
|
remote: remoteName,
|
|
5774
6158
|
skipIndexMarker: options.branchType === "orphan"
|
|
5775
6159
|
});
|
|
5776
|
-
await git.git.addConfig("canopycms.managed", "true");
|
|
5777
|
-
await git.git.addConfig("user.name", options.gitBotAuthorName);
|
|
5778
|
-
await git.git.addConfig("user.email", options.gitBotAuthorEmail);
|
|
5779
|
-
log5.debug("git", "Marked workspace as CanopyCMS-managed", {
|
|
5780
|
-
workspacePath: options.workspacePath
|
|
5781
|
-
});
|
|
5782
6160
|
if (!justCloned) {
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
remoteUrl
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
6161
|
+
await step("config", async () => {
|
|
6162
|
+
await git.ensureLocalConfig(identity);
|
|
6163
|
+
const remoteUrl = await _GitManager.resolveRemoteUrl({
|
|
6164
|
+
mode: options.mode,
|
|
6165
|
+
remoteUrl: options.remoteUrl,
|
|
6166
|
+
defaultRemoteUrl: options.defaultRemoteUrl,
|
|
6167
|
+
baseBranch,
|
|
6168
|
+
sourceRoot: options.sourceRoot,
|
|
6169
|
+
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
|
|
6170
|
+
});
|
|
6171
|
+
if (remoteUrl) {
|
|
6172
|
+
await git.ensureRemote(remoteUrl);
|
|
6173
|
+
}
|
|
6174
|
+
resolvedRemoteUrl = remoteUrl;
|
|
5790
6175
|
});
|
|
5791
|
-
if (remoteUrl) {
|
|
5792
|
-
await git.ensureRemote(remoteUrl);
|
|
5793
|
-
}
|
|
5794
6176
|
}
|
|
5795
6177
|
if (options.branchType === "orphan") {
|
|
5796
|
-
|
|
5797
|
-
|
|
6178
|
+
try {
|
|
6179
|
+
await step("checkout", () => git.createOrphanSettingsBranch(options.branchName, {}));
|
|
6180
|
+
} catch (err) {
|
|
6181
|
+
if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
|
|
6182
|
+
throw await _GitManager.remoteNotReadyError(options.mode) ?? err;
|
|
6183
|
+
}
|
|
6184
|
+
throw err;
|
|
6185
|
+
}
|
|
6186
|
+
await step("exclude", () => git.ensureGitExclude("*.lock"));
|
|
5798
6187
|
} else {
|
|
5799
|
-
await git.checkoutBranch(options.branchName);
|
|
5800
|
-
|
|
5801
|
-
|
|
6188
|
+
await step("checkout", () => justCloned ? git.checkoutFreshClone(options.branchName) : git.checkoutBranch(options.branchName));
|
|
6189
|
+
const pattern = options.gitExcludePattern;
|
|
6190
|
+
if (pattern) {
|
|
6191
|
+
await step("exclude", () => git.ensureGitExclude(pattern));
|
|
5802
6192
|
}
|
|
5803
6193
|
}
|
|
5804
6194
|
return git;
|
|
@@ -5840,6 +6230,21 @@ var GitManager = class _GitManager {
|
|
|
5840
6230
|
await this.invalidateContentIndexes();
|
|
5841
6231
|
}
|
|
5842
6232
|
}
|
|
6233
|
+
/**
|
|
6234
|
+
* The one checkout of a `--no-checkout` clone, which has the base branch
|
|
6235
|
+
* and its remote-tracking ref and nothing else, so neither the branch
|
|
6236
|
+
* listing nor the fetch that {@link checkoutBranch} starts with is needed.
|
|
6237
|
+
* The missing index makes git populate the whole tree, even for the base
|
|
6238
|
+
* branch HEAD already names.
|
|
6239
|
+
*/
|
|
6240
|
+
async checkoutFreshClone(branch) {
|
|
6241
|
+
const target = branch === this.baseBranch ? ["checkout", branch] : ["checkout", "-b", branch, `${this.remote}/${this.baseBranch}`];
|
|
6242
|
+
try {
|
|
6243
|
+
await this.git.raw([...PARALLEL_CHECKOUT_ARGS, ...target]);
|
|
6244
|
+
} finally {
|
|
6245
|
+
await this.invalidateContentIndexes();
|
|
6246
|
+
}
|
|
6247
|
+
}
|
|
5843
6248
|
async checkoutBranchInner(branch) {
|
|
5844
6249
|
const branches = await this.git.branch();
|
|
5845
6250
|
if (branches.all.includes(branch)) {
|
|
@@ -5902,7 +6307,7 @@ var GitManager = class _GitManager {
|
|
|
5902
6307
|
const branches = await this.git.branch();
|
|
5903
6308
|
const currentBranch = branches.current;
|
|
5904
6309
|
try {
|
|
5905
|
-
await this.git.fetch(this.remote, currentBranch);
|
|
6310
|
+
await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
|
|
5906
6311
|
} catch (err) {
|
|
5907
6312
|
if (!isMissingRemoteRefFailure(getErrorMessage(err)))
|
|
5908
6313
|
throw err;
|
|
@@ -5955,23 +6360,46 @@ var GitManager = class _GitManager {
|
|
|
5955
6360
|
throw err;
|
|
5956
6361
|
}
|
|
5957
6362
|
}
|
|
6363
|
+
/** `--sparse` stages a named path outside a sparse clone's cone (branch-sparse.ts). */
|
|
5958
6364
|
async add(files) {
|
|
5959
6365
|
const fileArray = Array.isArray(files) ? files : [files];
|
|
5960
|
-
await this.git.add
|
|
6366
|
+
await this.git.raw(["add", "--sparse", "--", ...fileArray]);
|
|
6367
|
+
}
|
|
6368
|
+
/**
|
|
6369
|
+
* Restrict the working tree to `dirs` in cone mode, which always keeps the root-level files.
|
|
6370
|
+
* Runs before a `--no-checkout` clone's one checkout, which invalidates the content caches.
|
|
6371
|
+
*/
|
|
6372
|
+
async setSparseCone(dirs) {
|
|
6373
|
+
await this.git.raw(["sparse-checkout", "set", "--cone", "--", ...dirs]);
|
|
6374
|
+
}
|
|
6375
|
+
/** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
|
|
6376
|
+
async addAllExceptCanopyState() {
|
|
6377
|
+
await stageAllExceptCanopyState(this.git);
|
|
5961
6378
|
}
|
|
5962
6379
|
async commit(message) {
|
|
5963
6380
|
await this.git.commit(message);
|
|
5964
6381
|
}
|
|
5965
6382
|
async push(branch) {
|
|
5966
|
-
const target = branch ?? await this.
|
|
6383
|
+
const target = branch ?? await this.currentBranchName();
|
|
5967
6384
|
await this.git.raw([
|
|
5968
6385
|
"push",
|
|
5969
6386
|
"--set-upstream",
|
|
5970
6387
|
"--end-of-options",
|
|
5971
6388
|
this.remote,
|
|
5972
|
-
|
|
6389
|
+
`refs/heads/${target}:refs/heads/${target}`
|
|
5973
6390
|
]);
|
|
5974
6391
|
}
|
|
6392
|
+
/**
|
|
6393
|
+
* The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
|
|
6394
|
+
* `heads/<name>` when a same-named tag exists, which is not a branch name.
|
|
6395
|
+
*/
|
|
6396
|
+
async currentBranchName() {
|
|
6397
|
+
const branches = await this.git.branch();
|
|
6398
|
+
if (branches.detached || !branches.current) {
|
|
6399
|
+
throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
|
|
6400
|
+
}
|
|
6401
|
+
return branches.current;
|
|
6402
|
+
}
|
|
5975
6403
|
/**
|
|
5976
6404
|
* Whether the local branch has commits the remote mirror doesn't -- i.e.
|
|
5977
6405
|
* whether push() would move the remote ref forward.
|
|
@@ -5990,12 +6418,12 @@ var GitManager = class _GitManager {
|
|
|
5990
6418
|
* ("couldn't find remote ref"); that counts as "ahead", not an error.
|
|
5991
6419
|
*/
|
|
5992
6420
|
async hasUnpushedCommits(branch) {
|
|
5993
|
-
const target = branch ?? await this.git.revparse(["--abbrev-ref", "--end-of-options", "HEAD"]);
|
|
5994
|
-
const localSha = (await this.git.revparse(["--end-of-options", target])).trim();
|
|
6421
|
+
const target = branch ?? await this.git.revparse(["--verify", "--abbrev-ref", "--end-of-options", "HEAD"]);
|
|
6422
|
+
const localSha = (await this.git.revparse(["--verify", "--end-of-options", target])).trim();
|
|
5995
6423
|
let fetchedTip;
|
|
5996
6424
|
try {
|
|
5997
6425
|
await this.git.raw(["fetch", "--end-of-options", this.remote, target]);
|
|
5998
|
-
fetchedTip = (await this.git.revparse(["--end-of-options", "FETCH_HEAD"])).trim();
|
|
6426
|
+
fetchedTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
|
|
5999
6427
|
} catch {
|
|
6000
6428
|
return true;
|
|
6001
6429
|
}
|
|
@@ -6004,6 +6432,25 @@ var GitManager = class _GitManager {
|
|
|
6004
6432
|
const aheadCount = (await this.git.raw(["rev-list", "--count", `${fetchedTip}..${localSha}`])).trim();
|
|
6005
6433
|
return aheadCount !== "0";
|
|
6006
6434
|
}
|
|
6435
|
+
/**
|
|
6436
|
+
* Repo-relative paths that differ between HEAD and its merge base with the
|
|
6437
|
+
* base branch: everything the branch changes, across all of its commits.
|
|
6438
|
+
* Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
|
|
6439
|
+
* constraint.
|
|
6440
|
+
*/
|
|
6441
|
+
async listChangedPathsSinceBase() {
|
|
6442
|
+
await this.git.raw(["fetch", "--end-of-options", this.remote, this.baseBranch]);
|
|
6443
|
+
const baseTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
|
|
6444
|
+
const output = await this.git.raw([
|
|
6445
|
+
"diff",
|
|
6446
|
+
"--name-only",
|
|
6447
|
+
"-z",
|
|
6448
|
+
"--no-renames",
|
|
6449
|
+
`${baseTip}...HEAD`,
|
|
6450
|
+
"--"
|
|
6451
|
+
]);
|
|
6452
|
+
return output.split("\0").filter((p) => p.length > 0);
|
|
6453
|
+
}
|
|
6007
6454
|
async ensureAuthor(author) {
|
|
6008
6455
|
const config = await this.git.listConfig();
|
|
6009
6456
|
const isManaged = config.all["canopycms.managed"] === "true";
|
|
@@ -6019,6 +6466,14 @@ var GitManager = class _GitManager {
|
|
|
6019
6466
|
await this.git.addConfig("user.email", author.email);
|
|
6020
6467
|
}
|
|
6021
6468
|
}
|
|
6469
|
+
/** Writes each key into this repo's own config only where it differs. */
|
|
6470
|
+
async ensureLocalConfig(values) {
|
|
6471
|
+
const local = await this.git.listConfig("local");
|
|
6472
|
+
for (const [key, value] of Object.entries(values)) {
|
|
6473
|
+
if (local.all[key] !== value)
|
|
6474
|
+
await this.git.addConfig(key, value);
|
|
6475
|
+
}
|
|
6476
|
+
}
|
|
6022
6477
|
async ensureRemote(remoteUrl) {
|
|
6023
6478
|
const config = await this.git.listConfig();
|
|
6024
6479
|
const isManaged = config.all["canopycms.managed"] === "true";
|
|
@@ -6057,10 +6512,24 @@ var GitManager = class _GitManager {
|
|
|
6057
6512
|
await ensureGitExcludePattern(this.repoPath, pattern);
|
|
6058
6513
|
}
|
|
6059
6514
|
/**
|
|
6060
|
-
*
|
|
6061
|
-
* deployment-specific settings never pollute content history. It holds
|
|
6062
|
-
* settings files committed by explicit path (permissions.json,
|
|
6063
|
-
* the workspace root).
|
|
6515
|
+
* Put the workspace on the settings branch: an orphan (no shared history),
|
|
6516
|
+
* so deployment-specific settings never pollute content history. It holds
|
|
6517
|
+
* only settings files committed by explicit path (permissions.json,
|
|
6518
|
+
* groups.json at the workspace root).
|
|
6519
|
+
*
|
|
6520
|
+
* The remote is the settings branch's durable copy, and the workspace clone
|
|
6521
|
+
* is `--single-branch` at the base branch, so the remote is asked first:
|
|
6522
|
+
* - no local branch: check out the remote's, or create an empty orphan only
|
|
6523
|
+
* when the remote has none. An unreadable remote throws
|
|
6524
|
+
* {@link SettingsRemoteUnreadableError} rather than guess "none".
|
|
6525
|
+
* - local branch present: a history unrelated to the remote's is repaired
|
|
6526
|
+
* onto the remote's when the local branch is still its empty initial
|
|
6527
|
+
* commit with a clean working tree, and otherwise throws
|
|
6528
|
+
* {@link SettingsBranchDivergedError}; local commits are never discarded.
|
|
6529
|
+
* An unreadable remote throws only for that empty branch, since one that
|
|
6530
|
+
* holds settings can serve them and the next save surfaces the remote error.
|
|
6531
|
+
* Either way a remote branch holding content is never adopted:
|
|
6532
|
+
* {@link SettingsBranchHasContentHistoryError}.
|
|
6064
6533
|
*/
|
|
6065
6534
|
async createOrphanSettingsBranch(branchName, initialFiles) {
|
|
6066
6535
|
try {
|
|
@@ -6070,56 +6539,709 @@ var GitManager = class _GitManager {
|
|
|
6070
6539
|
}
|
|
6071
6540
|
}
|
|
6072
6541
|
async createOrphanSettingsBranchInner(branchName, initialFiles) {
|
|
6073
|
-
|
|
6542
|
+
log6.debug("git", "Ensuring settings branch", { branchName });
|
|
6074
6543
|
const branches = await this.git.branch();
|
|
6075
|
-
|
|
6076
|
-
|
|
6544
|
+
const localExists = branches.all.includes(branchName);
|
|
6545
|
+
if (localExists) {
|
|
6077
6546
|
await this.git.checkout(branchName);
|
|
6547
|
+
}
|
|
6548
|
+
let remoteTip;
|
|
6549
|
+
try {
|
|
6550
|
+
remoteTip = await this.remoteBranchTip(branchName);
|
|
6551
|
+
} catch (err) {
|
|
6552
|
+
if (localExists && !await this.isEmptyInitialBranch(branchName)) {
|
|
6553
|
+
log6.debug("git", "Remote unreadable; serving the local settings branch", { branchName });
|
|
6554
|
+
return;
|
|
6555
|
+
}
|
|
6556
|
+
throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
|
|
6557
|
+
}
|
|
6558
|
+
if (localExists) {
|
|
6559
|
+
await this.reconcileLocalSettingsBranch(branchName, remoteTip);
|
|
6560
|
+
return;
|
|
6561
|
+
}
|
|
6562
|
+
if (remoteTip) {
|
|
6563
|
+
const fetchedTip = await this.fetchBranchTip(branchName);
|
|
6564
|
+
await this.assertNotContentHistory(branchName, fetchedTip);
|
|
6565
|
+
await this.git.raw(["checkout", "-b", branchName, fetchedTip]);
|
|
6566
|
+
log6.debug("git", "Checked out the remote settings branch", { branchName });
|
|
6078
6567
|
return;
|
|
6079
6568
|
}
|
|
6080
6569
|
await this.git.raw(["checkout", "--orphan", branchName]);
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6570
|
+
await this.git.raw(["rm", "-r", "-f", "-q", "--sparse", "--ignore-unmatch", "--", "."]);
|
|
6571
|
+
const leftover = (await this.git.raw(["ls-files"])).trim();
|
|
6572
|
+
if (leftover) {
|
|
6573
|
+
throw new Error(`Settings branch '${branchName}' was not created: the base branch's files (${leftover.split("\n").slice(0, 3).join(", ")}) stayed in its index`);
|
|
6084
6574
|
}
|
|
6085
6575
|
for (const [filePath, content] of Object.entries(initialFiles)) {
|
|
6086
6576
|
const absolutePath = path17.join(this.repoPath, filePath);
|
|
6087
|
-
await
|
|
6088
|
-
await
|
|
6577
|
+
await fs15.mkdir(path17.dirname(absolutePath), { recursive: true });
|
|
6578
|
+
await fs15.writeFile(absolutePath, content, "utf-8");
|
|
6089
6579
|
await this.git.add(filePath);
|
|
6090
6580
|
}
|
|
6091
6581
|
await this.git.commit("Initialize settings branch", ["--allow-empty"]);
|
|
6092
|
-
|
|
6582
|
+
log6.debug("git", "Orphan settings branch created", { branchName });
|
|
6583
|
+
}
|
|
6584
|
+
/**
|
|
6585
|
+
* Compares the checked-out local settings branch with the remote's tip.
|
|
6586
|
+
* Related histories are left for the next settings pull to reconcile; only
|
|
6587
|
+
* an unrelated one is acted on here.
|
|
6588
|
+
*/
|
|
6589
|
+
async reconcileLocalSettingsBranch(branchName, remoteTip) {
|
|
6590
|
+
if (!remoteTip)
|
|
6591
|
+
return;
|
|
6592
|
+
const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
|
|
6593
|
+
if (localTip === remoteTip)
|
|
6594
|
+
return;
|
|
6595
|
+
const fetchedTip = await this.fetchBranchTip(branchName);
|
|
6596
|
+
const [localRoots, remoteRoots] = await Promise.all([
|
|
6597
|
+
this.rootCommits(localTip),
|
|
6598
|
+
this.rootCommits(fetchedTip)
|
|
6599
|
+
]);
|
|
6600
|
+
if (localRoots.some((root) => remoteRoots.includes(root)))
|
|
6601
|
+
return;
|
|
6602
|
+
const localWork = [];
|
|
6603
|
+
if (!await this.isEmptyInitialBranch(branchName)) {
|
|
6604
|
+
localWork.push("commits beyond its empty initial commit");
|
|
6605
|
+
}
|
|
6606
|
+
const uncommitted = await this.getUncommittedFiles();
|
|
6607
|
+
if (uncommitted.length > 0) {
|
|
6608
|
+
localWork.push(`uncommitted changes to ${uncommitted.join(", ")}`);
|
|
6609
|
+
}
|
|
6610
|
+
if (localWork.length > 0) {
|
|
6611
|
+
throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(" and "));
|
|
6612
|
+
}
|
|
6613
|
+
await this.assertNotContentHistory(branchName, fetchedTip);
|
|
6614
|
+
await this.git.raw(["checkout", "-B", branchName, fetchedTip]);
|
|
6615
|
+
log6.debug("git", "Replaced an empty settings branch with the remote one", { branchName });
|
|
6616
|
+
}
|
|
6617
|
+
/**
|
|
6618
|
+
* The remote's tip for `branch`, or `undefined` when the remote has no such
|
|
6619
|
+
* branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
|
|
6620
|
+
* output only for a reachable remote without the ref.
|
|
6621
|
+
*/
|
|
6622
|
+
async remoteBranchTip(branch) {
|
|
6623
|
+
const ref = `refs/heads/${branch}`;
|
|
6624
|
+
const output = await this.git.raw(["ls-remote", "--heads", this.remote, ref]);
|
|
6625
|
+
for (const line of output.split("\n")) {
|
|
6626
|
+
const [sha, name] = line.trim().split(" ");
|
|
6627
|
+
if (sha && name === ref)
|
|
6628
|
+
return sha;
|
|
6629
|
+
}
|
|
6630
|
+
return void 0;
|
|
6631
|
+
}
|
|
6632
|
+
/**
|
|
6633
|
+
* Fetch `branch` from the remote and return the fetched commit, pinned as
|
|
6634
|
+
* pullBaseInner explains. The full ref: a bare name resolves to a
|
|
6635
|
+
* same-named tag first.
|
|
6636
|
+
*/
|
|
6637
|
+
async fetchBranchTip(branch) {
|
|
6638
|
+
await this.git.fetch(this.remote, `refs/heads/${branch}`);
|
|
6639
|
+
return (await this.git.revparse(["FETCH_HEAD"])).trim();
|
|
6640
|
+
}
|
|
6641
|
+
/**
|
|
6642
|
+
* Refuse to adopt a remote settings branch that holds content: its tip has a file other than
|
|
6643
|
+
* the settings files, or its roots include the base branch's (a settings branch is an orphan).
|
|
6644
|
+
* Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
|
|
6645
|
+
* The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
|
|
6646
|
+
*/
|
|
6647
|
+
async assertNotContentHistory(branchName, tip) {
|
|
6648
|
+
const tree = await this.git.raw(["ls-tree", "-r", "--name-only", tip]);
|
|
6649
|
+
const strays = tree.split("\n").filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
|
|
6650
|
+
if (strays.length > 0) {
|
|
6651
|
+
const shown = strays.slice(0, 3).join(", ") + (strays.length > 3 ? ", \u2026" : "");
|
|
6652
|
+
throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
|
|
6653
|
+
}
|
|
6654
|
+
const [tipRoots, baseRoots] = await Promise.all([
|
|
6655
|
+
this.rootCommits(tip),
|
|
6656
|
+
this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev))
|
|
6657
|
+
]);
|
|
6658
|
+
if (tipRoots.some((root) => baseRoots.includes(root))) {
|
|
6659
|
+
throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
|
|
6660
|
+
}
|
|
6661
|
+
}
|
|
6662
|
+
async baseBranchRev(settingsBranch) {
|
|
6663
|
+
for (const ref of [
|
|
6664
|
+
`refs/heads/${this.baseBranch}`,
|
|
6665
|
+
`refs/remotes/${this.remote}/${this.baseBranch}`
|
|
6666
|
+
]) {
|
|
6667
|
+
const sha = await this.git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
|
|
6668
|
+
if (sha.trim())
|
|
6669
|
+
return sha.trim();
|
|
6670
|
+
}
|
|
6671
|
+
try {
|
|
6672
|
+
return await this.fetchBranchTip(this.baseBranch);
|
|
6673
|
+
} catch (err) {
|
|
6674
|
+
throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote '${this.remote}', so settings branch '${settingsBranch}' was not checked out: ${getErrorMessage(err)}`);
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
async rootCommits(rev) {
|
|
6678
|
+
const output = await this.git.raw(["rev-list", "--max-parents=0", rev]);
|
|
6679
|
+
return output.split("\n").filter(Boolean);
|
|
6680
|
+
}
|
|
6681
|
+
/**
|
|
6682
|
+
* Whether `branch` is a single parentless commit with an empty tree, as
|
|
6683
|
+
* orphan creation leaves it. Full refs throughout: a bare name resolves to a
|
|
6684
|
+
* same-named tag first, and clones fetch tags.
|
|
6685
|
+
*/
|
|
6686
|
+
async isEmptyInitialBranch(branch) {
|
|
6687
|
+
const ref = `refs/heads/${branch}`;
|
|
6688
|
+
const commits = await this.git.raw(["rev-list", "--max-count=2", ref]);
|
|
6689
|
+
if (commits.split("\n").filter(Boolean).length !== 1)
|
|
6690
|
+
return false;
|
|
6691
|
+
const tree = await this.git.raw(["ls-tree", ref]);
|
|
6692
|
+
return tree.trim() === "";
|
|
6693
|
+
}
|
|
6694
|
+
};
|
|
6695
|
+
|
|
6696
|
+
// dist/branch-provisioning.js
|
|
6697
|
+
init_logger();
|
|
6698
|
+
var PUBLISH_LOCK_WAIT_MS = 8e3;
|
|
6699
|
+
var CREATE_RESIDUE_MIN_QUIET_MS = 6e4;
|
|
6700
|
+
var STAGING_MAX_AGE_MS = 20 * 6e4;
|
|
6701
|
+
var REPAIR_MAX_AGE_MS = 20 * 6e4;
|
|
6702
|
+
var DELETING_MAX_AGE_MS = 10 * 6e4;
|
|
6703
|
+
var SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 6e4;
|
|
6704
|
+
var BranchProvisioningBusyError = class extends Error {
|
|
6705
|
+
constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
|
|
6706
|
+
super(message);
|
|
6707
|
+
this.dirName = dirName;
|
|
6708
|
+
this.name = "BranchProvisioningBusyError";
|
|
6709
|
+
}
|
|
6710
|
+
};
|
|
6711
|
+
var BranchDirOccupiedError = class extends Error {
|
|
6712
|
+
constructor(dirName) {
|
|
6713
|
+
super(`A directory named '${dirName}' already exists in the branch workspace and holds a git repository this deployment did not create. It was left untouched; an admin can purge it from System Health, or choose another branch name.`);
|
|
6714
|
+
this.dirName = dirName;
|
|
6715
|
+
this.name = "BranchDirOccupiedError";
|
|
6716
|
+
}
|
|
6717
|
+
};
|
|
6718
|
+
function formatDirStamp(date) {
|
|
6719
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
6720
|
+
}
|
|
6721
|
+
function shortDirName(dirName) {
|
|
6722
|
+
const digest = createHash("sha1").update(dirName).digest("hex").slice(0, 10);
|
|
6723
|
+
return `${dirName.slice(0, 40)}-${digest}`;
|
|
6724
|
+
}
|
|
6725
|
+
function stampedName(prefix, dirName, now) {
|
|
6726
|
+
return `${prefix}${shortDirName(dirName)}-${randomBytes(3).toString("hex")}-${formatDirStamp(now)}`;
|
|
6727
|
+
}
|
|
6728
|
+
var STAGING_PREFIX = ".prov-";
|
|
6729
|
+
var REPAIR_PREFIX = ".repair-";
|
|
6730
|
+
var TRASH_PREFIX = ".trash-";
|
|
6731
|
+
var stagingDirName = (dirName, now = /* @__PURE__ */ new Date()) => stampedName(STAGING_PREFIX, dirName, now);
|
|
6732
|
+
var NO_GIT = { hasGitDir: false, configLock: false, hasIndex: false };
|
|
6733
|
+
async function listDir(dirPath) {
|
|
6734
|
+
try {
|
|
6735
|
+
return await fs16.readdir(dirPath);
|
|
6736
|
+
} catch (err) {
|
|
6737
|
+
if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
|
|
6738
|
+
return null;
|
|
6739
|
+
throw err;
|
|
6740
|
+
}
|
|
6741
|
+
}
|
|
6742
|
+
function gitConfigRemoteUrls(text) {
|
|
6743
|
+
const urls = [];
|
|
6744
|
+
let inRemote = false;
|
|
6745
|
+
for (const raw of text.split("\n")) {
|
|
6746
|
+
const line = raw.trim();
|
|
6747
|
+
if (line.startsWith("[")) {
|
|
6748
|
+
inRemote = /^\[\s*remote\s+"[^"]*"\s*\]/i.test(line);
|
|
6749
|
+
continue;
|
|
6750
|
+
}
|
|
6751
|
+
const match = inRemote ? /^url\s*=\s*(.*)$/i.exec(line) : null;
|
|
6752
|
+
if (match)
|
|
6753
|
+
urls.push(match[1].trim().replace(/^"(.*)"$/, "$1"));
|
|
6754
|
+
}
|
|
6755
|
+
return urls;
|
|
6756
|
+
}
|
|
6757
|
+
function sameRemote(url, expected) {
|
|
6758
|
+
if (isNetworkRemoteUrl(url) || isNetworkRemoteUrl(expected))
|
|
6759
|
+
return url === expected;
|
|
6760
|
+
const local = (value) => path18.resolve(value.replace(/^file:\/\//i, ""));
|
|
6761
|
+
return local(url) === local(expected);
|
|
6762
|
+
}
|
|
6763
|
+
async function classifyFinalDir(dirPath, expectedRemoteUrl) {
|
|
6764
|
+
let stat;
|
|
6765
|
+
try {
|
|
6766
|
+
stat = await fs16.lstat(dirPath);
|
|
6767
|
+
} catch (err) {
|
|
6768
|
+
if (isNotFoundError(err))
|
|
6769
|
+
return { kind: "vacant" };
|
|
6770
|
+
throw err;
|
|
6771
|
+
}
|
|
6772
|
+
if (!stat.isDirectory())
|
|
6773
|
+
return { kind: "residue", signature: NO_GIT };
|
|
6774
|
+
const metaEntries = await listDir(path18.join(dirPath, BRANCH_META_DIR)) ?? [];
|
|
6775
|
+
if (metaEntries.includes(BRANCH_META_FILE)) {
|
|
6776
|
+
try {
|
|
6777
|
+
return await readBranchMetadataFile(dirPath) ? { kind: "live" } : { kind: "protected" };
|
|
6778
|
+
} catch (err) {
|
|
6779
|
+
if (err instanceof BranchMetadataCorruptError)
|
|
6780
|
+
return { kind: "corrupt" };
|
|
6781
|
+
throw err;
|
|
6782
|
+
}
|
|
6783
|
+
}
|
|
6784
|
+
if (metaEntries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`))) {
|
|
6785
|
+
return { kind: "protected" };
|
|
6786
|
+
}
|
|
6787
|
+
const gitPath = path18.join(dirPath, ".git");
|
|
6788
|
+
let gitStat;
|
|
6789
|
+
try {
|
|
6790
|
+
gitStat = await fs16.lstat(gitPath);
|
|
6791
|
+
} catch (err) {
|
|
6792
|
+
if (isNotFoundError(err))
|
|
6793
|
+
return { kind: "residue", signature: NO_GIT };
|
|
6794
|
+
throw err;
|
|
6795
|
+
}
|
|
6796
|
+
if (!gitStat.isDirectory())
|
|
6797
|
+
return { kind: "foreign" };
|
|
6798
|
+
const gitEntries = await listDir(gitPath) ?? [];
|
|
6799
|
+
const signature = {
|
|
6800
|
+
hasGitDir: true,
|
|
6801
|
+
configLock: gitEntries.includes("config.lock"),
|
|
6802
|
+
hasIndex: gitEntries.includes("index")
|
|
6803
|
+
};
|
|
6804
|
+
let config;
|
|
6805
|
+
try {
|
|
6806
|
+
config = await fs16.readFile(path18.join(gitPath, "config"), "utf8");
|
|
6807
|
+
} catch {
|
|
6808
|
+
return { kind: "residue", signature };
|
|
6809
|
+
}
|
|
6810
|
+
return gitConfigRemoteUrls(config).some((url) => sameRemote(url, expectedRemoteUrl)) ? { kind: "residue", signature } : { kind: "foreign" };
|
|
6811
|
+
}
|
|
6812
|
+
async function quietAgeMs(dirPath, now = Date.now()) {
|
|
6813
|
+
const mtimes = await Promise.all(["", ".git", BRANCH_META_DIR].map((sub) => fs16.lstat(path18.join(dirPath, sub)).then((stat) => stat.mtimeMs, () => 0)));
|
|
6814
|
+
return now - Math.max(...mtimes);
|
|
6815
|
+
}
|
|
6816
|
+
async function quarantineResidueAt(baseRoot, dirName, options) {
|
|
6817
|
+
const finalPath = path18.join(baseRoot, dirName);
|
|
6818
|
+
let inode;
|
|
6819
|
+
try {
|
|
6820
|
+
inode = (await fs16.lstat(finalPath)).ino;
|
|
6821
|
+
} catch (err) {
|
|
6822
|
+
if (isNotFoundError(err))
|
|
6823
|
+
return { kind: "vacant" };
|
|
6824
|
+
throw err;
|
|
6825
|
+
}
|
|
6826
|
+
const candidateName = stampedName(REPAIR_PREFIX, dirName, /* @__PURE__ */ new Date());
|
|
6827
|
+
const candidate = path18.join(baseRoot, candidateName);
|
|
6828
|
+
try {
|
|
6829
|
+
await fs16.rename(finalPath, candidate);
|
|
6830
|
+
} catch (err) {
|
|
6831
|
+
if (isNotFoundError(err))
|
|
6832
|
+
return { kind: "vacant" };
|
|
6833
|
+
throw err;
|
|
6834
|
+
}
|
|
6835
|
+
const moveBack = async (why) => {
|
|
6836
|
+
try {
|
|
6837
|
+
await fs16.rename(candidate, finalPath);
|
|
6838
|
+
} catch (err) {
|
|
6839
|
+
canopyLogError(`[canopy] Could not move '${dirName}' back from ${candidateName} after ${why}: ${getErrorMessage(err)}. The worker's sweep restores or trashes it.`);
|
|
6840
|
+
}
|
|
6841
|
+
};
|
|
6842
|
+
const { state, quiet, reason } = await judgeQuarantineCandidate(candidate, inode, options).catch(async (err) => {
|
|
6843
|
+
await moveBack(`failing to judge it (${getErrorMessage(err)})`);
|
|
6844
|
+
throw err;
|
|
6845
|
+
});
|
|
6846
|
+
if (reason === void 0 && state.kind === "residue") {
|
|
6847
|
+
const trashName = stampedName(TRASH_PREFIX, dirName, /* @__PURE__ */ new Date());
|
|
6848
|
+
await fs16.rename(candidate, path18.join(baseRoot, trashName));
|
|
6849
|
+
const { hasGitDir, configLock, hasIndex } = state.signature;
|
|
6850
|
+
canopyLogWarn(`[canopy] Quarantined unfinished branch directory '${dirName}' as ${trashName} (git=${hasGitDir} config.lock=${configLock} index=${hasIndex} quiet=${Math.round(quiet / 1e3)}s)`);
|
|
6851
|
+
return { kind: "quarantined", trashName };
|
|
6852
|
+
}
|
|
6853
|
+
await moveBack(`declining to quarantine it (${reason})`);
|
|
6854
|
+
return { kind: "kept", reason: reason ?? "replaced" };
|
|
6855
|
+
}
|
|
6856
|
+
async function judgeQuarantineCandidate(candidate, inode, options) {
|
|
6857
|
+
const state = await classifyFinalDir(candidate, options.expectedRemoteUrl);
|
|
6858
|
+
const moved = await fs16.lstat(candidate).catch(() => null);
|
|
6859
|
+
const quiet = await quietAgeMs(candidate, options.now);
|
|
6860
|
+
let reason;
|
|
6861
|
+
if (!moved || moved.ino !== inode)
|
|
6862
|
+
reason = "replaced";
|
|
6863
|
+
else if (state.kind === "vacant")
|
|
6864
|
+
reason = "replaced";
|
|
6865
|
+
else if (state.kind !== "residue")
|
|
6866
|
+
reason = state.kind;
|
|
6867
|
+
else if (quiet < options.minQuietMs)
|
|
6868
|
+
reason = "too-young";
|
|
6869
|
+
return { state, quiet, reason };
|
|
6870
|
+
}
|
|
6871
|
+
async function removeLeftoverDir(dirPath) {
|
|
6872
|
+
try {
|
|
6873
|
+
await fs16.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
6874
|
+
} catch (err) {
|
|
6875
|
+
canopyLogWarn(`[canopy] Could not remove ${dirPath}; the worker sweeps it: ${getErrorMessage(err)}`);
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
6878
|
+
async function hasBranchMetadata(branchRoot) {
|
|
6879
|
+
return fs16.access(path18.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
|
|
6880
|
+
}
|
|
6881
|
+
async function stageBranchWorkspace(options) {
|
|
6882
|
+
const { provisionLog: log8 } = options;
|
|
6883
|
+
const stagingPath = path18.join(options.baseRoot, stagingDirName(options.dirName));
|
|
6884
|
+
try {
|
|
6885
|
+
await log8.step("clone", () => GitManager.cloneWorkspace(options.remoteUrl, stagingPath, options.baseBranch, {
|
|
6886
|
+
...options.clone,
|
|
6887
|
+
noCheckout: true
|
|
6888
|
+
}));
|
|
6889
|
+
const git = new GitManager({
|
|
6890
|
+
repoPath: stagingPath,
|
|
6891
|
+
baseBranch: options.baseBranch,
|
|
6892
|
+
remote: options.clone.remoteName
|
|
6893
|
+
});
|
|
6894
|
+
const cone = options.sparseCone;
|
|
6895
|
+
if (cone)
|
|
6896
|
+
await log8.step("sparse", () => git.setSparseCone(cone));
|
|
6897
|
+
if (await hasBranchMetadata(path18.join(options.baseRoot, options.dirName))) {
|
|
6898
|
+
await removeLeftoverDir(stagingPath);
|
|
6899
|
+
return { kind: "exists" };
|
|
6900
|
+
}
|
|
6901
|
+
await log8.step("checkout", () => git.checkoutFreshClone(options.branchName));
|
|
6902
|
+
await log8.step("exclude", () => git.ensureGitExclude(options.gitExcludePattern));
|
|
6903
|
+
const written = await log8.step("metadata", () => writeOccJsonFile(path18.join(stagingPath, BRANCH_META_DIR, BRANCH_META_FILE), { ...options.metadata }, { expectedVersion: null, trailingNewline: true }));
|
|
6904
|
+
return {
|
|
6905
|
+
kind: "staged",
|
|
6906
|
+
stagingPath,
|
|
6907
|
+
meta: { ...options.metadata, version: written.version, writeId: written.writeId }
|
|
6908
|
+
};
|
|
6909
|
+
} catch (err) {
|
|
6910
|
+
await removeLeftoverDir(stagingPath);
|
|
6911
|
+
throw err;
|
|
6912
|
+
}
|
|
6913
|
+
}
|
|
6914
|
+
async function lockBranchName(baseRoot, dirName) {
|
|
6915
|
+
try {
|
|
6916
|
+
return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
|
|
6917
|
+
} catch (err) {
|
|
6918
|
+
if (isNodeError(err) && err.code === "ELOCKED")
|
|
6919
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
6920
|
+
throw err;
|
|
6921
|
+
}
|
|
6922
|
+
}
|
|
6923
|
+
async function inspectBlocking(finalPath) {
|
|
6924
|
+
const entries = await listDir(path18.join(finalPath, BRANCH_META_DIR)) ?? [];
|
|
6925
|
+
if (entries.includes(BRANCH_META_FILE))
|
|
6926
|
+
return "live";
|
|
6927
|
+
if (entries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`)))
|
|
6928
|
+
return "protected";
|
|
6929
|
+
return "residue";
|
|
6930
|
+
}
|
|
6931
|
+
var RENAME_BLOCKED = /* @__PURE__ */ new Set(["EEXIST", "ENOTEMPTY", "ENOTDIR"]);
|
|
6932
|
+
async function publishStaging(options) {
|
|
6933
|
+
const { baseRoot, dirName, stagingPath } = options;
|
|
6934
|
+
const finalPath = path18.join(baseRoot, dirName);
|
|
6935
|
+
const release = await lockBranchName(baseRoot, dirName);
|
|
6936
|
+
try {
|
|
6937
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
6938
|
+
try {
|
|
6939
|
+
await fs16.rename(stagingPath, finalPath);
|
|
6940
|
+
return "published";
|
|
6941
|
+
} catch (err) {
|
|
6942
|
+
if (!isNodeError(err))
|
|
6943
|
+
throw err;
|
|
6944
|
+
if (err.code === "ENOENT") {
|
|
6945
|
+
const meta = await readBranchMetadataFile(finalPath).catch(() => null);
|
|
6946
|
+
if (meta?.writeId !== void 0 && meta.writeId === options.writeId)
|
|
6947
|
+
return "published";
|
|
6948
|
+
throw err;
|
|
6949
|
+
}
|
|
6950
|
+
if (!RENAME_BLOCKED.has(err.code ?? ""))
|
|
6951
|
+
throw err;
|
|
6952
|
+
}
|
|
6953
|
+
const blocking = await (options.inspectBlocked ?? inspectBlocking)(finalPath);
|
|
6954
|
+
if (blocking === "live")
|
|
6955
|
+
return "exists";
|
|
6956
|
+
if (blocking === "protected")
|
|
6957
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
6958
|
+
const result = await quarantineResidueAt(baseRoot, dirName, {
|
|
6959
|
+
minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
|
|
6960
|
+
expectedRemoteUrl: options.expectedRemoteUrl
|
|
6961
|
+
});
|
|
6962
|
+
if (result.kind !== "kept")
|
|
6963
|
+
continue;
|
|
6964
|
+
if (result.reason === "live")
|
|
6965
|
+
return "exists";
|
|
6966
|
+
if (result.reason === "corrupt") {
|
|
6967
|
+
await readBranchMetadataFile(finalPath);
|
|
6968
|
+
return "exists";
|
|
6969
|
+
}
|
|
6970
|
+
if (result.reason === "foreign")
|
|
6971
|
+
throw new BranchDirOccupiedError(dirName);
|
|
6972
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
6973
|
+
}
|
|
6974
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
6975
|
+
} finally {
|
|
6976
|
+
await release().catch((err) => {
|
|
6977
|
+
canopyLogWarn(`[canopy] Failed to release the provisioning lock for ${dirName}:`, err);
|
|
6978
|
+
});
|
|
6979
|
+
}
|
|
6980
|
+
}
|
|
6981
|
+
|
|
6982
|
+
// dist/branch-sparse.js
|
|
6983
|
+
import fs17 from "node:fs/promises";
|
|
6984
|
+
import path19 from "node:path";
|
|
6985
|
+
init_operating_mode();
|
|
6986
|
+
init_logger();
|
|
6987
|
+
function sparseConeFor(contentRoot) {
|
|
6988
|
+
const root = (contentRoot ?? "content").split("/").filter((segment) => segment && segment !== ".").join("/");
|
|
6989
|
+
return root ? [.../* @__PURE__ */ new Set([CANOPY_META_DIR, root])].sort() : null;
|
|
6990
|
+
}
|
|
6991
|
+
function sameCone(a, b) {
|
|
6992
|
+
if (a === null || b === null)
|
|
6993
|
+
return a === b;
|
|
6994
|
+
const [left, right] = [[...a].sort(), [...b].sort()];
|
|
6995
|
+
return left.length === right.length && left.every((dir, i) => dir === right[i]);
|
|
6996
|
+
}
|
|
6997
|
+
var SPARSE_CONE_RECORD = ".sparse-cone.json";
|
|
6998
|
+
function isConeDir(dir) {
|
|
6999
|
+
return typeof dir === "string" && dir.length > 0 && !path19.isAbsolute(dir) && !dir.split("/").some((segment) => segment === "" || segment === "." || segment === "..");
|
|
7000
|
+
}
|
|
7001
|
+
async function readRecordedSparseCone(contentBranchesRoot) {
|
|
7002
|
+
let raw;
|
|
7003
|
+
try {
|
|
7004
|
+
raw = await fs17.readFile(path19.join(contentBranchesRoot, SPARSE_CONE_RECORD), "utf8");
|
|
7005
|
+
} catch (err) {
|
|
7006
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
7007
|
+
return void 0;
|
|
7008
|
+
throw err;
|
|
7009
|
+
}
|
|
7010
|
+
let parsed;
|
|
7011
|
+
try {
|
|
7012
|
+
parsed = JSON.parse(raw);
|
|
7013
|
+
} catch {
|
|
7014
|
+
return void 0;
|
|
7015
|
+
}
|
|
7016
|
+
if (typeof parsed !== "object" || parsed === null || !("cone" in parsed))
|
|
7017
|
+
return void 0;
|
|
7018
|
+
const { cone } = parsed;
|
|
7019
|
+
if (cone === null)
|
|
7020
|
+
return { cone: null };
|
|
7021
|
+
return Array.isArray(cone) && cone.length > 0 && cone.every(isConeDir) ? { cone: cone.filter(isConeDir) } : void 0;
|
|
7022
|
+
}
|
|
7023
|
+
async function recordSparseCone(contentBranchesRoot, cone) {
|
|
7024
|
+
const current = await readRecordedSparseCone(contentBranchesRoot);
|
|
7025
|
+
if (current && sameCone(current.cone, cone))
|
|
7026
|
+
return;
|
|
7027
|
+
const target = path19.join(contentBranchesRoot, SPARSE_CONE_RECORD);
|
|
7028
|
+
const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
7029
|
+
try {
|
|
7030
|
+
await fs17.writeFile(temp, `${JSON.stringify({ cone })}
|
|
7031
|
+
`, "utf8");
|
|
7032
|
+
} catch (err) {
|
|
7033
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
7034
|
+
return;
|
|
7035
|
+
throw err;
|
|
7036
|
+
}
|
|
7037
|
+
try {
|
|
7038
|
+
await fs17.rename(temp, target);
|
|
7039
|
+
} catch (err) {
|
|
7040
|
+
await fs17.unlink(temp).catch(() => {
|
|
7041
|
+
});
|
|
7042
|
+
throw err;
|
|
7043
|
+
}
|
|
7044
|
+
}
|
|
7045
|
+
|
|
7046
|
+
// dist/branch-workspace.js
|
|
7047
|
+
init_operating_mode();
|
|
7048
|
+
init_logger();
|
|
7049
|
+
|
|
7050
|
+
// dist/utils/provision-log.js
|
|
7051
|
+
init_logger();
|
|
7052
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
7053
|
+
var defaultSink = (line) => canopyLog(line);
|
|
7054
|
+
var sink = defaultSink;
|
|
7055
|
+
var ProvisionLog = class {
|
|
7056
|
+
constructor(dir) {
|
|
7057
|
+
this.id = randomBytes2(3).toString("hex");
|
|
7058
|
+
this.startedAt = Date.now();
|
|
7059
|
+
this.durations = [];
|
|
7060
|
+
this.prefix = `[canopy] provision id=${this.id} dir=${dir}`;
|
|
7061
|
+
}
|
|
7062
|
+
async step(name, run) {
|
|
7063
|
+
sink(`${this.prefix} step=${name} start`);
|
|
7064
|
+
const stepStartedAt = Date.now();
|
|
7065
|
+
let state = "failed";
|
|
7066
|
+
try {
|
|
7067
|
+
const result = await run();
|
|
7068
|
+
state = "done";
|
|
7069
|
+
return result;
|
|
7070
|
+
} finally {
|
|
7071
|
+
const ms = Date.now() - stepStartedAt;
|
|
7072
|
+
this.durations.push(`${name}=${ms}`);
|
|
7073
|
+
sink(`${this.prefix} step=${name} ${state} ms=${ms}`);
|
|
7074
|
+
}
|
|
7075
|
+
}
|
|
7076
|
+
finish(outcome) {
|
|
7077
|
+
const total = Date.now() - this.startedAt;
|
|
7078
|
+
sink(`${this.prefix} outcome=${outcome} total=${total} ${this.durations.join(" ")}`.trim());
|
|
6093
7079
|
}
|
|
6094
7080
|
};
|
|
6095
7081
|
|
|
6096
7082
|
// dist/branch-workspace.js
|
|
6097
|
-
var
|
|
6098
|
-
var
|
|
7083
|
+
var testHooks = {};
|
|
7084
|
+
var provisionsInFlight = /* @__PURE__ */ new Map();
|
|
6099
7085
|
var BranchWorkspaceManager = class {
|
|
6100
7086
|
constructor(config) {
|
|
6101
7087
|
this.config = config;
|
|
6102
7088
|
}
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
7089
|
+
/** The branch's context, provisioning it first when it does not exist. */
|
|
7090
|
+
async openOrCreateBranch(options) {
|
|
7091
|
+
return (await this.provisionBranch(options)).context;
|
|
7092
|
+
}
|
|
7093
|
+
/**
|
|
7094
|
+
* Provision a branch workspace, or report the one already there.
|
|
7095
|
+
*
|
|
7096
|
+
* @throws BranchProvisioningBusyError when another process is mid-way through this branch, or
|
|
7097
|
+
* what is at its path is too fresh to judge (retriable)
|
|
7098
|
+
* @throws BranchDirOccupiedError in prod, when its path holds a repository this deployment did
|
|
7099
|
+
* not create
|
|
7100
|
+
* @throws BranchMetadataCorruptError when its branch.json does not parse
|
|
7101
|
+
*/
|
|
7102
|
+
async provisionBranch(options) {
|
|
7103
|
+
if (isSettingsBranchName(options.branchName, operatingStrategy(options.mode).getSettingsBranchName(this.config))) {
|
|
7104
|
+
throw new BranchPathError("Settings branches are not content branches");
|
|
7105
|
+
}
|
|
7106
|
+
const { branchRoot, baseRoot, branchName: safeName } = resolveBranchPath(options);
|
|
7107
|
+
const paths = {
|
|
7108
|
+
branchRoot,
|
|
7109
|
+
baseRoot,
|
|
7110
|
+
dirName: path20.basename(branchRoot),
|
|
7111
|
+
safeName
|
|
7112
|
+
};
|
|
7113
|
+
const existing = await readBranchMetadataFile(branchRoot);
|
|
7114
|
+
if (existing) {
|
|
7115
|
+
return { kind: "exists", context: { branch: existing.branch, branchRoot, baseRoot } };
|
|
7116
|
+
}
|
|
7117
|
+
const inFlight = provisionsInFlight.get(branchRoot);
|
|
7118
|
+
if (inFlight)
|
|
7119
|
+
return { kind: "exists", context: (await inFlight).context };
|
|
7120
|
+
const run = this.provisionUnshared(options, paths);
|
|
7121
|
+
provisionsInFlight.set(branchRoot, run);
|
|
7122
|
+
try {
|
|
7123
|
+
return await run;
|
|
7124
|
+
} finally {
|
|
7125
|
+
provisionsInFlight.delete(branchRoot);
|
|
7126
|
+
}
|
|
7127
|
+
}
|
|
7128
|
+
async provisionUnshared(options, paths) {
|
|
7129
|
+
const { branchRoot, baseRoot, dirName } = paths;
|
|
7130
|
+
await fs18.mkdir(baseRoot, { recursive: true });
|
|
7131
|
+
const baseBranch = await resolveBaseBranch({
|
|
7132
|
+
defaultBaseBranch: this.config.defaultBaseBranch,
|
|
7133
|
+
mode: options.mode,
|
|
7134
|
+
detectFrom: this.config.sourceRoot ? path20.resolve(process.cwd(), this.config.sourceRoot) : void 0
|
|
7135
|
+
});
|
|
7136
|
+
const remoteUrl = await GitManager.resolveCloneRemoteUrl({
|
|
7137
|
+
mode: options.mode,
|
|
7138
|
+
remoteUrl: options.remoteUrl,
|
|
7139
|
+
defaultRemoteUrl: this.config.defaultRemoteUrl,
|
|
7140
|
+
baseBranch,
|
|
7141
|
+
sourceRoot: this.config.sourceRoot,
|
|
7142
|
+
allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd
|
|
7143
|
+
});
|
|
7144
|
+
const metadata = buildInitialBranchMetadata({
|
|
7145
|
+
branch: {
|
|
7146
|
+
name: paths.safeName,
|
|
7147
|
+
title: options.title,
|
|
7148
|
+
description: options.description,
|
|
7149
|
+
access: options.access,
|
|
7150
|
+
createdBy: options.createdBy,
|
|
7151
|
+
baseBranch
|
|
6109
7152
|
}
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
7153
|
+
});
|
|
7154
|
+
const state = await classifyFinalDir(branchRoot, remoteUrl);
|
|
7155
|
+
if (state.kind === "live" || state.kind === "corrupt")
|
|
7156
|
+
return this.existing(paths);
|
|
7157
|
+
if (state.kind === "protected")
|
|
7158
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
7159
|
+
if (state.kind === "foreign") {
|
|
7160
|
+
if (options.mode !== "dev")
|
|
7161
|
+
throw new BranchDirOccupiedError(dirName);
|
|
7162
|
+
return this.adoptInPlace(options, paths, baseBranch, metadata);
|
|
7163
|
+
}
|
|
7164
|
+
if (state.kind === "residue" && await this.recentlyActive(paths)) {
|
|
7165
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
7166
|
+
}
|
|
7167
|
+
const sparseCone = sparseConeFor(this.config.contentRoot);
|
|
7168
|
+
await recordSparseCone(baseRoot, sparseCone).catch((err) => {
|
|
7169
|
+
canopyLogWarn(`[canopy] Could not record the sparse-checkout cone: ${getErrorMessage(err)}`);
|
|
7170
|
+
});
|
|
7171
|
+
const provisionLog = new ProvisionLog(dirName);
|
|
7172
|
+
let outcome;
|
|
7173
|
+
try {
|
|
7174
|
+
outcome = await this.stageAndPublish(paths, {
|
|
7175
|
+
provisionLog,
|
|
7176
|
+
baseBranch,
|
|
7177
|
+
remoteUrl,
|
|
7178
|
+
metadata,
|
|
7179
|
+
mode: options.mode,
|
|
7180
|
+
sparseCone
|
|
7181
|
+
});
|
|
7182
|
+
} catch (err) {
|
|
7183
|
+
provisionLog.finish("error");
|
|
7184
|
+
throw err;
|
|
7185
|
+
}
|
|
7186
|
+
provisionLog.finish(outcome.kind === "created" ? "ok" : "exists");
|
|
7187
|
+
return outcome;
|
|
7188
|
+
}
|
|
7189
|
+
async stageAndPublish(paths, run) {
|
|
7190
|
+
const { branchRoot, baseRoot, dirName } = paths;
|
|
7191
|
+
const staged = await stageBranchWorkspace({
|
|
7192
|
+
baseRoot,
|
|
7193
|
+
dirName,
|
|
7194
|
+
branchName: paths.safeName,
|
|
7195
|
+
baseBranch: run.baseBranch,
|
|
7196
|
+
remoteUrl: run.remoteUrl,
|
|
7197
|
+
clone: {
|
|
7198
|
+
remoteName: this.config.defaultRemoteName,
|
|
7199
|
+
config: managedWorkspaceConfig(this.config.gitBotAuthorName, this.config.gitBotAuthorEmail)
|
|
7200
|
+
},
|
|
7201
|
+
gitExcludePattern: operatingStrategy(run.mode).getGitExcludePattern(),
|
|
7202
|
+
metadata: run.metadata,
|
|
7203
|
+
provisionLog: run.provisionLog,
|
|
7204
|
+
sparseCone: run.sparseCone
|
|
7205
|
+
});
|
|
7206
|
+
if (staged.kind === "exists")
|
|
7207
|
+
return this.existing(paths);
|
|
7208
|
+
let published;
|
|
7209
|
+
try {
|
|
7210
|
+
await testHooks.beforePublish?.({ stagingPath: staged.stagingPath, finalPath: branchRoot });
|
|
7211
|
+
published = await run.provisionLog.step("publish", () => publishStaging({
|
|
7212
|
+
baseRoot,
|
|
7213
|
+
dirName,
|
|
7214
|
+
stagingPath: staged.stagingPath,
|
|
7215
|
+
writeId: staged.meta.writeId,
|
|
7216
|
+
expectedRemoteUrl: run.remoteUrl,
|
|
7217
|
+
inspectBlocked: testHooks.inspectBlocked
|
|
7218
|
+
}));
|
|
7219
|
+
} finally {
|
|
7220
|
+
if (published !== "published")
|
|
7221
|
+
await removeLeftoverDir(staged.stagingPath);
|
|
7222
|
+
}
|
|
7223
|
+
if (published === "exists")
|
|
7224
|
+
return this.existing(paths);
|
|
7225
|
+
await run.provisionLog.step("register", () => register(paths));
|
|
7226
|
+
return { kind: "created", context: { branch: staged.meta.branch, branchRoot, baseRoot } };
|
|
7227
|
+
}
|
|
7228
|
+
/**
|
|
7229
|
+
* The one exception to [PROV-1]: a dev workspace that already holds real work is kept where it
|
|
7230
|
+
* is, given branch.json under the branch's lock.
|
|
7231
|
+
*/
|
|
7232
|
+
async adoptInPlace(options, paths, baseBranch, metadata) {
|
|
7233
|
+
const { branchRoot, baseRoot, dirName } = paths;
|
|
7234
|
+
const provisionLog = new ProvisionLog(dirName);
|
|
7235
|
+
let created = false;
|
|
7236
|
+
try {
|
|
7237
|
+
const release = await provisionLog.step("lock", () => lockBranchName(baseRoot, dirName));
|
|
7238
|
+
try {
|
|
7239
|
+
if (!await readBranchMetadataFile(branchRoot)) {
|
|
6118
7240
|
await GitManager.initializeWorkspace({
|
|
6119
|
-
workspacePath:
|
|
6120
|
-
branchName:
|
|
7241
|
+
workspacePath: branchRoot,
|
|
7242
|
+
branchName: paths.safeName,
|
|
6121
7243
|
mode: options.mode,
|
|
6122
|
-
baseBranch
|
|
7244
|
+
baseBranch,
|
|
6123
7245
|
sourceRoot: this.config.sourceRoot,
|
|
6124
7246
|
defaultRemoteUrl: this.config.defaultRemoteUrl,
|
|
6125
7247
|
remoteUrl: options.remoteUrl,
|
|
@@ -6128,60 +7250,55 @@ var BranchWorkspaceManager = class {
|
|
|
6128
7250
|
branchType: "content",
|
|
6129
7251
|
gitBotAuthorName: this.config.gitBotAuthorName,
|
|
6130
7252
|
gitBotAuthorEmail: this.config.gitBotAuthorEmail,
|
|
6131
|
-
gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern()
|
|
7253
|
+
gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern(),
|
|
7254
|
+
provisionLog
|
|
6132
7255
|
});
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
log6.debug("workspace", "Failed to release workspace-init lock", { err });
|
|
6139
|
-
}
|
|
6140
|
-
}
|
|
6141
|
-
workspaceInitLocks.delete(options.branchRoot);
|
|
7256
|
+
created = await provisionLog.step("metadata", () => writeOccJsonFile(path20.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE), { ...metadata }, { expectedVersion: null, trailingNewline: true }).then(() => true, (err) => {
|
|
7257
|
+
if (err instanceof OccWriteConflictError)
|
|
7258
|
+
return false;
|
|
7259
|
+
throw err;
|
|
7260
|
+
}));
|
|
6142
7261
|
}
|
|
6143
|
-
}
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
});
|
|
6147
|
-
}
|
|
6148
|
-
async openOrCreateBranch(options) {
|
|
6149
|
-
const { branchName, mode, basePathOverride, title, description, access, createdBy, remoteUrl } = options;
|
|
6150
|
-
const { branchRoot, baseRoot, branchName: safeName } = await ensureBranchRoot({
|
|
6151
|
-
mode,
|
|
6152
|
-
branchName,
|
|
6153
|
-
basePathOverride
|
|
6154
|
-
});
|
|
6155
|
-
const baseBranch = await resolveBaseBranch({
|
|
6156
|
-
defaultBaseBranch: this.config.defaultBaseBranch,
|
|
6157
|
-
mode,
|
|
6158
|
-
detectFrom: this.config.sourceRoot ? path18.resolve(process.cwd(), this.config.sourceRoot) : void 0
|
|
6159
|
-
});
|
|
6160
|
-
await this.ensureGitWorkspace({
|
|
6161
|
-
branchRoot,
|
|
6162
|
-
branchName: safeName,
|
|
6163
|
-
mode,
|
|
6164
|
-
baseBranch,
|
|
6165
|
-
remoteUrl
|
|
6166
|
-
});
|
|
6167
|
-
const metadata = getBranchMetadataFileManager(branchRoot, baseRoot);
|
|
6168
|
-
const meta = await metadata.save({
|
|
6169
|
-
branch: {
|
|
6170
|
-
name: safeName,
|
|
6171
|
-
title,
|
|
6172
|
-
description,
|
|
6173
|
-
access,
|
|
6174
|
-
createdBy,
|
|
6175
|
-
baseBranch
|
|
7262
|
+
} finally {
|
|
7263
|
+
await release().catch(() => {
|
|
7264
|
+
});
|
|
6176
7265
|
}
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
}
|
|
7266
|
+
if (created)
|
|
7267
|
+
await provisionLog.step("register", () => register(paths));
|
|
7268
|
+
} catch (err) {
|
|
7269
|
+
provisionLog.finish("error");
|
|
7270
|
+
throw err;
|
|
7271
|
+
}
|
|
7272
|
+
provisionLog.finish(created ? "ok" : "exists");
|
|
7273
|
+
if (!created)
|
|
7274
|
+
return this.existing(paths);
|
|
7275
|
+
const meta = await readBranchMetadataFile(branchRoot);
|
|
7276
|
+
if (!meta)
|
|
7277
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
7278
|
+
return { kind: "created", context: { branch: meta.branch, branchRoot, baseRoot } };
|
|
7279
|
+
}
|
|
7280
|
+
/** Whether residue at the branch path may still belong to a live process. */
|
|
7281
|
+
async recentlyActive({ branchRoot, baseRoot, dirName }) {
|
|
7282
|
+
const lockAgeMs = await fs18.stat(path20.join(baseRoot, branchProvisioningLockName(dirName))).then((stat) => Date.now() - stat.mtimeMs, () => Infinity);
|
|
7283
|
+
if (lockAgeMs < PROVISIONING_LOCK_STALE_MS)
|
|
7284
|
+
return true;
|
|
7285
|
+
return await quietAgeMs(branchRoot) < CREATE_RESIDUE_MIN_QUIET_MS;
|
|
7286
|
+
}
|
|
7287
|
+
async existing({ branchRoot, baseRoot, dirName }) {
|
|
7288
|
+
const meta = await readBranchMetadataFile(branchRoot);
|
|
7289
|
+
if (!meta)
|
|
7290
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
7291
|
+
return { kind: "exists", context: { branch: meta.branch, branchRoot, baseRoot } };
|
|
6183
7292
|
}
|
|
6184
7293
|
};
|
|
7294
|
+
async function register({ branchRoot, baseRoot }) {
|
|
7295
|
+
try {
|
|
7296
|
+
await invalidateBranchContentCaches(branchRoot);
|
|
7297
|
+
await new BranchRegistry(baseRoot).invalidate();
|
|
7298
|
+
} catch (err) {
|
|
7299
|
+
canopyLogWarn(`[canopy] Registering new branch ${branchRoot} failed: ${getErrorMessage(err)}`);
|
|
7300
|
+
}
|
|
7301
|
+
}
|
|
6185
7302
|
async function loadOrCreateBranchContext(options) {
|
|
6186
7303
|
if (readsFromCheckout(options.config)) {
|
|
6187
7304
|
const cwd = process.cwd();
|
|
@@ -6239,8 +7356,8 @@ async function resolveBranchRoot(config) {
|
|
|
6239
7356
|
}
|
|
6240
7357
|
|
|
6241
7358
|
// dist/content-listing.js
|
|
6242
|
-
import
|
|
6243
|
-
import
|
|
7359
|
+
import fs19 from "node:fs/promises";
|
|
7360
|
+
import path21 from "node:path";
|
|
6244
7361
|
import matter3 from "gray-matter";
|
|
6245
7362
|
import { parse as yamlParse2 } from "yaml";
|
|
6246
7363
|
var log7 = createDebugLogger({ prefix: "ContentListing" });
|
|
@@ -6253,7 +7370,7 @@ var normalizePath = (root, target) => {
|
|
|
6253
7370
|
};
|
|
6254
7371
|
var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
6255
7372
|
try {
|
|
6256
|
-
const raw = await
|
|
7373
|
+
const raw = await fs19.readFile(filePath, "utf8");
|
|
6257
7374
|
if (format === "json") {
|
|
6258
7375
|
return asRecord(JSON.parse(raw));
|
|
6259
7376
|
}
|
|
@@ -6273,9 +7390,13 @@ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
|
6273
7390
|
return {};
|
|
6274
7391
|
}
|
|
6275
7392
|
};
|
|
6276
|
-
var createReferenceResolver = (branchRoot, flatSchema, contentRootName) => ({
|
|
7393
|
+
var createReferenceResolver = (branchRoot, flatSchema, contentRootName, visibility) => ({
|
|
6277
7394
|
store: new ContentStore(branchRoot, flatSchema, { contentRootName }),
|
|
6278
|
-
cache: createReferenceResolveCache()
|
|
7395
|
+
cache: createReferenceResolveCache(),
|
|
7396
|
+
// The predicate that hides an entry from the listing is the one that restricts it as a
|
|
7397
|
+
// reference target, so a target is never shown in a sibling's data that its own listing
|
|
7398
|
+
// row would have hidden.
|
|
7399
|
+
access: visibility?.shouldInclude
|
|
6279
7400
|
});
|
|
6280
7401
|
var resolveCollectionItemReferences = async (items, collection, resolver) => Promise.all(items.map(async (item) => {
|
|
6281
7402
|
const fields = collection.entries?.find((e) => e.name === item.entryType)?.schema;
|
|
@@ -6283,7 +7404,7 @@ var resolveCollectionItemReferences = async (items, collection, resolver) => Pro
|
|
|
6283
7404
|
return item;
|
|
6284
7405
|
return {
|
|
6285
7406
|
...item,
|
|
6286
|
-
data: await resolver.store.resolveReferences(item.data, fields, resolver.cache)
|
|
7407
|
+
data: await resolver.store.resolveReferences(item.data, fields, resolver.cache, resolver.access)
|
|
6287
7408
|
};
|
|
6288
7409
|
}));
|
|
6289
7410
|
async function listEntries(branchRoot, flatSchema, contentRootName, options, visibility) {
|
|
@@ -6294,10 +7415,10 @@ async function listEntries(branchRoot, flatSchema, contentRootName, options, vis
|
|
|
6294
7415
|
const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
|
|
6295
7416
|
const shouldInclude = visibility?.shouldInclude;
|
|
6296
7417
|
const skippedFiles = [];
|
|
6297
|
-
const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName) : null;
|
|
7418
|
+
const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility) : null;
|
|
6298
7419
|
const collectionResults = await Promise.all(collections.map(async (collection) => {
|
|
6299
7420
|
const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
|
|
6300
|
-
const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.
|
|
7421
|
+
const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
|
|
6301
7422
|
const resolved = resolver ? await resolveCollectionItemReferences(visible, collection, resolver) : visible;
|
|
6302
7423
|
return resolved.map((entry) => ({ entry, collection }));
|
|
6303
7424
|
}));
|
|
@@ -6375,7 +7496,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
|
|
|
6375
7496
|
normalizePath(root, collectionRoot);
|
|
6376
7497
|
let dirents;
|
|
6377
7498
|
try {
|
|
6378
|
-
dirents = await
|
|
7499
|
+
dirents = await fs19.readdir(collectionRoot, { withFileTypes: true });
|
|
6379
7500
|
} catch (err) {
|
|
6380
7501
|
if (isNotFoundError(err))
|
|
6381
7502
|
return [];
|
|
@@ -6383,7 +7504,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
|
|
|
6383
7504
|
}
|
|
6384
7505
|
const files = dirents.filter((d) => d.isFile() && validExts.some((ext) => d.name.endsWith(ext)) && d.name !== ".collection.json").sort((a, b) => a.name.localeCompare(b.name));
|
|
6385
7506
|
const entries = await Promise.all(files.map(async (file) => {
|
|
6386
|
-
const absolutePath =
|
|
7507
|
+
const absolutePath = path21.join(collectionRoot, file.name);
|
|
6387
7508
|
const relativePath = normalizePath(root, absolutePath);
|
|
6388
7509
|
const parsed = parseTypedFilename(file.name, entryTypes);
|
|
6389
7510
|
if (!parsed) {
|
|
@@ -6398,11 +7519,11 @@ var listCollectionEntries = async (root, collection, onSkip) => {
|
|
|
6398
7519
|
const format = entryType?.format || "json";
|
|
6399
7520
|
const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
|
|
6400
7521
|
const [stats, data] = await Promise.all([
|
|
6401
|
-
|
|
7522
|
+
fs19.stat(absolutePath),
|
|
6402
7523
|
readEntryData(absolutePath, format, bodyField)
|
|
6403
7524
|
]);
|
|
6404
7525
|
const item = {
|
|
6405
|
-
logicalPath:
|
|
7526
|
+
logicalPath: entryLogicalPath(collection.logicalPath, slug),
|
|
6406
7527
|
contentId,
|
|
6407
7528
|
slug,
|
|
6408
7529
|
collectionPath: collection.logicalPath,
|
|
@@ -6422,7 +7543,8 @@ var listCollectionEntries = async (root, collection, onSkip) => {
|
|
|
6422
7543
|
init_config2();
|
|
6423
7544
|
|
|
6424
7545
|
// dist/assets/transform-directives.js
|
|
6425
|
-
var MAX_INPUT_PIXELS =
|
|
7546
|
+
var MAX_INPUT_PIXELS = 6e3 * 4e3;
|
|
7547
|
+
var CROP_PRECISION = 4;
|
|
6426
7548
|
function isValidCropRect(x, y, w, h) {
|
|
6427
7549
|
for (const n of [x, y, w, h]) {
|
|
6428
7550
|
if (!Number.isFinite(n) || n < 0 || n > 1)
|
|
@@ -6434,6 +7556,21 @@ function isValidCropRect(x, y, w, h) {
|
|
|
6434
7556
|
return false;
|
|
6435
7557
|
return true;
|
|
6436
7558
|
}
|
|
7559
|
+
function roundToCropPrecision(n) {
|
|
7560
|
+
const factor = 10 ** CROP_PRECISION;
|
|
7561
|
+
return Math.round(n * factor) / factor;
|
|
7562
|
+
}
|
|
7563
|
+
function roundCropRect(rect) {
|
|
7564
|
+
const x = roundToCropPrecision(rect.x);
|
|
7565
|
+
const y = roundToCropPrecision(rect.y);
|
|
7566
|
+
let w = roundToCropPrecision(rect.w);
|
|
7567
|
+
let h = roundToCropPrecision(rect.h);
|
|
7568
|
+
if (x + w > 1)
|
|
7569
|
+
w = roundToCropPrecision(1 - x);
|
|
7570
|
+
if (y + h > 1)
|
|
7571
|
+
h = roundToCropPrecision(1 - y);
|
|
7572
|
+
return { x, y, w, h };
|
|
7573
|
+
}
|
|
6437
7574
|
|
|
6438
7575
|
// dist/validation/field-traversal.js
|
|
6439
7576
|
function isPlainRecord2(value) {
|
|
@@ -6466,32 +7603,35 @@ function isValidImageCropValue(value) {
|
|
|
6466
7603
|
if (typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") {
|
|
6467
7604
|
return false;
|
|
6468
7605
|
}
|
|
6469
|
-
|
|
7606
|
+
if (!isValidCropRect(x, y, w, h))
|
|
7607
|
+
return false;
|
|
7608
|
+
const rounded = roundCropRect({ x, y, w, h });
|
|
7609
|
+
return isValidCropRect(rounded.x, rounded.y, rounded.w, rounded.h);
|
|
6470
7610
|
}
|
|
6471
|
-
function validateImageValue(field, value,
|
|
7611
|
+
function validateImageValue(field, value, path24) {
|
|
6472
7612
|
if (!isPlainRecord3(value)) {
|
|
6473
|
-
return [{ fieldPath:
|
|
7613
|
+
return [{ fieldPath: path24, message: "Expected an image object with { src, alt }" }];
|
|
6474
7614
|
}
|
|
6475
7615
|
const errors = [];
|
|
6476
7616
|
if (typeof value.src !== "string" || value.src.trim() === "") {
|
|
6477
|
-
errors.push({ fieldPath: `${
|
|
7617
|
+
errors.push({ fieldPath: `${path24}.src`, message: "Image src is required" });
|
|
6478
7618
|
}
|
|
6479
7619
|
if (typeof value.alt !== "string") {
|
|
6480
|
-
errors.push({ fieldPath: `${
|
|
7620
|
+
errors.push({ fieldPath: `${path24}.alt`, message: "Image alt text is required" });
|
|
6481
7621
|
} else if (value.alt.trim() === "" && field.altOptional !== true) {
|
|
6482
|
-
errors.push({ fieldPath: `${
|
|
7622
|
+
errors.push({ fieldPath: `${path24}.alt`, message: "Image alt text is required" });
|
|
6483
7623
|
}
|
|
6484
7624
|
if (value.width !== void 0 && !isPositiveInt(value.width)) {
|
|
6485
|
-
errors.push({ fieldPath: `${
|
|
7625
|
+
errors.push({ fieldPath: `${path24}.width`, message: "Image width must be a positive integer" });
|
|
6486
7626
|
}
|
|
6487
7627
|
if (value.height !== void 0 && !isPositiveInt(value.height)) {
|
|
6488
7628
|
errors.push({
|
|
6489
|
-
fieldPath: `${
|
|
7629
|
+
fieldPath: `${path24}.height`,
|
|
6490
7630
|
message: "Image height must be a positive integer"
|
|
6491
7631
|
});
|
|
6492
7632
|
}
|
|
6493
7633
|
if (value.crop !== void 0 && !isValidImageCropValue(value.crop)) {
|
|
6494
|
-
errors.push({ fieldPath: `${
|
|
7634
|
+
errors.push({ fieldPath: `${path24}.crop`, message: "Invalid image crop rect" });
|
|
6495
7635
|
}
|
|
6496
7636
|
return errors;
|
|
6497
7637
|
}
|
|
@@ -6503,44 +7643,44 @@ function referenceValueId(value) {
|
|
|
6503
7643
|
return void 0;
|
|
6504
7644
|
}
|
|
6505
7645
|
var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
|
|
6506
|
-
function validateScalar(field, value,
|
|
7646
|
+
function validateScalar(field, value, path24) {
|
|
6507
7647
|
switch (field.type) {
|
|
6508
7648
|
case "number":
|
|
6509
7649
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
6510
|
-
return [{ fieldPath:
|
|
7650
|
+
return [{ fieldPath: path24, message: "Expected a number" }];
|
|
6511
7651
|
}
|
|
6512
7652
|
return [];
|
|
6513
7653
|
case "boolean":
|
|
6514
7654
|
if (typeof value !== "boolean") {
|
|
6515
|
-
return [{ fieldPath:
|
|
7655
|
+
return [{ fieldPath: path24, message: "Expected true or false" }];
|
|
6516
7656
|
}
|
|
6517
7657
|
return [];
|
|
6518
7658
|
case "datetime":
|
|
6519
7659
|
if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
|
|
6520
|
-
return [{ fieldPath:
|
|
7660
|
+
return [{ fieldPath: path24, message: "Expected a valid date/time string" }];
|
|
6521
7661
|
}
|
|
6522
7662
|
return [];
|
|
6523
7663
|
case "select": {
|
|
6524
7664
|
if (typeof value !== "string") {
|
|
6525
|
-
return [{ fieldPath:
|
|
7665
|
+
return [{ fieldPath: path24, message: "Expected a selection" }];
|
|
6526
7666
|
}
|
|
6527
7667
|
const options = selectOptionValues(field);
|
|
6528
7668
|
if (value !== "" && !options.includes(value)) {
|
|
6529
|
-
return [{ fieldPath:
|
|
7669
|
+
return [{ fieldPath: path24, message: `Must be one of: ${options.join(", ")}` }];
|
|
6530
7670
|
}
|
|
6531
7671
|
return [];
|
|
6532
7672
|
}
|
|
6533
7673
|
case "reference":
|
|
6534
7674
|
if (referenceValueId(value) === void 0) {
|
|
6535
|
-
return [{ fieldPath:
|
|
7675
|
+
return [{ fieldPath: path24, message: "Expected a reference id" }];
|
|
6536
7676
|
}
|
|
6537
7677
|
return [];
|
|
6538
7678
|
case "image":
|
|
6539
|
-
return validateImageValue(field, value,
|
|
7679
|
+
return validateImageValue(field, value, path24);
|
|
6540
7680
|
default:
|
|
6541
7681
|
if (STRING_FIELD_TYPES.has(field.type)) {
|
|
6542
7682
|
if (typeof value !== "string") {
|
|
6543
|
-
return [{ fieldPath:
|
|
7683
|
+
return [{ fieldPath: path24, message: "Expected text" }];
|
|
6544
7684
|
}
|
|
6545
7685
|
}
|
|
6546
7686
|
return [];
|
|
@@ -6563,12 +7703,12 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
6563
7703
|
errors.push(...validateEntryData(field.fields, data, pathPrefix));
|
|
6564
7704
|
continue;
|
|
6565
7705
|
}
|
|
6566
|
-
const
|
|
7706
|
+
const path24 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
|
|
6567
7707
|
const value = data[field.name];
|
|
6568
7708
|
const isRequired = "required" in field && field.required === true;
|
|
6569
7709
|
if (isRequired && isEmptyForRequired(field, value)) {
|
|
6570
7710
|
const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
|
|
6571
|
-
errors.push({ fieldPath:
|
|
7711
|
+
errors.push({ fieldPath: path24, message });
|
|
6572
7712
|
continue;
|
|
6573
7713
|
}
|
|
6574
7714
|
if (value === void 0 || value === null)
|
|
@@ -6576,11 +7716,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
6576
7716
|
if (field.type === "block") {
|
|
6577
7717
|
const blockField = field;
|
|
6578
7718
|
if (!Array.isArray(value)) {
|
|
6579
|
-
errors.push({ fieldPath:
|
|
7719
|
+
errors.push({ fieldPath: path24, message: "Expected a list of blocks" });
|
|
6580
7720
|
continue;
|
|
6581
7721
|
}
|
|
6582
7722
|
value.forEach((item, index) => {
|
|
6583
|
-
const itemPath = `${
|
|
7723
|
+
const itemPath = `${path24}[${index}]`;
|
|
6584
7724
|
if (!isPlainRecord3(item)) {
|
|
6585
7725
|
errors.push({ fieldPath: itemPath, message: "Expected a block object" });
|
|
6586
7726
|
return;
|
|
@@ -6602,11 +7742,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
6602
7742
|
const objectField = field;
|
|
6603
7743
|
if (objectField.list) {
|
|
6604
7744
|
if (!Array.isArray(value)) {
|
|
6605
|
-
errors.push({ fieldPath:
|
|
7745
|
+
errors.push({ fieldPath: path24, message: "Expected a list of items" });
|
|
6606
7746
|
continue;
|
|
6607
7747
|
}
|
|
6608
7748
|
value.forEach((item, index) => {
|
|
6609
|
-
const itemPath = `${
|
|
7749
|
+
const itemPath = `${path24}[${index}]`;
|
|
6610
7750
|
if (!isPlainRecord3(item)) {
|
|
6611
7751
|
errors.push({ fieldPath: itemPath, message: "Expected an object" });
|
|
6612
7752
|
return;
|
|
@@ -6616,27 +7756,27 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
6616
7756
|
}
|
|
6617
7757
|
});
|
|
6618
7758
|
} else if (!isPlainRecord3(value)) {
|
|
6619
|
-
errors.push({ fieldPath:
|
|
7759
|
+
errors.push({ fieldPath: path24, message: "Expected an object" });
|
|
6620
7760
|
} else if (objectField.fields) {
|
|
6621
|
-
errors.push(...validateEntryData(objectField.fields, value,
|
|
7761
|
+
errors.push(...validateEntryData(objectField.fields, value, path24));
|
|
6622
7762
|
}
|
|
6623
7763
|
continue;
|
|
6624
7764
|
}
|
|
6625
7765
|
if ("list" in field && field.list) {
|
|
6626
7766
|
if (!Array.isArray(value)) {
|
|
6627
|
-
errors.push({ fieldPath:
|
|
7767
|
+
errors.push({ fieldPath: path24, message: "Expected a list of values" });
|
|
6628
7768
|
continue;
|
|
6629
7769
|
}
|
|
6630
7770
|
value.forEach((item, index) => {
|
|
6631
|
-
errors.push(...validateScalar(field, item, `${
|
|
7771
|
+
errors.push(...validateScalar(field, item, `${path24}[${index}]`));
|
|
6632
7772
|
});
|
|
6633
7773
|
continue;
|
|
6634
7774
|
}
|
|
6635
7775
|
if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
|
|
6636
|
-
errors.push({ fieldPath:
|
|
7776
|
+
errors.push({ fieldPath: path24, message: "Expected a single value, not a list" });
|
|
6637
7777
|
continue;
|
|
6638
7778
|
}
|
|
6639
|
-
errors.push(...validateScalar(field, value,
|
|
7779
|
+
errors.push(...validateScalar(field, value, path24));
|
|
6640
7780
|
}
|
|
6641
7781
|
return errors;
|
|
6642
7782
|
}
|
|
@@ -6687,7 +7827,7 @@ These are likely abandoned create-scaffolds (empty entries left behind when a cr
|
|
|
6687
7827
|
var GENERATED_RECORD_FILENAME = ".canopy-generated.json";
|
|
6688
7828
|
async function readGeneratedRecord(outputDir) {
|
|
6689
7829
|
try {
|
|
6690
|
-
const raw = await
|
|
7830
|
+
const raw = await fs20.readFile(path22.join(outputDir, GENERATED_RECORD_FILENAME), "utf-8");
|
|
6691
7831
|
const parsed = JSON.parse(raw);
|
|
6692
7832
|
if (typeof parsed !== "object" || parsed === null || !("files" in parsed))
|
|
6693
7833
|
return [];
|
|
@@ -6701,19 +7841,19 @@ async function readGeneratedRecord(outputDir) {
|
|
|
6701
7841
|
}
|
|
6702
7842
|
async function writeGeneratedRecord(outputDir, files) {
|
|
6703
7843
|
const record = { files: [...files].sort() };
|
|
6704
|
-
await
|
|
6705
|
-
await
|
|
7844
|
+
await fs20.mkdir(outputDir, { recursive: true });
|
|
7845
|
+
await fs20.writeFile(path22.join(outputDir, GENERATED_RECORD_FILENAME), `${JSON.stringify(record, null, 2)}
|
|
6706
7846
|
`, "utf-8");
|
|
6707
7847
|
}
|
|
6708
7848
|
async function removeEmptyDirsUpward(startDir, stopAt) {
|
|
6709
7849
|
let current = startDir;
|
|
6710
7850
|
while (current.startsWith(stopAt) && current !== stopAt) {
|
|
6711
7851
|
try {
|
|
6712
|
-
await
|
|
7852
|
+
await fs20.rmdir(current);
|
|
6713
7853
|
} catch {
|
|
6714
7854
|
return;
|
|
6715
7855
|
}
|
|
6716
|
-
current =
|
|
7856
|
+
current = path22.dirname(current);
|
|
6717
7857
|
}
|
|
6718
7858
|
}
|
|
6719
7859
|
var SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
|
|
@@ -6775,9 +7915,9 @@ async function generateAIContentFiles(options) {
|
|
|
6775
7915
|
entryLinkUrl: config.entryLinkUrl,
|
|
6776
7916
|
...resolveBuildStamp()
|
|
6777
7917
|
});
|
|
6778
|
-
const absoluteOutputDir =
|
|
7918
|
+
const absoluteOutputDir = path22.resolve(outputDir) + path22.sep;
|
|
6779
7919
|
const resolveOutputPath = (filePath) => {
|
|
6780
|
-
const absolutePath =
|
|
7920
|
+
const absolutePath = path22.resolve(path22.join(absoluteOutputDir, filePath));
|
|
6781
7921
|
if (!absolutePath.startsWith(absoluteOutputDir)) {
|
|
6782
7922
|
throw new Error(`Path traversal detected in AI content output: ${filePath}`);
|
|
6783
7923
|
}
|
|
@@ -6789,8 +7929,8 @@ async function generateAIContentFiles(options) {
|
|
|
6789
7929
|
let fileCount = 0;
|
|
6790
7930
|
for (const [filePath, content] of result.files) {
|
|
6791
7931
|
const absolutePath = resolveOutputPath(filePath);
|
|
6792
|
-
await
|
|
6793
|
-
await
|
|
7932
|
+
await fs20.mkdir(path22.dirname(absolutePath), { recursive: true });
|
|
7933
|
+
await fs20.writeFile(absolutePath, content, "utf-8");
|
|
6794
7934
|
fileCount++;
|
|
6795
7935
|
}
|
|
6796
7936
|
const currentSet = new Set(currentFiles);
|
|
@@ -6799,13 +7939,13 @@ async function generateAIContentFiles(options) {
|
|
|
6799
7939
|
for (const filePath of stale) {
|
|
6800
7940
|
const absolutePath = resolveOutputPath(filePath);
|
|
6801
7941
|
try {
|
|
6802
|
-
await
|
|
7942
|
+
await fs20.unlink(absolutePath);
|
|
6803
7943
|
removedCount++;
|
|
6804
7944
|
} catch (err) {
|
|
6805
7945
|
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
6806
7946
|
throw err;
|
|
6807
7947
|
}
|
|
6808
|
-
await removeEmptyDirsUpward(
|
|
7948
|
+
await removeEmptyDirsUpward(path22.dirname(absolutePath), absoluteOutputDir);
|
|
6809
7949
|
}
|
|
6810
7950
|
await writeGeneratedRecord(absoluteOutputDir, currentFiles);
|
|
6811
7951
|
return { fileCount, removedCount, outputDir: absoluteOutputDir };
|
|
@@ -6816,7 +7956,7 @@ var jiti = createJiti(import.meta.url);
|
|
|
6816
7956
|
async function generateAIContentCLI(options) {
|
|
6817
7957
|
const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
|
|
6818
7958
|
console.log("\nCanopyCMS generate-ai-content\n");
|
|
6819
|
-
const canopyConfigPath =
|
|
7959
|
+
const canopyConfigPath = path23.join(projectDir, "canopycms.config.ts");
|
|
6820
7960
|
let canopyConfigModule;
|
|
6821
7961
|
try {
|
|
6822
7962
|
canopyConfigModule = await jiti.import(canopyConfigPath);
|
|
@@ -6827,7 +7967,7 @@ async function generateAIContentCLI(options) {
|
|
|
6827
7967
|
}
|
|
6828
7968
|
const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
|
|
6829
7969
|
const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
|
|
6830
|
-
const schemasPath =
|
|
7970
|
+
const schemasPath = path23.join(projectDir, appDir, "schemas.ts");
|
|
6831
7971
|
let entrySchemaRegistry = {};
|
|
6832
7972
|
try {
|
|
6833
7973
|
const schemasModule = await jiti.import(schemasPath);
|
|
@@ -6838,7 +7978,7 @@ async function generateAIContentCLI(options) {
|
|
|
6838
7978
|
let aiConfig;
|
|
6839
7979
|
if (configPath) {
|
|
6840
7980
|
try {
|
|
6841
|
-
const aiConfigModule = await jiti.import(
|
|
7981
|
+
const aiConfigModule = await jiti.import(path23.resolve(configPath));
|
|
6842
7982
|
aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
|
|
6843
7983
|
} catch (err) {
|
|
6844
7984
|
console.error(`Could not load AI config from ${configPath}`);
|
|
@@ -6855,7 +7995,7 @@ async function generateAIContentCLI(options) {
|
|
|
6855
7995
|
console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
|
|
6856
7996
|
process.exit(1);
|
|
6857
7997
|
}
|
|
6858
|
-
const resolvedOutput =
|
|
7998
|
+
const resolvedOutput = path23.resolve(projectDir, outputDir);
|
|
6859
7999
|
console.log(` Output: ${resolvedOutput}`);
|
|
6860
8000
|
console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
|
|
6861
8001
|
const result = await generateAIContentFiles({
|