canopycms 0.0.67 → 0.0.68-int.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
package/dist/services.js
CHANGED
|
@@ -9,8 +9,13 @@ import { operatingStrategy } from './operating-mode/index.js';
|
|
|
9
9
|
import { BranchSchemaCache } from './branch-schema-cache.js';
|
|
10
10
|
import { enqueueTask } from './task-queue/cms-task-queue.js';
|
|
11
11
|
import { getTaskQueueDir } from './task-queue/task-queue-config.js';
|
|
12
|
-
import { detectHeadBranch } from './utils/git.js';
|
|
12
|
+
import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
|
|
13
13
|
import { readsFromCheckout } from './build-mode.js';
|
|
14
|
+
import { timeRequestPhase } from './utils/request-timing.js';
|
|
15
|
+
import { BRANCH_META_DIR } from './branch-metadata-file.js';
|
|
16
|
+
import { appendTrailers, buildEditorTrailers, } from './submission-attribution.js';
|
|
17
|
+
import { getErrorMessage, redactCredentials } from './utils/error.js';
|
|
18
|
+
import { withContentWriteLock } from './utils/content-write-lock.js';
|
|
14
19
|
/**
|
|
15
20
|
* A per-instance active-branch detector with its own 5s TTL cache, in priority
|
|
16
21
|
* order: an explicitly configured value; `defaultBaseBranch ?? 'main'` with no
|
|
@@ -97,7 +102,7 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
97
102
|
const branchSchemaCache = options.branchSchemaCache ?? new BranchSchemaCache(config.mode);
|
|
98
103
|
const checkBranchAccess = createCheckBranchAccess(config.defaultBranchAccess ?? 'deny', config);
|
|
99
104
|
// Content access loads permissions dynamically from the settings branch (orphan git branch)
|
|
100
|
-
const
|
|
105
|
+
const ensureSettingsBranchRoot = options.getSettingsBranchRoot ??
|
|
101
106
|
(async () => {
|
|
102
107
|
const strategy = operatingStrategy(config.mode);
|
|
103
108
|
const settingsRoot = strategy.getSettingsRoot();
|
|
@@ -111,9 +116,10 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
111
116
|
});
|
|
112
117
|
return settingsRoot;
|
|
113
118
|
});
|
|
119
|
+
const getSettingsBranchRoot = () => timeRequestPhase('settingsRoot', ensureSettingsBranchRoot);
|
|
114
120
|
const contentAccessDeps = {
|
|
115
121
|
checkBranchAccess,
|
|
116
|
-
loadPathPermissions,
|
|
122
|
+
loadPathPermissions: (repoRoot, mode) => timeRequestPhase('permissions', () => loadPathPermissions(repoRoot, mode)),
|
|
117
123
|
defaultPathAccess: config.defaultPathAccess ?? 'deny',
|
|
118
124
|
mode: config.mode,
|
|
119
125
|
getSettingsBranchRoot,
|
|
@@ -137,8 +143,13 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
137
143
|
name: config.gitBotAuthorName,
|
|
138
144
|
email: config.gitBotAuthorEmail,
|
|
139
145
|
});
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
// [SYNC-C1] Unlocked, a commit made while a rebase is stopped on a conflict
|
|
147
|
+
// lands on its detached head with the rebase's half-resolved index, and the
|
|
148
|
+
// rebase's `--abort` discards it.
|
|
149
|
+
await withContentWriteLock(options.context.branchRoot, async () => {
|
|
150
|
+
await git.add(options.files);
|
|
151
|
+
await git.commit(options.message);
|
|
152
|
+
});
|
|
142
153
|
};
|
|
143
154
|
const submitBranch = async (options) => {
|
|
144
155
|
// Defense-in-depth: refuse to push the base branch to itself even if the
|
|
@@ -156,23 +167,53 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
156
167
|
name: config.gitBotAuthorName,
|
|
157
168
|
email: config.gitBotAuthorEmail,
|
|
158
169
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
// [SYNC-C1] Commits the whole working tree, so it holds the branch's
|
|
171
|
+
// content-write lock from checkout through push. Unlocked, its checkout
|
|
172
|
+
// succeeds while the worker's rebase is stopped on a conflict, the commit
|
|
173
|
+
// lands on the branch, and the rebase's `--abort` resets the branch past it
|
|
174
|
+
// after this reported success. The push stays inside so no rebase rewrites
|
|
175
|
+
// the commit between commit and push.
|
|
176
|
+
const submitted = await withContentWriteLock(options.context.branchRoot, async () => {
|
|
177
|
+
await git.checkoutBranch(options.context.branch.name);
|
|
178
|
+
const status = await git.status();
|
|
179
|
+
// Commit and push answer two DIFFERENT questions. Committing cleans the
|
|
180
|
+
// working tree, so one combined "tree is dirty" gate makes a retry after a
|
|
181
|
+
// failed push a silent no-op: nothing left to commit, the block is skipped,
|
|
182
|
+
// and success is reported though the commit never reached the remote. Push
|
|
183
|
+
// whenever there is something new to send — we just committed, or the local
|
|
184
|
+
// branch already had unpushed commits from an earlier attempt.
|
|
185
|
+
// canopycms's own state under .canopy-meta/ is never content, so it neither
|
|
186
|
+
// makes a commit worth creating nor gets staged into one.
|
|
187
|
+
let committed = false;
|
|
188
|
+
if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
|
|
189
|
+
await git.addAllExceptCanopyState();
|
|
190
|
+
const trailers = buildEditorTrailers(options.submitter ? [options.submitter] : [], {
|
|
191
|
+
editedBy: config.gitEditedByTrailers ?? true,
|
|
192
|
+
coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
|
|
193
|
+
});
|
|
194
|
+
await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
|
|
195
|
+
committed = true;
|
|
196
|
+
}
|
|
197
|
+
if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
|
|
198
|
+
await git.push(options.context.branch.name);
|
|
199
|
+
}
|
|
200
|
+
return status;
|
|
201
|
+
});
|
|
202
|
+
// The list only feeds the PR body, so a failure to compute it falls back to
|
|
203
|
+
// this submit's own working-tree changes rather than failing a submit that
|
|
204
|
+
// has already been pushed.
|
|
205
|
+
let changedPaths;
|
|
206
|
+
try {
|
|
207
|
+
changedPaths = await git.listChangedPathsSinceBase();
|
|
172
208
|
}
|
|
173
|
-
|
|
174
|
-
|
|
209
|
+
catch (err) {
|
|
210
|
+
console.warn(`CanopyCMS: Could not list the changes on ${options.context.branch.name} against its base; ` +
|
|
211
|
+
"the PR body lists only this submit's changes:", redactCredentials(getErrorMessage(err)));
|
|
212
|
+
changedPaths = submitted.files.map((f) => f.path);
|
|
175
213
|
}
|
|
214
|
+
return {
|
|
215
|
+
changedPaths: changedPaths.filter((p) => !p.startsWith(`${BRANCH_META_DIR}/`)),
|
|
216
|
+
};
|
|
176
217
|
};
|
|
177
218
|
// Must be initialized before closures that reference it (commitToSettingsBranch)
|
|
178
219
|
let githubService;
|
|
@@ -233,57 +274,31 @@ async function _createCanopyServicesInternal(config, options) {
|
|
|
233
274
|
error: error instanceof Error ? error.message : 'Push failed',
|
|
234
275
|
};
|
|
235
276
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
console.warn('Failed to create/update PR:', err);
|
|
263
|
-
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
264
|
-
}
|
|
265
|
-
return { committed: true, pushed: true, prUrl, syncStatus: 'synced' };
|
|
266
|
-
}
|
|
267
|
-
// Async path: queue task for worker (prod Lambda has no internet)
|
|
268
|
-
const taskDir = getTaskQueueDir(config);
|
|
269
|
-
try {
|
|
270
|
-
await enqueueTask(taskDir, {
|
|
271
|
-
action: 'push-and-create-or-update-pr',
|
|
272
|
-
payload: {
|
|
273
|
-
branch: settingsBranch,
|
|
274
|
-
baseBranch: config.defaultBaseBranch ?? 'main',
|
|
275
|
-
title: 'Update permissions and groups',
|
|
276
|
-
body: settingsPRBody,
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
return { committed: true, pushed: true, syncStatus: 'pending-sync' };
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
console.warn('Failed to enqueue settings PR task:', err);
|
|
283
|
-
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
284
|
-
}
|
|
277
|
+
// The settings branch is an orphan: it shares no history with the base
|
|
278
|
+
// branch, so GitHub rejects a PR for it. The change took effect when it
|
|
279
|
+
// was committed and pushed above (permissions and groups are read live
|
|
280
|
+
// from the settings workspace); the only GitHub step is mirroring the
|
|
281
|
+
// branch, so there is never a PR.
|
|
282
|
+
if (!operatingStrategy(mode).supportsPullRequests()) {
|
|
283
|
+
return { committed: true, pushed: true };
|
|
284
|
+
}
|
|
285
|
+
// With a githubService the push above already reached GitHub: both it and
|
|
286
|
+
// the workspace remote come from `defaultRemoteUrl`. Without one (prod
|
|
287
|
+
// Lambda has no internet) the worker pushes it.
|
|
288
|
+
if (githubService) {
|
|
289
|
+
return { committed: true, pushed: true, syncStatus: 'synced' };
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await enqueueTask(getTaskQueueDir(config), {
|
|
293
|
+
action: 'push-branch',
|
|
294
|
+
payload: { branch: settingsBranch },
|
|
295
|
+
});
|
|
296
|
+
return { committed: true, pushed: true, syncStatus: 'pending-sync' };
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
console.warn('Failed to enqueue settings push task:', err);
|
|
300
|
+
return { committed: true, pushed: true, syncStatus: 'sync-failed' };
|
|
285
301
|
}
|
|
286
|
-
return { committed: true, pushed: true };
|
|
287
302
|
}
|
|
288
303
|
catch (error) {
|
|
289
304
|
return {
|
|
@@ -8,8 +8,8 @@ import type { OperatingMode } from './operating-mode/index.js';
|
|
|
8
8
|
* its target, and `GitManager.initializeWorkspace` clones INTO the settings
|
|
9
9
|
* root, which `git clone` refuses if anything is already there. A dedicated
|
|
10
10
|
* dot-directory also keeps this marker out of `path.dirname(settingsRoot)`,
|
|
11
|
-
* where `ensureLocalSimulatedRemote` puts `.remote-init.lock` —
|
|
12
|
-
* calls into that while holding this lock, so separate directories make the
|
|
11
|
+
* where `ensureLocalSimulatedRemote` puts `.remote-init.lock` — an in-place
|
|
12
|
+
* settings re-init calls into that while holding this lock, so separate directories make the
|
|
13
13
|
* nesting obvious rather than incidental. For how a lock's anchor path is
|
|
14
14
|
* chosen, see utils/provisioning-lock.ts and docs/concurrency.md.
|
|
15
15
|
* @internal Exported for tests.
|
|
@@ -27,12 +27,32 @@ export interface EnsureSettingsWorkspaceOptions {
|
|
|
27
27
|
* BranchWorkspaceManager this writes no metadata files and never touches the
|
|
28
28
|
* branch registry.
|
|
29
29
|
*
|
|
30
|
+
* A first clone follows [PROV-1] (branch-provisioning.ts): it is built in a
|
|
31
|
+
* staging sibling and renamed into place, so a process killed mid-clone never
|
|
32
|
+
* leaves a half-made workspace at the settings root. Never sparse: the orphan
|
|
33
|
+
* init's `git rm -rf .` would leave out-of-cone base files in the tree.
|
|
34
|
+
*
|
|
30
35
|
* Two locking layers, mirroring BranchWorkspaceManager: an in-memory promise
|
|
31
36
|
* lock within the process, and `acquireProvisioningLock` (proper-lockfile)
|
|
32
|
-
* across processes and hosts.
|
|
37
|
+
* across processes and hosts around the publish and every in-place init.
|
|
33
38
|
*/
|
|
34
39
|
export declare class SettingsWorkspaceManager {
|
|
35
40
|
private readonly config;
|
|
36
41
|
constructor(config: CanopyConfig);
|
|
37
42
|
ensureGitWorkspace(options: EnsureSettingsWorkspaceOptions): Promise<void>;
|
|
43
|
+
private initOptions;
|
|
44
|
+
/**
|
|
45
|
+
* Layer 2 around the init itself (proper-lockfile: heartbeat-refreshed while the holder lives,
|
|
46
|
+
* so a slow EFS operation is not mistaken for a crash, and patient retries so a loser WAITS
|
|
47
|
+
* instead of racing into a concurrent init).
|
|
48
|
+
*/
|
|
49
|
+
private withInitLock;
|
|
50
|
+
/** Re-init an existing workspace where it is; false when, under the lock, there is none. */
|
|
51
|
+
private ensureInPlace;
|
|
52
|
+
/**
|
|
53
|
+
* Clone and set up the settings branch in a staging sibling, then publish it under the init
|
|
54
|
+
* lock, moving an interrupted first init aside to `.trash-settings-*`. A workspace someone
|
|
55
|
+
* else published meanwhile wins and the staging copy is dropped.
|
|
56
|
+
*/
|
|
57
|
+
private provisionStaged;
|
|
38
58
|
}
|
|
@@ -1,14 +1,61 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { GitManager } from './git-manager.js';
|
|
4
|
+
import { removeLeftoverDir, settingsStagingDirName, settingsTrashDirName, } from './branch-provisioning.js';
|
|
4
5
|
import { createDebugLogger } from './utils/debug.js';
|
|
5
|
-
import { getErrorMessage } from './utils/error.js';
|
|
6
|
+
import { getErrorMessage, isNodeError } from './utils/error.js';
|
|
7
|
+
import { canopyLogWarn } from './utils/logger.js';
|
|
8
|
+
import { ProvisionLog } from './utils/provision-log.js';
|
|
6
9
|
import { acquireProvisioningLock } from './utils/provisioning-lock.js';
|
|
7
10
|
import { RESERVED_SETTINGS_BRANCH_PREFIX } from './paths/index.js';
|
|
8
11
|
const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
|
|
9
12
|
// In-memory lock against concurrent init within one process. One lock suffices,
|
|
10
13
|
// unlike content branches, which need one per branch.
|
|
11
14
|
let settingsInitLock = null;
|
|
15
|
+
/**
|
|
16
|
+
* Settings workspaces this process has fully ensured, keyed by {@link ensuredKey}. A hit
|
|
17
|
+
* skips the guard, the init lock and initializeWorkspace's dozen git subprocesses, which
|
|
18
|
+
* otherwise ran on every API request. It is sound because settings freshness never comes from
|
|
19
|
+
* that pass: it reads the remote only to provision the settings branch or repair an empty one
|
|
20
|
+
* (GitManager.createOrphanSettingsBranch), saves pull, and every process reads the one shared
|
|
21
|
+
* workspace. Everything it verified is fixed for the process: the settings-branch name resolves once
|
|
22
|
+
* from config, the remote URL comes from config, and nothing in CanopyCMS checks the settings
|
|
23
|
+
* workspace out onto another branch. groups.json and permissions.json are still read from
|
|
24
|
+
* disk on every request; only the provisioning is memoized. Failures are never recorded. In
|
|
25
|
+
* dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
|
|
26
|
+
* re-seeds it.
|
|
27
|
+
*
|
|
28
|
+
* A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
|
|
29
|
+
* caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
|
|
30
|
+
* path, guard and lock included.
|
|
31
|
+
*/
|
|
32
|
+
const ensuredSettingsWorkspaces = new Set();
|
|
33
|
+
function ensuredKey(options) {
|
|
34
|
+
return `${path.resolve(options.settingsRoot)}\0${options.branchName}`;
|
|
35
|
+
}
|
|
36
|
+
async function headBranch(settingsRoot) {
|
|
37
|
+
try {
|
|
38
|
+
const head = (await fs.readFile(path.join(settingsRoot, '.git', 'HEAD'), 'utf-8')).trim();
|
|
39
|
+
return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function checkedOutOn(settingsRoot, branchName) {
|
|
46
|
+
return (await headBranch(settingsRoot)) === branchName;
|
|
47
|
+
}
|
|
48
|
+
async function hasLocalBranch(settingsRoot, branchName) {
|
|
49
|
+
const gitDir = path.join(settingsRoot, '.git');
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(path.join(gitDir, 'refs', 'heads', branchName));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
const packed = await fs.readFile(path.join(gitDir, 'packed-refs'), 'utf-8').catch(() => '');
|
|
56
|
+
return packed.split('\n').some((line) => line.endsWith(` refs/heads/${branchName}`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
12
59
|
const SETTINGS_INIT_LOCK_DIR = '.settings-init';
|
|
13
60
|
const SETTINGS_INIT_LOCK_NAME = 'lock';
|
|
14
61
|
/**
|
|
@@ -19,8 +66,8 @@ const SETTINGS_INIT_LOCK_NAME = 'lock';
|
|
|
19
66
|
* its target, and `GitManager.initializeWorkspace` clones INTO the settings
|
|
20
67
|
* root, which `git clone` refuses if anything is already there. A dedicated
|
|
21
68
|
* dot-directory also keeps this marker out of `path.dirname(settingsRoot)`,
|
|
22
|
-
* where `ensureLocalSimulatedRemote` puts `.remote-init.lock` —
|
|
23
|
-
* calls into that while holding this lock, so separate directories make the
|
|
69
|
+
* where `ensureLocalSimulatedRemote` puts `.remote-init.lock` — an in-place
|
|
70
|
+
* settings re-init calls into that while holding this lock, so separate directories make the
|
|
24
71
|
* nesting obvious rather than incidental. For how a lock's anchor path is
|
|
25
72
|
* chosen, see utils/provisioning-lock.ts and docs/concurrency.md.
|
|
26
73
|
* @internal Exported for tests.
|
|
@@ -48,14 +95,49 @@ async function settingsFilesPresent(settingsRoot) {
|
|
|
48
95
|
}
|
|
49
96
|
return false;
|
|
50
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* What occupies the settings root.
|
|
100
|
+
*
|
|
101
|
+
* `interrupted` is a first init that died before its settings branch existed: not on this
|
|
102
|
+
* deployment's settings branch or any `canopycms-settings-*` one, no local ref for it, and no
|
|
103
|
+
* settings files -- what the rename guard lets through, minus a local settings branch that could
|
|
104
|
+
* hold unpushed commits. A clone killed in place leaves this behind, often with a stale
|
|
105
|
+
* `config.lock` that fails every later git config write.
|
|
106
|
+
*/
|
|
107
|
+
async function settingsRootState(options) {
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = await fs.readdir(options.settingsRoot);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
114
|
+
return 'vacant';
|
|
115
|
+
if (isNodeError(err) && err.code === 'ENOTDIR')
|
|
116
|
+
return 'interrupted';
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
if (entries.length === 0)
|
|
120
|
+
return 'vacant';
|
|
121
|
+
if (await settingsFilesPresent(options.settingsRoot))
|
|
122
|
+
return 'in-use';
|
|
123
|
+
if (!(await GitManager.repoExistsAt(options.settingsRoot)))
|
|
124
|
+
return 'interrupted';
|
|
125
|
+
const head = await headBranch(options.settingsRoot);
|
|
126
|
+
if (head === options.branchName || head?.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
|
|
127
|
+
return 'in-use';
|
|
128
|
+
}
|
|
129
|
+
if (await hasLocalBranch(options.settingsRoot, options.branchName))
|
|
130
|
+
return 'in-use';
|
|
131
|
+
return 'interrupted';
|
|
132
|
+
}
|
|
51
133
|
/**
|
|
52
134
|
* Rename guard (settings-branch protection): refuse to boot when an
|
|
53
135
|
* ALREADY-POPULATED settings workspace would be re-orphaned under a different
|
|
54
136
|
* branch name.
|
|
55
137
|
*
|
|
56
138
|
* Without it, GitManager.initializeWorkspace sees an existing .git, skips the
|
|
57
|
-
* clone, and calls createOrphanSettingsBranch(branchName); for
|
|
58
|
-
* git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
|
|
139
|
+
* clone, and calls createOrphanSettingsBranch(branchName); for a name neither the
|
|
140
|
+
* workspace nor its remote has, git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
|
|
59
141
|
* Orphan branches share no history, so that is not a migration — it
|
|
60
142
|
* PERMANENTLY WIPES permissions.json/groups.json with nothing to recover from.
|
|
61
143
|
* The trigger is almost always deploymentName / settingsBranch /
|
|
@@ -103,8 +185,9 @@ async function assertSettingsWorkspaceIdentity(options) {
|
|
|
103
185
|
`deploymentName, settingsBranch, or CANOPYCMS_DEPLOYMENT_NAME changed on a ` +
|
|
104
186
|
`deployment that already has a populated settings workspace. To resolve: restore ` +
|
|
105
187
|
`the previous value so this resolves back to ` +
|
|
106
|
-
`'${currentBranch ?? options.branchName}', or, if
|
|
107
|
-
`
|
|
188
|
+
`'${currentBranch ?? options.branchName}', or, if switching is genuinely intended, ` +
|
|
189
|
+
`move ${options.settingsRoot} aside manually first: the next start checks out ` +
|
|
190
|
+
`'${options.branchName}' from the remote, or starts it empty if the remote has none.`);
|
|
108
191
|
}
|
|
109
192
|
}
|
|
110
193
|
/**
|
|
@@ -113,15 +196,26 @@ async function assertSettingsWorkspaceIdentity(options) {
|
|
|
113
196
|
* BranchWorkspaceManager this writes no metadata files and never touches the
|
|
114
197
|
* branch registry.
|
|
115
198
|
*
|
|
199
|
+
* A first clone follows [PROV-1] (branch-provisioning.ts): it is built in a
|
|
200
|
+
* staging sibling and renamed into place, so a process killed mid-clone never
|
|
201
|
+
* leaves a half-made workspace at the settings root. Never sparse: the orphan
|
|
202
|
+
* init's `git rm -rf .` would leave out-of-cone base files in the tree.
|
|
203
|
+
*
|
|
116
204
|
* Two locking layers, mirroring BranchWorkspaceManager: an in-memory promise
|
|
117
205
|
* lock within the process, and `acquireProvisioningLock` (proper-lockfile)
|
|
118
|
-
* across processes and hosts.
|
|
206
|
+
* across processes and hosts around the publish and every in-place init.
|
|
119
207
|
*/
|
|
120
208
|
export class SettingsWorkspaceManager {
|
|
121
209
|
constructor(config) {
|
|
122
210
|
this.config = config;
|
|
123
211
|
}
|
|
124
212
|
async ensureGitWorkspace(options) {
|
|
213
|
+
const key = ensuredKey(options);
|
|
214
|
+
if (ensuredSettingsWorkspaces.has(key)) {
|
|
215
|
+
if (await checkedOutOn(options.settingsRoot, options.branchName))
|
|
216
|
+
return;
|
|
217
|
+
ensuredSettingsWorkspaces.delete(key);
|
|
218
|
+
}
|
|
125
219
|
return log.timed('workspace', 'ensureGitWorkspace', async () => {
|
|
126
220
|
// Layer 1: In-memory lock (prevents redundant async calls within same process)
|
|
127
221
|
if (settingsInitLock) {
|
|
@@ -141,44 +235,11 @@ export class SettingsWorkspaceManager {
|
|
|
141
235
|
// Running it here also means no path reaches initializeWorkspace
|
|
142
236
|
// without the guard having run in THIS process.
|
|
143
237
|
await assertSettingsWorkspaceIdentity(options);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// loser WAITS instead of racing into a concurrent clone).
|
|
148
|
-
const releaseLock = await acquireProvisioningLock(settingsInitLockTarget(options.settingsRoot), SETTINGS_INIT_LOCK_NAME);
|
|
149
|
-
try {
|
|
150
|
-
// Re-run the guard on the now-stable state: a previous holder may
|
|
151
|
-
// have created the workspace, or moved it onto its own settings
|
|
152
|
-
// branch, after we sampled it above, and acting on that stale
|
|
153
|
-
// sample is the destructive path the guard exists to stop. Not
|
|
154
|
-
// gated on any "did I win the race" flag — by design there is none;
|
|
155
|
-
// every process here either holds the lock or has already thrown.
|
|
156
|
-
await assertSettingsWorkspaceIdentity(options);
|
|
157
|
-
// initializeWorkspace is idempotent (it checks for .git), so this
|
|
158
|
-
// is safe even if another process just finished init.
|
|
159
|
-
await GitManager.initializeWorkspace({
|
|
160
|
-
workspacePath: options.settingsRoot,
|
|
161
|
-
branchName: options.branchName,
|
|
162
|
-
mode: options.mode,
|
|
163
|
-
baseBranch: this.config.defaultBaseBranch,
|
|
164
|
-
sourceRoot: this.config.sourceRoot,
|
|
165
|
-
defaultRemoteUrl: this.config.defaultRemoteUrl,
|
|
166
|
-
remoteUrl: options.remoteUrl,
|
|
167
|
-
remoteName: this.config.defaultRemoteName,
|
|
168
|
-
allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
|
|
169
|
-
branchType: 'orphan', // Key difference: orphan branch for settings
|
|
170
|
-
gitBotAuthorName: this.config.gitBotAuthorName,
|
|
171
|
-
gitBotAuthorEmail: this.config.gitBotAuthorEmail,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
finally {
|
|
175
|
-
try {
|
|
176
|
-
await releaseLock();
|
|
177
|
-
}
|
|
178
|
-
catch (err) {
|
|
179
|
-
log.debug('workspace', 'Failed to release settings-init lock', { err });
|
|
180
|
-
}
|
|
238
|
+
if ((await settingsRootState(options)) !== 'in-use' ||
|
|
239
|
+
!(await this.ensureInPlace(options))) {
|
|
240
|
+
await this.provisionStaged(options);
|
|
181
241
|
}
|
|
242
|
+
ensuredSettingsWorkspaces.add(key);
|
|
182
243
|
}
|
|
183
244
|
finally {
|
|
184
245
|
settingsInitLock = null;
|
|
@@ -187,4 +248,95 @@ export class SettingsWorkspaceManager {
|
|
|
187
248
|
await settingsInitLock;
|
|
188
249
|
});
|
|
189
250
|
}
|
|
251
|
+
initOptions(options, workspacePath) {
|
|
252
|
+
return {
|
|
253
|
+
workspacePath,
|
|
254
|
+
branchName: options.branchName,
|
|
255
|
+
mode: options.mode,
|
|
256
|
+
baseBranch: this.config.defaultBaseBranch,
|
|
257
|
+
sourceRoot: this.config.sourceRoot,
|
|
258
|
+
defaultRemoteUrl: this.config.defaultRemoteUrl,
|
|
259
|
+
remoteUrl: options.remoteUrl,
|
|
260
|
+
remoteName: this.config.defaultRemoteName,
|
|
261
|
+
allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
|
|
262
|
+
branchType: 'orphan',
|
|
263
|
+
gitBotAuthorName: this.config.gitBotAuthorName,
|
|
264
|
+
gitBotAuthorEmail: this.config.gitBotAuthorEmail,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Layer 2 around the init itself (proper-lockfile: heartbeat-refreshed while the holder lives,
|
|
269
|
+
* so a slow EFS operation is not mistaken for a crash, and patient retries so a loser WAITS
|
|
270
|
+
* instead of racing into a concurrent init).
|
|
271
|
+
*/
|
|
272
|
+
async withInitLock(options, run) {
|
|
273
|
+
const releaseLock = await acquireProvisioningLock(settingsInitLockTarget(options.settingsRoot), SETTINGS_INIT_LOCK_NAME);
|
|
274
|
+
try {
|
|
275
|
+
// Re-run the guard on the now-stable state: a previous holder may
|
|
276
|
+
// have created the workspace, or moved it onto its own settings
|
|
277
|
+
// branch, after we sampled it above, and acting on that stale
|
|
278
|
+
// sample is the destructive path the guard exists to stop. Not
|
|
279
|
+
// gated on any "did I win the race" flag — by design there is none;
|
|
280
|
+
// every process here either holds the lock or has already thrown.
|
|
281
|
+
await assertSettingsWorkspaceIdentity(options);
|
|
282
|
+
return await run();
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
try {
|
|
286
|
+
await releaseLock();
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
log.debug('workspace', 'Failed to release settings-init lock', { err });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Re-init an existing workspace where it is; false when, under the lock, there is none. */
|
|
294
|
+
async ensureInPlace(options) {
|
|
295
|
+
return this.withInitLock(options, async () => {
|
|
296
|
+
if ((await settingsRootState(options)) !== 'in-use')
|
|
297
|
+
return false;
|
|
298
|
+
await GitManager.initializeWorkspace(this.initOptions(options, options.settingsRoot));
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Clone and set up the settings branch in a staging sibling, then publish it under the init
|
|
304
|
+
* lock, moving an interrupted first init aside to `.trash-settings-*`. A workspace someone
|
|
305
|
+
* else published meanwhile wins and the staging copy is dropped.
|
|
306
|
+
*/
|
|
307
|
+
async provisionStaged(options) {
|
|
308
|
+
const settingsRoot = path.resolve(options.settingsRoot);
|
|
309
|
+
const workspaceRoot = path.dirname(settingsRoot);
|
|
310
|
+
await fs.mkdir(workspaceRoot, { recursive: true });
|
|
311
|
+
const stagingPath = path.join(workspaceRoot, settingsStagingDirName());
|
|
312
|
+
const provisionLog = new ProvisionLog('settings');
|
|
313
|
+
let published = false;
|
|
314
|
+
try {
|
|
315
|
+
await GitManager.initializeWorkspace({
|
|
316
|
+
...this.initOptions(options, stagingPath),
|
|
317
|
+
provisionLog,
|
|
318
|
+
});
|
|
319
|
+
published = await provisionLog.step('publish', () => this.withInitLock(options, async () => {
|
|
320
|
+
const state = await settingsRootState(options);
|
|
321
|
+
if (state === 'in-use')
|
|
322
|
+
return false;
|
|
323
|
+
if (state === 'interrupted') {
|
|
324
|
+
const trashName = settingsTrashDirName();
|
|
325
|
+
await fs.rename(settingsRoot, path.join(workspaceRoot, trashName));
|
|
326
|
+
canopyLogWarn(`[canopy] Moved an unfinished settings workspace clone aside as ${trashName}`);
|
|
327
|
+
}
|
|
328
|
+
await fs.rename(stagingPath, settingsRoot);
|
|
329
|
+
return true;
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
provisionLog.finish('error');
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
if (!published)
|
|
338
|
+
await removeLeftoverDir(stagingPath);
|
|
339
|
+
}
|
|
340
|
+
provisionLog.finish(published ? 'ok' : 'exists');
|
|
341
|
+
}
|
|
190
342
|
}
|
package/dist/static/seo.d.ts
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* entry-schema.ts, which builds a schema group from these same names so the schema side and
|
|
10
10
|
* the read side cannot drift.
|
|
11
11
|
*/
|
|
12
|
-
import { isAbsoluteUrl, stripTrailingSlashes } from '../utils/url-prefix.js';
|
|
12
|
+
import { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash } from '../utils/url-prefix.js';
|
|
13
13
|
/**
|
|
14
14
|
* Re-exported so this module's public surface (and `canopycms/server`'s, via `static/index.ts`)
|
|
15
15
|
* is unchanged by their move to `utils/url-prefix.ts` — where they now sit beside
|
|
16
16
|
* `joinUrlPrefix`, the shared join that `assets/asset-url.ts` also needs and that must not
|
|
17
17
|
* import anything under `static/`.
|
|
18
18
|
*/
|
|
19
|
-
export { isAbsoluteUrl, stripTrailingSlashes };
|
|
19
|
+
export { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash };
|
|
20
20
|
/** og:type values covered by the recommended group. */
|
|
21
21
|
export type SeoOgType = 'website' | 'article' | 'profile';
|
|
22
22
|
/** twitter:card values covered by the recommended group. */
|
|
@@ -87,18 +87,6 @@ export declare function extractSeoFields(entryData: unknown, opts?: ExtractSeoFi
|
|
|
87
87
|
* resolves for anyone holding the link.
|
|
88
88
|
*/
|
|
89
89
|
export declare function isNoindexEntry(entryData: unknown, opts?: SeoFieldLocation): boolean;
|
|
90
|
-
/**
|
|
91
|
-
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
92
|
-
*
|
|
93
|
-
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
94
|
-
* `/blog/rss.xml`) alone, and never doubles an existing slash.
|
|
95
|
-
*
|
|
96
|
-
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
97
|
-
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
98
|
-
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
99
|
-
* with a trailing slash" means and breaks the URL).
|
|
100
|
-
*/
|
|
101
|
-
export declare function withTrailingSlash(path: string): string;
|
|
102
90
|
export interface ResolveSeoUrlOptions {
|
|
103
91
|
/** Site origin (e.g. `https://example.com`). Trailing slashes are stripped. */
|
|
104
92
|
siteUrl?: string;
|
package/dist/static/seo.js
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* entry-schema.ts, which builds a schema group from these same names so the schema side and
|
|
10
10
|
* the read side cannot drift.
|
|
11
11
|
*/
|
|
12
|
-
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes, toSameOriginPath, } from '../utils/url-prefix.js';
|
|
12
|
+
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes, toSameOriginPath, withTrailingSlash, } from '../utils/url-prefix.js';
|
|
13
13
|
/**
|
|
14
14
|
* Re-exported so this module's public surface (and `canopycms/server`'s, via `static/index.ts`)
|
|
15
15
|
* is unchanged by their move to `utils/url-prefix.ts` — where they now sit beside
|
|
16
16
|
* `joinUrlPrefix`, the shared join that `assets/asset-url.ts` also needs and that must not
|
|
17
17
|
* import anything under `static/`.
|
|
18
18
|
*/
|
|
19
|
-
export { isAbsoluteUrl, stripTrailingSlashes };
|
|
19
|
+
export { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash };
|
|
20
20
|
/**
|
|
21
21
|
* The recommended SEO field names. `defineSeoFieldGroup()` emits a schema group using exactly
|
|
22
22
|
* these names, so an adopter who uses both ends needs no configuration at all.
|
|
@@ -106,29 +106,6 @@ export function extractSeoFields(entryData, opts = {}) {
|
|
|
106
106
|
export function isNoindexEntry(entryData, opts = {}) {
|
|
107
107
|
return extractSeoFields(entryData, opts).noindex === true;
|
|
108
108
|
}
|
|
109
|
-
/**
|
|
110
|
-
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
111
|
-
*
|
|
112
|
-
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
113
|
-
* `/blog/rss.xml`) alone, and never doubles an existing slash.
|
|
114
|
-
*
|
|
115
|
-
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
116
|
-
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
117
|
-
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
118
|
-
* with a trailing slash" means and breaks the URL).
|
|
119
|
-
*/
|
|
120
|
-
export function withTrailingSlash(path) {
|
|
121
|
-
const splitIndex = path.search(/[?#]/);
|
|
122
|
-
const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
|
|
123
|
-
const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
|
|
124
|
-
const withLeading = base.startsWith('/') ? base : `/${base}`;
|
|
125
|
-
if (withLeading === '/' || withLeading.endsWith('/'))
|
|
126
|
-
return withLeading + suffix;
|
|
127
|
-
const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
|
|
128
|
-
if (lastSegment.includes('.'))
|
|
129
|
-
return withLeading + suffix;
|
|
130
|
-
return `${withLeading}/${suffix}`;
|
|
131
|
-
}
|
|
132
109
|
/**
|
|
133
110
|
* Resolve a possibly-relative URL for public emission (canonical tag, sitemap URL, OG image).
|
|
134
111
|
*
|