canopycms 0.0.64 → 0.0.65
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/dist/api/assets.js +9 -0
- package/dist/auth/file-based-auth-cache.js +14 -2
- package/dist/branch-health.d.ts +20 -0
- package/dist/branch-health.js +6 -1
- package/dist/cli/cli.d.ts +17 -0
- package/dist/cli/cli.js +41 -4
- package/dist/cli/init.js +31 -4
- package/dist/cli/template-files/Dockerfile.cms.template +8 -2
- package/dist/cli/template-files/cms-stack.ts.template +33 -6
- package/dist/cli/template-files/deploy-cms.yml.template +21 -8
- package/dist/dev-content-watcher.d.ts +10 -0
- package/dist/dev-content-watcher.js +120 -29
- package/dist/task-queue/task-queue.js +10 -1
- package/dist/utils/git.d.ts +17 -0
- package/dist/utils/git.js +61 -0
- package/dist/worker/cms-worker.d.ts +22 -0
- package/dist/worker/cms-worker.js +262 -11
- package/package.json +2 -1
package/dist/api/assets.js
CHANGED
|
@@ -256,6 +256,15 @@ async function serveLazyTransform(assetStore, key) {
|
|
|
256
256
|
if (meta.kind !== 'raster') {
|
|
257
257
|
return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
|
|
258
258
|
}
|
|
259
|
+
// The slug is decorative in the URL but load-bearing in the stored key, so
|
|
260
|
+
// it must equal the asset's real slug: `[a-z0-9-]+` is all the parser can
|
|
261
|
+
// enforce, and every other string that passes it aliases the same image into
|
|
262
|
+
// a new cache key and a new stored object. Canopy's own URLs always carry
|
|
263
|
+
// `meta.slug` (assets/asset-url.ts). Mirrors the prod transform Lambda's
|
|
264
|
+
// handler.ts check - the two paths must agree, or dev accepts URLs prod 404s.
|
|
265
|
+
if (parsed.slug !== meta.slug) {
|
|
266
|
+
return { ok: false, status: 404, error: 'Not found' };
|
|
267
|
+
}
|
|
259
268
|
// When the URL omits an explicit `f=` format, the transform preserves the
|
|
260
269
|
// source format, so the URL's `{ext}` must match the source's real ext
|
|
261
270
|
// exactly - the parser alone can't check this (it doesn't know the source
|
|
@@ -189,10 +189,22 @@ export async function writeAuthCacheSnapshot(cachePath, files) {
|
|
|
189
189
|
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
190
190
|
await fs.rename(tmpPath, finalPath);
|
|
191
191
|
}
|
|
192
|
-
// Atomic symlink swap: create temp symlink, rename over current
|
|
192
|
+
// Atomic symlink swap: create temp symlink, rename over current.
|
|
193
|
+
//
|
|
194
|
+
// The target MUST be relative (the bare `snapshot-<ts>` basename), because
|
|
195
|
+
// writer and reader do not always share a mount namespace. In prod the EC2
|
|
196
|
+
// worker mounts the EFS filesystem root and writes through
|
|
197
|
+
// CANOPYCMS_WORKSPACE_ROOT=/mnt/efs/workspace (cachePath
|
|
198
|
+
// /mnt/efs/workspace/.cache), while the CMS Lambda mounts the /workspace
|
|
199
|
+
// access point at /mnt/efs and reads the SAME directory as /mnt/efs/.cache.
|
|
200
|
+
// An absolute target recorded by one is a nonexistent path to the other -
|
|
201
|
+
// `resolveActiveCacheDir`'s escape guard then correctly rejects it and falls
|
|
202
|
+
// back to the flat layout, where the worker never writes, leaving the Lambda
|
|
203
|
+
// with a permanently empty cache. A relative target resolves against
|
|
204
|
+
// whichever cachePath the reader was given, so it is correct from both.
|
|
193
205
|
const currentLink = path.join(cachePath, 'current');
|
|
194
206
|
const tmpLink = path.join(cachePath, `current-${timestamp}`);
|
|
195
|
-
await fs.symlink(snapshotDir, tmpLink);
|
|
207
|
+
await fs.symlink(path.basename(snapshotDir), tmpLink);
|
|
196
208
|
await fs.rename(tmpLink, currentLink);
|
|
197
209
|
// Clean up old snapshots (keep the 2 most recent)
|
|
198
210
|
await cleanupOldSnapshots(cachePath, 2);
|
package/dist/branch-health.d.ts
CHANGED
|
@@ -34,6 +34,26 @@ export interface BranchHealthEntry {
|
|
|
34
34
|
* repair-content-duplicates admin action.
|
|
35
35
|
*/
|
|
36
36
|
duplicateContentIds?: DuplicateContentId[];
|
|
37
|
+
/**
|
|
38
|
+
* healthy only, and only when true: this clone has an interrupted rebase on
|
|
39
|
+
* disk (`.git/rebase-merge` / `.git/rebase-apply`).
|
|
40
|
+
*
|
|
41
|
+
* Deliberately an advisory flag on `healthy` rather than its own
|
|
42
|
+
* `BranchHealthKind`, for the same reason `duplicateContentIds` is: the
|
|
43
|
+
* branch's metadata is intact and the state is USUALLY transient -- the
|
|
44
|
+
* worker's sync loop aborts an interrupted rebase at the top of its next
|
|
45
|
+
* per-branch pass. What this flag buys is visibility in the window BEFORE
|
|
46
|
+
* that pass runs, where the branch otherwise scanned as unqualified
|
|
47
|
+
* `healthy` while being skipped as dirty every cycle.
|
|
48
|
+
*
|
|
49
|
+
* NOT self-recovering in every case, so a persisting value is the real
|
|
50
|
+
* signal and needs an operator. Two ways it sticks: the abort itself keeps
|
|
51
|
+
* failing, or the branch's status moved off `editing` after it wedged --
|
|
52
|
+
* the rebase loop filters by status BEFORE reaching the recovery step, so a
|
|
53
|
+
* clone that crashed mid-rebase and was then submitted or archived is never
|
|
54
|
+
* revisited, and this flag is the only thing that surfaces it.
|
|
55
|
+
*/
|
|
56
|
+
rebaseInProgress?: boolean;
|
|
37
57
|
/** corrupt-metadata only: message describing why the file failed to load. */
|
|
38
58
|
parseError?: string;
|
|
39
59
|
/** corrupt-metadata only: branch.json's mtime, ISO. Omitted if branch.json itself couldn't be stat'd. */
|
package/dist/branch-health.js
CHANGED
|
@@ -4,6 +4,7 @@ import { BranchMetadataFileManager, BranchMetadataCorruptError } from './branch-
|
|
|
4
4
|
import { ContentIdIndex } from './content-id-index.js';
|
|
5
5
|
import { sanitizeBranchName } from './paths/branch-name.js';
|
|
6
6
|
import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
|
|
7
|
+
import { isRebaseInProgress } from './utils/git.js';
|
|
7
8
|
/**
|
|
8
9
|
* The on-disk path of the cross-process provisioning lock marker for a
|
|
9
10
|
* given branch directory, matching `branch-workspace.ts`'s
|
|
@@ -139,13 +140,17 @@ export async function scanBranchHealth(baseRoot, opts) {
|
|
|
139
140
|
continue;
|
|
140
141
|
}
|
|
141
142
|
if (meta) {
|
|
142
|
-
const duplicateContentIds = await
|
|
143
|
+
const [duplicateContentIds, rebaseInProgress] = await Promise.all([
|
|
144
|
+
scanDuplicateContentIds(branchRoot, contentRootName),
|
|
145
|
+
isRebaseInProgress(branchRoot),
|
|
146
|
+
]);
|
|
143
147
|
entries.push({
|
|
144
148
|
dirName,
|
|
145
149
|
kind: 'healthy',
|
|
146
150
|
...(isBaseBranch ? { isBaseBranch } : {}),
|
|
147
151
|
branch: meta.branch,
|
|
148
152
|
...(duplicateContentIds.length ? { duplicateContentIds } : {}),
|
|
153
|
+
...(rebaseInProgress ? { rebaseInProgress } : {}),
|
|
149
154
|
});
|
|
150
155
|
continue;
|
|
151
156
|
}
|
package/dist/cli/cli.d.ts
CHANGED
|
@@ -37,6 +37,23 @@ export declare function parseAuthFlag(value: string | boolean | undefined): Auth
|
|
|
37
37
|
export declare function parseDualBuildFlag(value: unknown): boolean | undefined;
|
|
38
38
|
declare const SYNC_SUBCOMMANDS: readonly ["push", "pull", "both", "abort"];
|
|
39
39
|
type SyncSubcommand = (typeof SYNC_SUBCOMMANDS)[number];
|
|
40
|
+
/** The auth modes `worker run-once` knows how to build a plugin for. */
|
|
41
|
+
export declare const KNOWN_AUTH_MODES: readonly ["clerk", "dev"];
|
|
42
|
+
export type KnownAuthMode = (typeof KNOWN_AUTH_MODES)[number];
|
|
43
|
+
/**
|
|
44
|
+
* Whether `CANOPY_AUTH_MODE` names a provider the CLI can actually construct.
|
|
45
|
+
*
|
|
46
|
+
* Pure and exported so it is testable: the dispatch below branches on 'clerk'
|
|
47
|
+
* and 'dev' only, and the catch around plugin loading fires solely on an
|
|
48
|
+
* IMPORT failure -- so before this guard existed, any other value (a typo,
|
|
49
|
+
* wrong casing like 'Clerk', a stale value from another system) selected no
|
|
50
|
+
* plugin, skipped the auth refresh entirely, and let the command run to "Done"
|
|
51
|
+
* with exit code 0. A cron'd `CANOPY_AUTH_MODE=clerk canopycms worker
|
|
52
|
+
* run-once` that became `Clerk` refreshed nothing for as long as the typo
|
|
53
|
+
* survived, while the cache aged and a user removed from the Clerk org kept
|
|
54
|
+
* editor access.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isKnownAuthMode(value: string): value is KnownAuthMode;
|
|
40
57
|
/** Resolve sync subcommand from positional arg. Returns null if missing or invalid. Exported for testing. */
|
|
41
58
|
export declare function resolveSyncSubcommand(sub: string | undefined): SyncSubcommand | null;
|
|
42
59
|
export {};
|
package/dist/cli/cli.js
CHANGED
|
@@ -592,7 +592,7 @@ async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 6e4, logger = nullLo
|
|
|
592
592
|
async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 6e4, logger = nullLogger) {
|
|
593
593
|
const now = Date.now();
|
|
594
594
|
let cleaned = 0;
|
|
595
|
-
for (const subdir of ["completed", "failed"]) {
|
|
595
|
+
for (const subdir of ["completed", "failed", "corrupt"]) {
|
|
596
596
|
const dir = path6.join(taskDir, subdir);
|
|
597
597
|
let files;
|
|
598
598
|
try {
|
|
@@ -810,6 +810,13 @@ function configImportPath(appDir, subdirs) {
|
|
|
810
810
|
const totalDepth = appDepth + subdirs;
|
|
811
811
|
return "../".repeat(totalDepth) + "canopycms.config";
|
|
812
812
|
}
|
|
813
|
+
async function firstExistingPath(projectDir, candidates) {
|
|
814
|
+
for (const candidate of candidates) {
|
|
815
|
+
if (await filePathExists2(path7.join(projectDir, candidate)))
|
|
816
|
+
return candidate;
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
813
820
|
async function init(options) {
|
|
814
821
|
const { projectDir, mode, appDir, ai, force, nonInteractive } = options;
|
|
815
822
|
const writeOpts = { force, nonInteractive };
|
|
@@ -864,15 +871,35 @@ async function init(options) {
|
|
|
864
871
|
await writeFile(path7.join(projectDir, appDir, "ai/config.ts"), await aiConfig2(), writeOpts);
|
|
865
872
|
await writeFile(path7.join(projectDir, appDir, `ai/[...path]/${serverRouteExt}`), await aiRoute2({ configImport: configImportPath(appDir, 2) }), writeOpts);
|
|
866
873
|
}
|
|
867
|
-
|
|
868
|
-
|
|
874
|
+
const existingJsConfig = await firstExistingPath(projectDir, [
|
|
875
|
+
"next.config.js",
|
|
876
|
+
"next.config.mjs"
|
|
877
|
+
]);
|
|
878
|
+
if (existingJsConfig) {
|
|
879
|
+
p.note([
|
|
880
|
+
`Found ${existingJsConfig}, which Next loads in preference to next.config.ts.`,
|
|
881
|
+
"Left it untouched rather than writing a second config Next would ignore.",
|
|
882
|
+
"",
|
|
883
|
+
"Wrap your existing config by hand:",
|
|
884
|
+
"",
|
|
885
|
+
" import { withCanopy } from 'canopycms-next/config'",
|
|
886
|
+
" export default withCanopy(yourConfig)"
|
|
887
|
+
].join("\n"), "Manual step");
|
|
888
|
+
} else {
|
|
889
|
+
await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
|
|
890
|
+
}
|
|
891
|
+
await writeFile(path7.join(projectDir, path7.dirname(appDir), "middleware.ts"), await middleware2({ authProvider }), writeOpts);
|
|
869
892
|
const gitignorePath = path7.join(projectDir, ".gitignore");
|
|
893
|
+
const CANOPY_GITIGNORE_BLOCK = "# CanopyCMS\n.canopy-dev/\n";
|
|
870
894
|
if (await filePathExists2(gitignorePath)) {
|
|
871
895
|
const content = await fs6.readFile(gitignorePath, "utf-8");
|
|
872
896
|
if (!content.includes(".canopy-dev")) {
|
|
873
|
-
await fs6.appendFile(gitignorePath,
|
|
897
|
+
await fs6.appendFile(gitignorePath, `
|
|
898
|
+
${CANOPY_GITIGNORE_BLOCK}`);
|
|
874
899
|
p.log.success("updated: .gitignore");
|
|
875
900
|
}
|
|
901
|
+
} else {
|
|
902
|
+
await writeFile(gitignorePath, CANOPY_GITIGNORE_BLOCK, writeOpts);
|
|
876
903
|
}
|
|
877
904
|
const packages = authProvider === "clerk" ? "canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev" : "canopycms canopycms-next canopycms-auth-dev";
|
|
878
905
|
p.note([
|
|
@@ -11196,6 +11223,10 @@ async function requireProjectRoot(command) {
|
|
|
11196
11223
|
}
|
|
11197
11224
|
return root;
|
|
11198
11225
|
}
|
|
11226
|
+
var KNOWN_AUTH_MODES = ["clerk", "dev"];
|
|
11227
|
+
function isKnownAuthMode(value) {
|
|
11228
|
+
return KNOWN_AUTH_MODES.includes(value);
|
|
11229
|
+
}
|
|
11199
11230
|
function resolveSyncSubcommand(sub) {
|
|
11200
11231
|
if (sub && SYNC_SUBCOMMANDS.includes(sub))
|
|
11201
11232
|
return sub;
|
|
@@ -11287,6 +11318,10 @@ async function main() {
|
|
|
11287
11318
|
process.exit(1);
|
|
11288
11319
|
}
|
|
11289
11320
|
const authMode = process.env.CANOPY_AUTH_MODE || "dev";
|
|
11321
|
+
if (!isKnownAuthMode(authMode)) {
|
|
11322
|
+
console.error(`Unknown CANOPY_AUTH_MODE "${authMode}" \u2014 expected one of: ${KNOWN_AUTH_MODES.join(", ")}. No auth plugin was loaded, so the auth cache will NOT be refreshed.`);
|
|
11323
|
+
process.exitCode = 1;
|
|
11324
|
+
}
|
|
11290
11325
|
let authPlugin;
|
|
11291
11326
|
try {
|
|
11292
11327
|
if (authMode === "clerk") {
|
|
@@ -11398,6 +11433,8 @@ if (isDirectRun) {
|
|
|
11398
11433
|
});
|
|
11399
11434
|
}
|
|
11400
11435
|
export {
|
|
11436
|
+
KNOWN_AUTH_MODES,
|
|
11437
|
+
isKnownAuthMode,
|
|
11401
11438
|
parseArgs,
|
|
11402
11439
|
parseAuthFlag,
|
|
11403
11440
|
parseDualBuildFlag,
|
package/dist/cli/init.js
CHANGED
|
@@ -1117,7 +1117,7 @@ async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 6e4, logger = nullLo
|
|
|
1117
1117
|
async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 6e4, logger = nullLogger) {
|
|
1118
1118
|
const now = Date.now();
|
|
1119
1119
|
let cleaned = 0;
|
|
1120
|
-
for (const subdir of ["completed", "failed"]) {
|
|
1120
|
+
for (const subdir of ["completed", "failed", "corrupt"]) {
|
|
1121
1121
|
const dir = path6.join(taskDir, subdir);
|
|
1122
1122
|
let files;
|
|
1123
1123
|
try {
|
|
@@ -1586,6 +1586,13 @@ function configImportPath(appDir, subdirs) {
|
|
|
1586
1586
|
const totalDepth = appDepth + subdirs;
|
|
1587
1587
|
return "../".repeat(totalDepth) + "canopycms.config";
|
|
1588
1588
|
}
|
|
1589
|
+
async function firstExistingPath(projectDir, candidates) {
|
|
1590
|
+
for (const candidate of candidates) {
|
|
1591
|
+
if (await filePathExists(path7.join(projectDir, candidate)))
|
|
1592
|
+
return candidate;
|
|
1593
|
+
}
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1589
1596
|
async function init(options) {
|
|
1590
1597
|
const { projectDir, mode, appDir, ai, force, nonInteractive } = options;
|
|
1591
1598
|
const writeOpts = { force, nonInteractive };
|
|
@@ -1640,15 +1647,35 @@ async function init(options) {
|
|
|
1640
1647
|
await writeFile(path7.join(projectDir, appDir, "ai/config.ts"), await aiConfig2(), writeOpts);
|
|
1641
1648
|
await writeFile(path7.join(projectDir, appDir, `ai/[...path]/${serverRouteExt}`), await aiRoute2({ configImport: configImportPath(appDir, 2) }), writeOpts);
|
|
1642
1649
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1650
|
+
const existingJsConfig = await firstExistingPath(projectDir, [
|
|
1651
|
+
"next.config.js",
|
|
1652
|
+
"next.config.mjs"
|
|
1653
|
+
]);
|
|
1654
|
+
if (existingJsConfig) {
|
|
1655
|
+
p.note([
|
|
1656
|
+
`Found ${existingJsConfig}, which Next loads in preference to next.config.ts.`,
|
|
1657
|
+
"Left it untouched rather than writing a second config Next would ignore.",
|
|
1658
|
+
"",
|
|
1659
|
+
"Wrap your existing config by hand:",
|
|
1660
|
+
"",
|
|
1661
|
+
" import { withCanopy } from 'canopycms-next/config'",
|
|
1662
|
+
" export default withCanopy(yourConfig)"
|
|
1663
|
+
].join("\n"), "Manual step");
|
|
1664
|
+
} else {
|
|
1665
|
+
await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
|
|
1666
|
+
}
|
|
1667
|
+
await writeFile(path7.join(projectDir, path7.dirname(appDir), "middleware.ts"), await middleware2({ authProvider }), writeOpts);
|
|
1645
1668
|
const gitignorePath = path7.join(projectDir, ".gitignore");
|
|
1669
|
+
const CANOPY_GITIGNORE_BLOCK = "# CanopyCMS\n.canopy-dev/\n";
|
|
1646
1670
|
if (await filePathExists(gitignorePath)) {
|
|
1647
1671
|
const content = await fs6.readFile(gitignorePath, "utf-8");
|
|
1648
1672
|
if (!content.includes(".canopy-dev")) {
|
|
1649
|
-
await fs6.appendFile(gitignorePath,
|
|
1673
|
+
await fs6.appendFile(gitignorePath, `
|
|
1674
|
+
${CANOPY_GITIGNORE_BLOCK}`);
|
|
1650
1675
|
p.log.success("updated: .gitignore");
|
|
1651
1676
|
}
|
|
1677
|
+
} else {
|
|
1678
|
+
await writeFile(gitignorePath, CANOPY_GITIGNORE_BLOCK, writeOpts);
|
|
1652
1679
|
}
|
|
1653
1680
|
const packages = authProvider === "clerk" ? "canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev" : "canopycms canopycms-next canopycms-auth-dev";
|
|
1654
1681
|
p.note([
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
# Node 22. Node 20 reached upstream EOL on 2026-04-30, so it receives no
|
|
2
|
+
# further security patches -- and the CMS Lambda runs this image.
|
|
3
|
+
#
|
|
4
|
+
# NOTE: the published canopycms packages declare `engines.node: ">=18"`,
|
|
5
|
+
# so this is not an installability floor -- it is the runtime CanopyCMS is
|
|
6
|
+
# actually built and tested on (.nvmrc is v22, as is the transform Lambda).
|
|
7
|
+
FROM public.ecr.aws/docker/library/node:22-slim AS builder
|
|
2
8
|
WORKDIR /app
|
|
3
9
|
{{DOCKER_COPY}}
|
|
4
10
|
# If your app uses `file:` dependencies (e.g. vendored tarballs under vendor/),
|
|
@@ -53,7 +59,7 @@ ARG NEXT_PUBLIC_CANOPY_MODE=dev
|
|
|
53
59
|
ENV NEXT_PUBLIC_CANOPY_MODE=$NEXT_PUBLIC_CANOPY_MODE
|
|
54
60
|
RUN {{DOCKER_BUILD}}
|
|
55
61
|
|
|
56
|
-
FROM public.ecr.aws/docker/library/node:
|
|
62
|
+
FROM public.ecr.aws/docker/library/node:22-slim AS runner
|
|
57
63
|
# Git is required by CanopyCMS for branch operations
|
|
58
64
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
|
59
65
|
# The runtime user's uid differs from the uid that owns EFS workspace files
|
|
@@ -107,13 +107,35 @@ export class CmsStack extends Stack {
|
|
|
107
107
|
reservedConcurrency: 10,
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
// Media support (uploads, on-demand image transforms).
|
|
111
|
-
// the deployed editor an asset backend, pass `assetBucket:
|
|
112
|
-
// assetSupport.bucket` to CanopyCmsService above, and wire
|
|
113
|
-
// `assetSupport.cloudFrontBehaviors` into the distribution below. See the
|
|
114
|
-
// assets section of docs/deploying-to-aws.md.
|
|
110
|
+
// Media support (uploads, on-demand image transforms). To enable it:
|
|
115
111
|
//
|
|
116
|
-
//
|
|
112
|
+
// 1. add `AssetSupport` to the `canopycms-cdk` import at the top;
|
|
113
|
+
// 2. uncomment the block below, moving it ABOVE `cmsService` (it has to
|
|
114
|
+
// exist before you can pass its bucket);
|
|
115
|
+
// 3. pass `assetBucket: assetSupport.bucket` to CanopyCmsService;
|
|
116
|
+
// 4. pass the behaviors to CanopyCmsDistribution, `/assets/t/*` FIRST
|
|
117
|
+
// (CloudFront matches in order, so the general pattern would
|
|
118
|
+
// otherwise swallow the transform one):
|
|
119
|
+
//
|
|
120
|
+
// additionalBehaviors: {
|
|
121
|
+
// '/assets/t/*': assetSupport.assetBehaviors().assetsTransform,
|
|
122
|
+
// '/assets/*': assetSupport.assetBehaviors().assets,
|
|
123
|
+
// },
|
|
124
|
+
//
|
|
125
|
+
// `editorOrigins` is REQUIRED: it is the S3 CORS allowlist for the
|
|
126
|
+
// editor's presigned uploads, so it must list the origin the editor is
|
|
127
|
+
// served from. Include http://localhost:3000 only if you upload from a
|
|
128
|
+
// local dev editor against this bucket.
|
|
129
|
+
//
|
|
130
|
+
// Run `pnpm --filter canopycms-cdk run build:lambda` before deploying --
|
|
131
|
+
// the transform Lambda needs its native sharp binary, and the construct
|
|
132
|
+
// refuses to synth without it.
|
|
133
|
+
//
|
|
134
|
+
// See the assets section of docs/deploying-to-aws.md.
|
|
135
|
+
//
|
|
136
|
+
// const assetSupport = new AssetSupport(this, 'Assets', {
|
|
137
|
+
// editorOrigins: [`https://${props.domainName}`],
|
|
138
|
+
// })
|
|
117
139
|
|
|
118
140
|
// CloudFront + Route53, only when a domain is configured. Skipping this
|
|
119
141
|
// leaves `cmsService.functionUrl` as the entry point, which is enough to
|
|
@@ -124,6 +146,11 @@ export class CmsStack extends Stack {
|
|
|
124
146
|
functionUrl: cmsService.functionUrl,
|
|
125
147
|
domainName: props.domainName,
|
|
126
148
|
hostedZoneDomain: props.hostedZoneDomain,
|
|
149
|
+
// Keep CloudFront's origin read timeout equal to the Lambda's own.
|
|
150
|
+
// Both default to 60s, so this line changes nothing today -- it is
|
|
151
|
+
// here so that raising `timeout` above cannot silently leave
|
|
152
|
+
// CloudFront answering 504 at the edge on requests that succeed.
|
|
153
|
+
originReadTimeout: cmsService.timeout,
|
|
127
154
|
})
|
|
128
155
|
}
|
|
129
156
|
}
|
|
@@ -39,10 +39,15 @@
|
|
|
39
39
|
# 3. Install the CDK dependencies the app entry point needs:
|
|
40
40
|
# `{{ADD_DEV}} canopycms canopycms-cdk aws-cdk-lib constructs tsx aws-cdk`
|
|
41
41
|
# 4. Set these repository secrets (Settings -> Secrets and variables ->
|
|
42
|
-
# Actions -> Secrets).
|
|
43
|
-
# infrastructure/bin/app.ts, so a missing value fails the deploy at
|
|
42
|
+
# Actions -> Secrets). All but AWS_DEPLOY_ROLE_ARN are read by `required()`
|
|
43
|
+
# in infrastructure/bin/app.ts, so a missing value fails the deploy at
|
|
44
|
+
# synth; a missing role ARN fails earlier, at the AWS credentials step:
|
|
44
45
|
# AWS_DEPLOY_ROLE_ARN OIDC role ARN from step 2
|
|
45
|
-
#
|
|
46
|
+
# CANOPY_GITHUB_TOKEN_SECRET_ARN FULL Secrets Manager ARN (with suffix)
|
|
47
|
+
# NOTE: the CANOPY_ prefix is required, not cosmetic. GitHub rejects any
|
|
48
|
+
# Actions secret whose name starts with GITHUB_ (a reserved prefix), so
|
|
49
|
+
# the otherwise-obvious GITHUB_TOKEN_SECRET_ARN cannot be created at all.
|
|
50
|
+
# The env var handed to CDK below keeps the unprefixed name.
|
|
46
51
|
# CLERK_SECRET_KEY_SECRET_ARN FULL Secrets Manager ARN (with suffix)
|
|
47
52
|
# CLERK_JWT_KEY Clerk's public JWKS PEM
|
|
48
53
|
# 5. Set these repository variables (same page -> Variables):
|
|
@@ -64,6 +69,14 @@ on:
|
|
|
64
69
|
- 'src/**'
|
|
65
70
|
- 'content/**'
|
|
66
71
|
- 'canopycms.config.ts'
|
|
72
|
+
# The Next config is what `init-deploy aws` itself tells you to edit for
|
|
73
|
+
# the dual build, and `public/**` is copied into the runner image (and is
|
|
74
|
+
# load-bearing for editor/public preview parity). Omitting them meant an
|
|
75
|
+
# edit shipped days later, piggybacked on an unrelated content change --
|
|
76
|
+
# so any breakage was attributed to the wrong commit.
|
|
77
|
+
- 'next.config.*'
|
|
78
|
+
- 'middleware.ts'
|
|
79
|
+
- 'public/**'
|
|
67
80
|
- 'Dockerfile.cms'
|
|
68
81
|
- 'infrastructure/**'
|
|
69
82
|
- 'cdk.json'
|
|
@@ -89,11 +102,11 @@ jobs:
|
|
|
89
102
|
contents: read
|
|
90
103
|
|
|
91
104
|
steps:
|
|
92
|
-
- uses: actions/checkout@v4
|
|
105
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
93
106
|
|
|
94
|
-
- uses: actions/setup-node@v4
|
|
107
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
95
108
|
with:
|
|
96
|
-
node-version:
|
|
109
|
+
node-version: 22
|
|
97
110
|
|
|
98
111
|
- name: Install dependencies
|
|
99
112
|
run: {{CI_INSTALL}}
|
|
@@ -124,7 +137,7 @@ jobs:
|
|
|
124
137
|
fi
|
|
125
138
|
|
|
126
139
|
- name: Configure AWS credentials
|
|
127
|
-
uses: aws-actions/configure-aws-credentials@v4
|
|
140
|
+
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
|
|
128
141
|
with:
|
|
129
142
|
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
130
143
|
aws-region: ${{ vars.AWS_REGION }}
|
|
@@ -138,7 +151,7 @@ jobs:
|
|
|
138
151
|
# in one place and not the other fails the deploy at synth.
|
|
139
152
|
- name: Deploy
|
|
140
153
|
env:
|
|
141
|
-
GITHUB_TOKEN_SECRET_ARN: ${{ secrets.
|
|
154
|
+
GITHUB_TOKEN_SECRET_ARN: ${{ secrets.CANOPY_GITHUB_TOKEN_SECRET_ARN }}
|
|
142
155
|
CLERK_SECRET_KEY_SECRET_ARN: ${{ secrets.CLERK_SECRET_KEY_SECRET_ARN }}
|
|
143
156
|
CLERK_JWT_KEY: ${{ secrets.CLERK_JWT_KEY }}
|
|
144
157
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
*
|
|
16
16
|
* All logic lives here in the core package; framework adapters just call startDevContentWatcher() once
|
|
17
17
|
* at dev startup. See packages/canopycms-next/src/context-wrapper.ts for the Next wiring.
|
|
18
|
+
*
|
|
19
|
+
* ## Divergence is a CONDITION, not an event
|
|
20
|
+
*
|
|
21
|
+
* It stays true until someone runs `sync push`, so the two things this module has to get right are
|
|
22
|
+
* (a) not re-printing the same condition into a scrolling request log, and (b) making the one printing
|
|
23
|
+
* of it hard to scroll past. Both are handled below by `reportOnce` + the gutter formatting; see
|
|
24
|
+
* `.claude/future-tasks/dev-divergence-in-app-surface.md` for the in-app surface that would replace
|
|
25
|
+
* the terminal as the primary home for this.
|
|
18
26
|
*/
|
|
19
27
|
import type { CanopyServices } from './services.js';
|
|
20
28
|
import type { DevContentSyncMode } from './config/types.js';
|
|
@@ -28,6 +36,8 @@ export interface StartDevContentWatcherOptions {
|
|
|
28
36
|
/** Warning sink. Defaults to console.warn. */
|
|
29
37
|
warn?: (message: string) => void;
|
|
30
38
|
}
|
|
39
|
+
/** Test-only: dispose every armed watcher and drop all cross-module dedupe state. */
|
|
40
|
+
export declare function resetDevContentWatchersForTests(): void;
|
|
31
41
|
/**
|
|
32
42
|
* Start watching the working-tree content directory for divergence from the served dev branch clone.
|
|
33
43
|
* Returns a disposer that stops the watcher. A no-op (returns immediately) when mode is 'off', when
|
|
@@ -15,35 +15,98 @@
|
|
|
15
15
|
*
|
|
16
16
|
* All logic lives here in the core package; framework adapters just call startDevContentWatcher() once
|
|
17
17
|
* at dev startup. See packages/canopycms-next/src/context-wrapper.ts for the Next wiring.
|
|
18
|
+
*
|
|
19
|
+
* ## Divergence is a CONDITION, not an event
|
|
20
|
+
*
|
|
21
|
+
* It stays true until someone runs `sync push`, so the two things this module has to get right are
|
|
22
|
+
* (a) not re-printing the same condition into a scrolling request log, and (b) making the one printing
|
|
23
|
+
* of it hard to scroll past. Both are handled below by `reportOnce` + the gutter formatting; see
|
|
24
|
+
* `.claude/future-tasks/dev-divergence-in-app-surface.md` for the in-app surface that would replace
|
|
25
|
+
* the terminal as the primary home for this.
|
|
18
26
|
*/
|
|
19
27
|
import fsSync from 'node:fs';
|
|
20
28
|
import path from 'node:path';
|
|
21
29
|
import chokidar from 'chokidar';
|
|
30
|
+
import pc from 'picocolors';
|
|
22
31
|
import { operatingStrategy } from './operating-mode/index.js';
|
|
23
32
|
import { getErrorMessage } from './utils/error.js';
|
|
24
33
|
import { diffContentTrees, isContentTreeDiffEmpty } from './sync-core.js';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Per-category cap on listed files. Deliberately small: the block is a prompt to run `sync push`,
|
|
36
|
+
* not a diff viewer, and a 30-line wall of paths is exactly as easy to scroll past as a 1-line
|
|
37
|
+
* warning. Each list carries its true count, so nothing is hidden -- only elided.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_LISTED_FILES = 5;
|
|
40
|
+
/**
|
|
41
|
+
* The registry lives on `globalThis`, NOT in module scope.
|
|
42
|
+
*
|
|
43
|
+
* Next's dev server compiles the server graph per route bundle and evaluates each copy in its own
|
|
44
|
+
* module scope, so a module-level `Map` is re-created empty per bundle -- every copy then believes it
|
|
45
|
+
* is the first watcher, arms its own, and re-prints the startup warning. (The duplicated
|
|
46
|
+
* "CanopyCMS dev-auth: Auto-configured ..." line in a dev log is the same effect on a different
|
|
47
|
+
* module-level latch.) A `globalThis` property is the one thing shared across those evaluations.
|
|
48
|
+
*/
|
|
49
|
+
const REGISTRY_KEY = '__canopycmsDevContentWatchers__';
|
|
50
|
+
function watcherRegistry() {
|
|
51
|
+
const host = globalThis;
|
|
52
|
+
const existing = host[REGISTRY_KEY];
|
|
53
|
+
if (existing)
|
|
54
|
+
return existing;
|
|
55
|
+
const created = new Map();
|
|
56
|
+
host[REGISTRY_KEY] = created;
|
|
57
|
+
return created;
|
|
58
|
+
}
|
|
59
|
+
/** Test-only: dispose every armed watcher and drop all cross-module dedupe state. */
|
|
60
|
+
export function resetDevContentWatchersForTests() {
|
|
61
|
+
const registry = watcherRegistry();
|
|
62
|
+
for (const state of registry.values())
|
|
63
|
+
state.dispose?.();
|
|
64
|
+
registry.clear();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One body line of the notice block. The left gutter (rather than a full box) is deliberate: content
|
|
68
|
+
* paths are long and unpredictable, and a right border would force either wrapping math or truncation
|
|
69
|
+
* of the very filenames the block exists to name.
|
|
70
|
+
*/
|
|
71
|
+
function body(line) {
|
|
72
|
+
const gutter = pc.yellow('|');
|
|
73
|
+
return line ? `${gutter} ${line}` : gutter;
|
|
74
|
+
}
|
|
30
75
|
function formatList(label, files) {
|
|
31
76
|
if (files.length === 0)
|
|
32
|
-
return
|
|
33
|
-
const shown = files.slice(0, MAX_LISTED_FILES)
|
|
34
|
-
const
|
|
35
|
-
|
|
77
|
+
return [];
|
|
78
|
+
const shown = files.slice(0, MAX_LISTED_FILES);
|
|
79
|
+
const lines = [body(pc.bold(`${label} (${files.length})`)), ...shown.map((f) => body(` ${f}`))];
|
|
80
|
+
if (files.length > shown.length) {
|
|
81
|
+
lines.push(body(pc.dim(` +${files.length - shown.length} more`)));
|
|
82
|
+
}
|
|
83
|
+
return lines;
|
|
36
84
|
}
|
|
37
85
|
function formatDivergenceWarning(branch, diff) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
const total = diff.changed.length + diff.added.length + diff.removed.length;
|
|
87
|
+
// Blank lines top and bottom: the block's job is to be findable in a scrolling request log, and
|
|
88
|
+
// whitespace separation does more for that than any amount of in-block decoration.
|
|
89
|
+
return [
|
|
90
|
+
'',
|
|
91
|
+
`${pc.bgYellow(pc.black(' CanopyCMS '))} ${pc.bold(pc.yellow('working-tree content has diverged'))}`,
|
|
92
|
+
body(`The dev server is serving ${pc.bold('stale content')}: ${total} file(s) differ from the`),
|
|
93
|
+
body(`dev branch clone "${branch}".`),
|
|
94
|
+
body(''),
|
|
95
|
+
...formatList('changed', diff.changed),
|
|
96
|
+
...formatList('only in working tree', diff.added),
|
|
97
|
+
...formatList('only in branch clone', diff.removed),
|
|
98
|
+
body(''),
|
|
99
|
+
body(`Fix: ${pc.bold('npx canopycms sync push')}`),
|
|
100
|
+
body(pc.dim("Silence: set dev.contentSync: 'off' in your Canopy config")),
|
|
101
|
+
'',
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
function formatResolvedNotice(branch) {
|
|
105
|
+
return [
|
|
106
|
+
'',
|
|
107
|
+
`${pc.bgGreen(pc.black(' CanopyCMS '))} ${pc.green(`working-tree content is back in sync with "${branch}"`)}`,
|
|
108
|
+
'',
|
|
109
|
+
].join('\n');
|
|
47
110
|
}
|
|
48
111
|
/**
|
|
49
112
|
* Start watching the working-tree content directory for divergence from the served dev branch clone.
|
|
@@ -73,6 +136,27 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
73
136
|
services.config.defaultActiveBranch ??
|
|
74
137
|
services.config.defaultBaseBranch ??
|
|
75
138
|
'main';
|
|
139
|
+
const registry = watcherRegistry();
|
|
140
|
+
let state = registry.get(workingTreeContentDir);
|
|
141
|
+
if (!state) {
|
|
142
|
+
state = { dispose: null, lastMessage: null, reportedDivergence: false };
|
|
143
|
+
registry.set(workingTreeContentDir, state);
|
|
144
|
+
}
|
|
145
|
+
const watcherState = state;
|
|
146
|
+
// Dispose any prior watcher for this dir (HMR re-start) before creating a new one. The registry
|
|
147
|
+
// ENTRY deliberately survives, carrying lastMessage/reportedDivergence into the new watcher.
|
|
148
|
+
watcherState.dispose?.();
|
|
149
|
+
/**
|
|
150
|
+
* Emit `message` unless it is verbatim what was emitted last for this directory. Covers the
|
|
151
|
+
* divergence block, the resolved notice and the error paths alike: in every case a repeat means
|
|
152
|
+
* "the condition is unchanged", which the reader already knows.
|
|
153
|
+
*/
|
|
154
|
+
const reportOnce = (message) => {
|
|
155
|
+
if (watcherState.lastMessage === message)
|
|
156
|
+
return;
|
|
157
|
+
watcherState.lastMessage = message;
|
|
158
|
+
warn(message);
|
|
159
|
+
};
|
|
76
160
|
let running = false;
|
|
77
161
|
let pending = false;
|
|
78
162
|
const check = async () => {
|
|
@@ -84,16 +168,26 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
84
168
|
try {
|
|
85
169
|
const branch = resolveBranch();
|
|
86
170
|
const branchContentDir = path.join(strategy.getContentBranchRoot(branch, sourceRoot), contentRoot);
|
|
87
|
-
// No branch clone yet (e.g. before the first editor/dev request created it)
|
|
171
|
+
// No branch clone yet (e.g. before the first editor/dev request created it) -> nothing to
|
|
172
|
+
// compare. Deliberately leaves reportedDivergence alone: "cannot tell" is not "resolved".
|
|
88
173
|
if (!fsSync.existsSync(branchContentDir))
|
|
89
174
|
return;
|
|
90
175
|
const diff = await diffContentTrees(workingTreeContentDir, branchContentDir);
|
|
91
|
-
if (isContentTreeDiffEmpty(diff))
|
|
176
|
+
if (isContentTreeDiffEmpty(diff)) {
|
|
177
|
+
// Close the loop: a condition that was announced needs its retraction announced too, or the
|
|
178
|
+
// reader is left believing the dev server is still serving stale content.
|
|
179
|
+
if (watcherState.reportedDivergence) {
|
|
180
|
+
watcherState.reportedDivergence = false;
|
|
181
|
+
reportOnce(formatResolvedNotice(branch));
|
|
182
|
+
}
|
|
92
183
|
return;
|
|
93
|
-
|
|
184
|
+
}
|
|
185
|
+
// Set BEFORE reporting so a deduped repeat still leaves the condition marked as outstanding.
|
|
186
|
+
watcherState.reportedDivergence = true;
|
|
187
|
+
reportOnce(formatDivergenceWarning(branch, diff));
|
|
94
188
|
}
|
|
95
189
|
catch (err) {
|
|
96
|
-
|
|
190
|
+
reportOnce(`CanopyCMS: dev content-sync check failed: ${getErrorMessage(err)}`);
|
|
97
191
|
}
|
|
98
192
|
finally {
|
|
99
193
|
running = false;
|
|
@@ -103,22 +197,19 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
103
197
|
}
|
|
104
198
|
}
|
|
105
199
|
};
|
|
106
|
-
// Dispose any prior watcher for this dir (HMR re-start) before creating a new one.
|
|
107
|
-
activeWatchers.get(workingTreeContentDir)?.();
|
|
108
200
|
const watcher = chokidar.watch(workingTreeContentDir, { ignoreInitial: true });
|
|
109
201
|
watcher.on('add', () => void check());
|
|
110
202
|
watcher.on('change', () => void check());
|
|
111
203
|
watcher.on('unlink', () => void check());
|
|
112
204
|
// Handle watcher errors (e.g. inotify ENOSPC/EMFILE) so an unhandled 'error' event can't crash dev.
|
|
113
|
-
watcher.on('error', (err) =>
|
|
205
|
+
watcher.on('error', (err) => reportOnce(`CanopyCMS: dev content-sync watcher error: ${getErrorMessage(err)}`));
|
|
114
206
|
// Initial divergence check at startup.
|
|
115
207
|
void check();
|
|
116
208
|
const dispose = () => {
|
|
117
209
|
void watcher.close();
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
}
|
|
210
|
+
if (watcherState.dispose === dispose)
|
|
211
|
+
watcherState.dispose = null;
|
|
121
212
|
};
|
|
122
|
-
|
|
213
|
+
watcherState.dispose = dispose;
|
|
123
214
|
return dispose;
|
|
124
215
|
}
|
|
@@ -339,7 +339,16 @@ export async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 60_000, logge
|
|
|
339
339
|
export async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 60_000, logger = nullLogger) {
|
|
340
340
|
const now = Date.now();
|
|
341
341
|
let cleaned = 0;
|
|
342
|
-
|
|
342
|
+
// `corrupt` included: unparseable task files are quarantined there by
|
|
343
|
+
// dequeue and orphan recovery and surfaced in admin listing, but the
|
|
344
|
+
// retention sweep never covered the directory, so it grew forever with
|
|
345
|
+
// deletion available only as a manual per-file admin action. Any recurring
|
|
346
|
+
// producer of malformed task JSON -- a partial write surviving a crash, a
|
|
347
|
+
// bad deploy writing schema-drifted tasks for a week -- accumulated files no
|
|
348
|
+
// automated path removed. Same stamp-based retention as the other two: a
|
|
349
|
+
// quarantined file older than the window has long since been triaged or
|
|
350
|
+
// forgotten.
|
|
351
|
+
for (const subdir of ['completed', 'failed', 'corrupt']) {
|
|
343
352
|
const dir = path.join(taskDir, subdir);
|
|
344
353
|
let files;
|
|
345
354
|
try {
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -72,6 +72,23 @@ export declare function isStaleLeaseRejection(message: string): boolean;
|
|
|
72
72
|
* so callers MUST run git with a locale-pinning env (`gitChildEnv`).
|
|
73
73
|
*/
|
|
74
74
|
export declare function isMissingRemoteRefFailure(message: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge`
|
|
77
|
+
* (interactive/merge backend) or `rebase-apply` (am backend) state directory
|
|
78
|
+
* git leaves behind when a rebase stops for conflicts or the process dies
|
|
79
|
+
* mid-way.
|
|
80
|
+
*
|
|
81
|
+
* This state is invisible to every other check the worker makes: a clone left
|
|
82
|
+
* mid-rebase reports uncommitted changes, so the sync loop's dirty check skips
|
|
83
|
+
* it as `skippedDirty` on every cycle forever, and `branch-health` sees valid
|
|
84
|
+
* branch.json and scans it as healthy. Nothing self-heals, and recovery
|
|
85
|
+
* previously meant an operator running `git rebase --abort` on EFS by hand.
|
|
86
|
+
*
|
|
87
|
+
* Never throws — a missing or unreadable repo is reported as "no rebase",
|
|
88
|
+
* which is the safe direction for both callers (the worker only ever uses a
|
|
89
|
+
* `true` to justify an abort it holds the content-write lock for).
|
|
90
|
+
*/
|
|
91
|
+
export declare function isRebaseInProgress(repoPath: string): Promise<boolean>;
|
|
75
92
|
/**
|
|
76
93
|
* Detect the current HEAD branch name for a given repository root.
|
|
77
94
|
* Returns the branch name, or the provided fallback (default 'main')
|
package/dist/utils/git.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { simpleGit } from 'simple-git';
|
|
2
4
|
// Matches scheme://... URLs (http, https, ssh, git — case-insensitive).
|
|
3
5
|
const NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
|
|
@@ -137,6 +139,65 @@ const MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find r
|
|
|
137
139
|
export function isMissingRemoteRefFailure(message) {
|
|
138
140
|
return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
|
|
139
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Resolve a repository's git directory from its working-tree root, handling
|
|
144
|
+
* both layouts: a real `.git` directory, and a `.git` FILE containing a
|
|
145
|
+
* `gitdir: <path>` pointer (linked worktrees, submodules).
|
|
146
|
+
*
|
|
147
|
+
* Deliberately fs-only rather than `git rev-parse --git-dir`: the callers are
|
|
148
|
+
* a per-branch sync loop and an admin-facing health scan that already walk
|
|
149
|
+
* every branch directory, and neither should pay a subprocess per branch just
|
|
150
|
+
* to find a path.
|
|
151
|
+
*/
|
|
152
|
+
async function resolveGitDir(repoPath) {
|
|
153
|
+
const dotGit = path.join(repoPath, '.git');
|
|
154
|
+
let stat;
|
|
155
|
+
try {
|
|
156
|
+
stat = await fs.stat(dotGit);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (stat.isDirectory())
|
|
162
|
+
return dotGit;
|
|
163
|
+
try {
|
|
164
|
+
const pointer = await fs.readFile(dotGit, 'utf-8');
|
|
165
|
+
const match = /^gitdir:\s*(.+)$/m.exec(pointer);
|
|
166
|
+
if (!match)
|
|
167
|
+
return null;
|
|
168
|
+
const target = match[1].trim();
|
|
169
|
+
return path.isAbsolute(target) ? target : path.resolve(repoPath, target);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge`
|
|
177
|
+
* (interactive/merge backend) or `rebase-apply` (am backend) state directory
|
|
178
|
+
* git leaves behind when a rebase stops for conflicts or the process dies
|
|
179
|
+
* mid-way.
|
|
180
|
+
*
|
|
181
|
+
* This state is invisible to every other check the worker makes: a clone left
|
|
182
|
+
* mid-rebase reports uncommitted changes, so the sync loop's dirty check skips
|
|
183
|
+
* it as `skippedDirty` on every cycle forever, and `branch-health` sees valid
|
|
184
|
+
* branch.json and scans it as healthy. Nothing self-heals, and recovery
|
|
185
|
+
* previously meant an operator running `git rebase --abort` on EFS by hand.
|
|
186
|
+
*
|
|
187
|
+
* Never throws — a missing or unreadable repo is reported as "no rebase",
|
|
188
|
+
* which is the safe direction for both callers (the worker only ever uses a
|
|
189
|
+
* `true` to justify an abort it holds the content-write lock for).
|
|
190
|
+
*/
|
|
191
|
+
export async function isRebaseInProgress(repoPath) {
|
|
192
|
+
const gitDir = await resolveGitDir(repoPath);
|
|
193
|
+
if (!gitDir)
|
|
194
|
+
return false;
|
|
195
|
+
const results = await Promise.all(['rebase-merge', 'rebase-apply'].map((dir) => fs
|
|
196
|
+
.stat(path.join(gitDir, dir))
|
|
197
|
+
.then(() => true)
|
|
198
|
+
.catch(() => false)));
|
|
199
|
+
return results.some(Boolean);
|
|
200
|
+
}
|
|
140
201
|
/**
|
|
141
202
|
* Detect the current HEAD branch name for a given repository root.
|
|
142
203
|
* Returns the branch name, or the provided fallback (default 'main')
|
|
@@ -217,6 +217,28 @@ export declare class CmsWorker {
|
|
|
217
217
|
* is what makes simple-git reject the promise here.
|
|
218
218
|
*/
|
|
219
219
|
private verifyBaseBranchExists;
|
|
220
|
+
/**
|
|
221
|
+
* Guarantee a bare repo's config carries NO `remote.origin.url`, and so no
|
|
222
|
+
* embedded bot token.
|
|
223
|
+
*
|
|
224
|
+
* `git clone https://x-access-token:<token>@github.com/...` records that URL
|
|
225
|
+
* verbatim as `remote.origin.url`, and for `remote.git` that config lives on
|
|
226
|
+
* shared EFS. The security model in docs/deploying-to-aws.md -- "If Lambda is
|
|
227
|
+
* compromised, an attacker can read/write content on EFS but cannot push to
|
|
228
|
+
* GitHub", "Secrets stay on the worker" -- is false while that string is
|
|
229
|
+
* there: a compromised Lambda could read the token off EFS and, despite
|
|
230
|
+
* having no egress of its own, exfiltrate it by writing it into branch
|
|
231
|
+
* content the worker then pushes to GitHub.
|
|
232
|
+
*
|
|
233
|
+
* Nothing needs the remote: every push passes the URL explicitly as an
|
|
234
|
+
* argument (see the `git.push(this.buildGitHubUrl(), ...)` call sites), and
|
|
235
|
+
* `verifyBaseBranchExists` reads local refs.
|
|
236
|
+
*
|
|
237
|
+
* VERIFIES rather than assuming: it re-reads the config and throws if the URL
|
|
238
|
+
* survives, because the previous code's `.catch(() => {})` meant a failed
|
|
239
|
+
* scrub was indistinguishable from a successful one.
|
|
240
|
+
*/
|
|
241
|
+
private scrubPersistedRemote;
|
|
220
242
|
/**
|
|
221
243
|
* Ensure remote.git bare repo exists.
|
|
222
244
|
* On first run, clone from GitHub as a bare repo.
|
|
@@ -14,7 +14,7 @@ import { GITHUB_TRACKING_REF_PREFIX, gitChildEnv, gitNetworkChildEnv } from '../
|
|
|
14
14
|
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
15
15
|
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
16
16
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
17
|
-
import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
|
|
17
|
+
import { isNonFastForwardRejection, isRebaseInProgress, isStaleLeaseRejection } from '../utils/git.js';
|
|
18
18
|
import { writeWorkerStatus } from './worker-status.js';
|
|
19
19
|
import { workerLog, workerLogWarn, workerLogError } from './log.js';
|
|
20
20
|
// Re-exported so the AWS entrypoint (packages/canopycms-cdk/worker/index.ts)
|
|
@@ -440,6 +440,87 @@ export class CmsWorker {
|
|
|
440
440
|
`refs/heads/${this.baseBranch}`,
|
|
441
441
|
]);
|
|
442
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Guarantee a bare repo's config carries NO `remote.origin.url`, and so no
|
|
445
|
+
* embedded bot token.
|
|
446
|
+
*
|
|
447
|
+
* `git clone https://x-access-token:<token>@github.com/...` records that URL
|
|
448
|
+
* verbatim as `remote.origin.url`, and for `remote.git` that config lives on
|
|
449
|
+
* shared EFS. The security model in docs/deploying-to-aws.md -- "If Lambda is
|
|
450
|
+
* compromised, an attacker can read/write content on EFS but cannot push to
|
|
451
|
+
* GitHub", "Secrets stay on the worker" -- is false while that string is
|
|
452
|
+
* there: a compromised Lambda could read the token off EFS and, despite
|
|
453
|
+
* having no egress of its own, exfiltrate it by writing it into branch
|
|
454
|
+
* content the worker then pushes to GitHub.
|
|
455
|
+
*
|
|
456
|
+
* Nothing needs the remote: every push passes the URL explicitly as an
|
|
457
|
+
* argument (see the `git.push(this.buildGitHubUrl(), ...)` call sites), and
|
|
458
|
+
* `verifyBaseBranchExists` reads local refs.
|
|
459
|
+
*
|
|
460
|
+
* VERIFIES rather than assuming: it re-reads the config and throws if the URL
|
|
461
|
+
* survives, because the previous code's `.catch(() => {})` meant a failed
|
|
462
|
+
* scrub was indistinguishable from a successful one.
|
|
463
|
+
*/
|
|
464
|
+
async scrubPersistedRemote(gitDir) {
|
|
465
|
+
const git = simpleGit({ baseDir: gitDir });
|
|
466
|
+
// `git config --get` exits 1 with no output when the key is absent.
|
|
467
|
+
// simple-git does NOT reliably throw on that -- verified against
|
|
468
|
+
// simple-git 3.36: it resolves with an empty string -- so an empty result
|
|
469
|
+
// must be read as "absent" too. Treating "" as a surviving URL is what
|
|
470
|
+
// made the first version of this reject every clean scrub.
|
|
471
|
+
//
|
|
472
|
+
// 'unreadable' is deliberately distinct from 'absent'. A read that fails
|
|
473
|
+
// for any OTHER reason must not be mistaken for "no token here": that
|
|
474
|
+
// would let the pre-check below short-circuit and skip the scrub entirely,
|
|
475
|
+
// silently leaving a token-bearing config on shared EFS -- the exact
|
|
476
|
+
// outcome this function exists to prevent. Fail closed and attempt the
|
|
477
|
+
// removal instead.
|
|
478
|
+
const readOriginUrl = async () => {
|
|
479
|
+
try {
|
|
480
|
+
const url = (await git.raw(['config', '--get', 'remote.origin.url'])).trim();
|
|
481
|
+
return url === '' ? null : url;
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// ANY throw is 'unreadable', never 'absent'. The genuinely-absent case
|
|
485
|
+
// does not reach here at all -- simple-git resolves with '' (verified
|
|
486
|
+
// against 3.36: it only treats a task as failed when stderr is
|
|
487
|
+
// non-empty, and a missing key writes nothing to stderr). So a throw
|
|
488
|
+
// means something actually went wrong, and mapping that to "no token
|
|
489
|
+
// here" would be the one fail-OPEN reading available.
|
|
490
|
+
//
|
|
491
|
+
// An earlier version tried to classify git's exit-1 "key not found"
|
|
492
|
+
// from the message text. That was dead code -- simple-git's GitError
|
|
493
|
+
// message is raw stdout+stderr with no exit-code text -- and its
|
|
494
|
+
// empty-message fallback mapped a hypothetical throw to 'absent',
|
|
495
|
+
// which is exactly the direction this must not fail.
|
|
496
|
+
return 'unreadable';
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const before = await readOriginUrl();
|
|
500
|
+
if (before === null)
|
|
501
|
+
return;
|
|
502
|
+
if (before === 'unreadable') {
|
|
503
|
+
workerLogWarn(` Could not read remote.origin.url in ${gitDir}; attempting the scrub anyway rather than assuming it is absent`);
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
await git.removeRemote('origin');
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
// Reached only from the 'unreadable' path, where the remote may in fact
|
|
510
|
+
// not exist. Let the verification below decide rather than failing here:
|
|
511
|
+
// it is the authoritative check, and it fails closed.
|
|
512
|
+
workerLogWarn(` removeRemote('origin') failed in ${gitDir}: ${getErrorMessage(err)} -- verifying directly`);
|
|
513
|
+
}
|
|
514
|
+
// Fails closed on BOTH a surviving URL and an unverifiable read: if we
|
|
515
|
+
// cannot prove the token is gone from shared storage, we do not proceed.
|
|
516
|
+
const remaining = await readOriginUrl();
|
|
517
|
+
if (remaining !== null) {
|
|
518
|
+
throw new Error(`Could not confirm the 'origin' remote is gone from ${gitDir} (${remaining === 'unreadable'
|
|
519
|
+
? 'its config was unreadable'
|
|
520
|
+
: 'its config still records a URL'}). For a token-bearing clone URL that means the GitHub bot token may be persisted on ` +
|
|
521
|
+
`shared storage. Refusing to continue.`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
443
524
|
/**
|
|
444
525
|
* Ensure remote.git bare repo exists.
|
|
445
526
|
* On first run, clone from GitHub as a bare repo.
|
|
@@ -465,6 +546,14 @@ export class CmsWorker {
|
|
|
465
546
|
exists = false;
|
|
466
547
|
}
|
|
467
548
|
if (exists) {
|
|
549
|
+
// SELF-HEAL, before anything else touches this repo. The previous
|
|
550
|
+
// already-exists path fast-returned without ever re-checking the config,
|
|
551
|
+
// so a token that survived one scrub survived forever -- and a clone
|
|
552
|
+
// interrupted by SIGKILL/power-off between `git clone` and the scrub left
|
|
553
|
+
// a repo whose config already held the token, which additionally hit the
|
|
554
|
+
// "delete remote.git and restart" refusal below and so sat on EFS until
|
|
555
|
+
// an operator acted.
|
|
556
|
+
await this.scrubPersistedRemote(this.remoteGitPath);
|
|
468
557
|
try {
|
|
469
558
|
await this.verifyBaseBranchExists(this.remoteGitPath);
|
|
470
559
|
}
|
|
@@ -479,21 +568,30 @@ export class CmsWorker {
|
|
|
479
568
|
}
|
|
480
569
|
workerLog('Initializing remote.git from GitHub...');
|
|
481
570
|
const git = simpleGit();
|
|
482
|
-
|
|
571
|
+
// Clone under a TEMP name and rename into place only once the token has
|
|
572
|
+
// been scrubbed and the repo verified, so `remote.git` never exists on EFS
|
|
573
|
+
// in a token-bearing state. A crash mid-clone now leaves only this staging
|
|
574
|
+
// directory, which the next boot deletes -- rather than a poisoned
|
|
575
|
+
// `remote.git` that fs.stat cannot distinguish from a healthy one.
|
|
576
|
+
const stagingPath = `${this.remoteGitPath}.cloning`;
|
|
577
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
483
578
|
try {
|
|
484
|
-
await
|
|
579
|
+
await git.clone(this.buildGitHubUrl(), stagingPath, ['--bare']);
|
|
580
|
+
// Before the rename, so the token is gone from the config the moment the
|
|
581
|
+
// repo becomes reachable under its real name. Throws (rather than
|
|
582
|
+
// swallowing) if the scrub does not take.
|
|
583
|
+
await this.scrubPersistedRemote(stagingPath);
|
|
584
|
+
await this.verifyBaseBranchExists(stagingPath);
|
|
485
585
|
}
|
|
486
586
|
catch (err) {
|
|
487
|
-
workerLogError(`remote.git
|
|
587
|
+
workerLogError(`remote.git clone failed: ${redactCredentials(getErrorMessage(err))}`);
|
|
488
588
|
// Deleting before throwing is what makes this recoverable: the next
|
|
489
589
|
// start() sees no remote.git and re-clones, instead of being stuck
|
|
490
590
|
// forever behind a poisoned bare repo that fs.stat alone can't detect.
|
|
491
|
-
await fs.rm(
|
|
492
|
-
throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} has no branch '${this.baseBranch}' - the GitHub repository
|
|
591
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
592
|
+
throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
|
|
493
593
|
}
|
|
494
|
-
|
|
495
|
-
const bareGit = simpleGit({ baseDir: this.remoteGitPath });
|
|
496
|
-
await bareGit.removeRemote('origin').catch(() => { });
|
|
594
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
497
595
|
workerLog('remote.git initialized');
|
|
498
596
|
}
|
|
499
597
|
/**
|
|
@@ -1908,6 +2006,83 @@ export class CmsWorker {
|
|
|
1908
2006
|
throw lockErr;
|
|
1909
2007
|
}
|
|
1910
2008
|
try {
|
|
2009
|
+
// Recover an INTERRUPTED rebase before anything else looks at this
|
|
2010
|
+
// tree. A clone left with .git/rebase-merge (or rebase-apply) reports
|
|
2011
|
+
// uncommitted changes, so without this the dirty check below would
|
|
2012
|
+
// classify it `skippedDirty` on every cycle FOREVER while
|
|
2013
|
+
// branch-health scanned it as healthy -- and editors would meanwhile
|
|
2014
|
+
// read, and be able to save over, conflict-marker content.
|
|
2015
|
+
//
|
|
2016
|
+
// An in-progress rebase is always this worker's own abandoned work:
|
|
2017
|
+
// it is the only thing that ever rebases these clones, and it got
|
|
2018
|
+
// here via a crash, an OOM, a spot interruption, or the ASG rolling
|
|
2019
|
+
// the instance (which happens on EVERY `cdk deploy`, while `stop()`
|
|
2020
|
+
// drains for at most taskTimeoutMs).
|
|
2021
|
+
//
|
|
2022
|
+
// NOT LOSSLESS, and it is important not to claim otherwise. `git
|
|
2023
|
+
// rebase --abort` hard-resets tracked files to the pre-rebase head.
|
|
2024
|
+
// While the worker was DOWN nothing held the [SYNC-C1] content-write
|
|
2025
|
+
// lock, so an editor could have saved into this wedged clone and
|
|
2026
|
+
// received a 200; that save is a working-tree modification, and the
|
|
2027
|
+
// abort reverts it. (New, untracked entry files survive; edits to
|
|
2028
|
+
// existing ones do not.) Taking the lock here stops any FURTHER save
|
|
2029
|
+
// racing the abort, but cannot recover one that already landed.
|
|
2030
|
+
//
|
|
2031
|
+
// Aborting anyway is still the right call: the alternative is a
|
|
2032
|
+
// branch wedged forever whose tree serves conflict-marker content to
|
|
2033
|
+
// editors. What must not happen is doing it SILENTLY -- so anything
|
|
2034
|
+
// modified beyond the rebase's own conflict state is logged by path
|
|
2035
|
+
// first, which is the only record an operator would have.
|
|
2036
|
+
if (await isRebaseInProgress(branchPath)) {
|
|
2037
|
+
const preAbort = await branchGit.status().catch(() => null);
|
|
2038
|
+
// Keyed on the WORKING-TREE column only. The two porcelain columns
|
|
2039
|
+
// mean different things here, and conflating them produces a false
|
|
2040
|
+
// data-loss report on essentially every conflict-wedged recovery
|
|
2041
|
+
// (verified against real git, mid-rebase):
|
|
2042
|
+
//
|
|
2043
|
+
// `M ` index=M, wd=' ' -- the interrupted replay's own cleanly
|
|
2044
|
+
// merged files, already STAGED. These
|
|
2045
|
+
// are committed history and survive the
|
|
2046
|
+
// abort untouched. Not collateral.
|
|
2047
|
+
// ` M` index=' ', wd=M -- a working-tree modification nothing
|
|
2048
|
+
// staged: an editor's save landing while
|
|
2049
|
+
// the worker was down. The abort
|
|
2050
|
+
// discards exactly these.
|
|
2051
|
+
// `??` -- untracked; the abort leaves them.
|
|
2052
|
+
//
|
|
2053
|
+
// KNOWN GAP, stated rather than hidden: a save onto one of the
|
|
2054
|
+
// rebase's own conflicted paths (the "saved over conflict-marker
|
|
2055
|
+
// content" case) is excluded below, because the file reads `UU`
|
|
2056
|
+
// whether or not an editor touched it -- status alone cannot tell
|
|
2057
|
+
// the two apart. Those discards go unlogged.
|
|
2058
|
+
const collateral = (preAbort?.files ?? [])
|
|
2059
|
+
.filter((f) => !preAbort?.conflicted.includes(f.path))
|
|
2060
|
+
.filter((f) => f.working_dir !== ' ' && f.working_dir !== '?')
|
|
2061
|
+
.map((f) => f.path);
|
|
2062
|
+
if (collateral.length > 0) {
|
|
2063
|
+
workerLogWarn(` ${branchDir}: aborting the interrupted rebase will DISCARD working-tree changes to ` +
|
|
2064
|
+
`${collateral.length} file(s) saved while the worker was down: ${collateral.join(', ')}`);
|
|
2065
|
+
}
|
|
2066
|
+
workerLogWarn(` ${branchDir}: found an interrupted rebase (this worker's own abandoned work) -- aborting it to recover the branch`);
|
|
2067
|
+
try {
|
|
2068
|
+
await branchGit.rebase(['--abort']);
|
|
2069
|
+
}
|
|
2070
|
+
catch (abortErr) {
|
|
2071
|
+
// Leave it for the next cycle rather than pressing on: every step
|
|
2072
|
+
// below assumes a clean tree.
|
|
2073
|
+
const reason = redactCredentials(`could not abort interrupted rebase: ${getErrorMessage(abortErr)}`);
|
|
2074
|
+
workerLogWarn(` Skipping ${branchDir}: ${reason}`);
|
|
2075
|
+
failed.push({ branch: branchDir, error: reason });
|
|
2076
|
+
// Record on branch metadata too, like the other two failure exits
|
|
2077
|
+
// (`!completed` and the outer catch). Without this a persistently
|
|
2078
|
+
// un-abortable wedge appeared in worker-status.json but never set
|
|
2079
|
+
// a `syncFailureReason`, so the admin branch panel showed nothing
|
|
2080
|
+
// -- and this is precisely the state that needs an operator,
|
|
2081
|
+
// since it is the one the next cycle cannot fix by itself.
|
|
2082
|
+
await this.recordRebaseFailure(branchPath, branchDir, reason);
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
1911
2086
|
// Skip dirty branches — editor has unsaved changes that can't be rebased.
|
|
1912
2087
|
// Now inside the lock, so no write can land between this check and the
|
|
1913
2088
|
// rebase below.
|
|
@@ -2031,11 +2206,59 @@ export class CmsWorker {
|
|
|
2031
2206
|
await this.afterConflictDetectedForTesting();
|
|
2032
2207
|
// During rebase, --theirs = the branch being replayed (editor's work).
|
|
2033
2208
|
// (git rebase reverses ours/theirs: "ours" is the rebase target, "theirs" is the branch.)
|
|
2209
|
+
//
|
|
2210
|
+
// MODIFY/DELETE conflicts have no "their version" to check out
|
|
2211
|
+
// and must be resolved by staging a delete or an add instead.
|
|
2212
|
+
// `git checkout --theirs` on one exits non-zero ("path ... does
|
|
2213
|
+
// not have their version") and simple-git throws -- and because
|
|
2214
|
+
// this loop body IS the round loop's catch, that throw escapes
|
|
2215
|
+
// the round loop entirely, skipping BOTH `rebase --abort` sites
|
|
2216
|
+
// below and leaving the clone wedged mid-rebase forever. The
|
|
2217
|
+
// index/working-tree code pair identifies which side deleted
|
|
2218
|
+
// (verified against real git, not inferred):
|
|
2219
|
+
//
|
|
2220
|
+
// U/D "deleted by them" -- the BRANCH deleted it, base
|
|
2221
|
+
// modified it. Git leaves base's version in the tree.
|
|
2222
|
+
// Keep-branch-version means honouring the delete: git rm.
|
|
2223
|
+
// D/U "deleted by us" -- base deleted it, the BRANCH
|
|
2224
|
+
// modified it. Git leaves the branch's version in the
|
|
2225
|
+
// tree. Keep-branch-version means keeping it: git add.
|
|
2226
|
+
//
|
|
2227
|
+
// Any per-file resolution that STILL fails routes into the
|
|
2228
|
+
// `!completed` path below (which aborts and records) instead of
|
|
2229
|
+
// escaping -- deliberately NOT a rethrow, since a throw from
|
|
2230
|
+
// here is exactly the bug being fixed.
|
|
2231
|
+
const conflictKind = new Map(st.files.map((f) => [f.path, `${f.index}${f.working_dir}`]));
|
|
2232
|
+
let resolutionFailure;
|
|
2034
2233
|
for (const file of st.conflicted) {
|
|
2035
|
-
|
|
2036
|
-
|
|
2234
|
+
const kind = conflictKind.get(file);
|
|
2235
|
+
try {
|
|
2236
|
+
if (kind === 'UD') {
|
|
2237
|
+
await branchGit.raw(['rm', '-f', '--', file]);
|
|
2238
|
+
}
|
|
2239
|
+
else if (kind === 'DU') {
|
|
2240
|
+
await branchGit.add(file);
|
|
2241
|
+
}
|
|
2242
|
+
else {
|
|
2243
|
+
await branchGit.raw(['checkout', '--theirs', file]);
|
|
2244
|
+
await branchGit.add(file);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
catch (resolveErr) {
|
|
2248
|
+
resolutionFailure =
|
|
2249
|
+
`failed to resolve conflicted file '${file}' (status ${kind ?? '??'}): ` +
|
|
2250
|
+
getErrorMessage(resolveErr);
|
|
2251
|
+
break;
|
|
2252
|
+
}
|
|
2037
2253
|
conflictedFiles.push(file);
|
|
2038
2254
|
}
|
|
2255
|
+
if (resolutionFailure !== undefined) {
|
|
2256
|
+
// Same exit shape as the "unexpected error" branch below: set
|
|
2257
|
+
// failureReason and break, letting the `!completed` block do
|
|
2258
|
+
// the single `rebase --abort` and record the failure once.
|
|
2259
|
+
failureReason = resolutionFailure;
|
|
2260
|
+
break;
|
|
2261
|
+
}
|
|
2039
2262
|
// nextAction stays 'continue'
|
|
2040
2263
|
}
|
|
2041
2264
|
else {
|
|
@@ -2232,6 +2455,34 @@ export class CmsWorker {
|
|
|
2232
2455
|
}
|
|
2233
2456
|
}
|
|
2234
2457
|
finally {
|
|
2458
|
+
// Last-resort guarantee that NO exit path leaves this clone
|
|
2459
|
+
// mid-rebase -- including an unexpected throw from any git step
|
|
2460
|
+
// above, which lands in the outer catch and previously only logged.
|
|
2461
|
+
//
|
|
2462
|
+
// It must live HERE rather than in that outer catch: the catch runs
|
|
2463
|
+
// AFTER this finally has released the content-write lock, so aborting
|
|
2464
|
+
// there would hard-reset a working tree an editor's save could
|
|
2465
|
+
// already be racing -- precisely the [SYNC-C1] hazard the lock
|
|
2466
|
+
// exists to prevent. Inside the finally the lock is still held --
|
|
2467
|
+
// EXCEPT on the narrow path where it was compromised mid-hold, in
|
|
2468
|
+
// which case a newly-admitted writer may already be live and this
|
|
2469
|
+
// abort carries the same exposure as the compromise path's own abort
|
|
2470
|
+
// above. Not special-cased: leaving a clone wedged mid-rebase is the
|
|
2471
|
+
// worse outcome, and the writer in that window is already being told
|
|
2472
|
+
// to retry.
|
|
2473
|
+
//
|
|
2474
|
+
// Guarded on actual rebase state so the happy path and the `continue`
|
|
2475
|
+
// exits cost one stat and do nothing.
|
|
2476
|
+
try {
|
|
2477
|
+
if (await isRebaseInProgress(branchPath)) {
|
|
2478
|
+
workerLogWarn(` ${branchDir}: rebase still in progress on exit -- aborting so the clone is not left wedged`);
|
|
2479
|
+
await branchGit.rebase(['--abort']);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
catch (abortErr) {
|
|
2483
|
+
// Best effort: the next cycle's recovery check retries this.
|
|
2484
|
+
workerLogWarn(` Failed to abort in-progress rebase for ${branchDir}: ${getErrorMessage(abortErr)}`);
|
|
2485
|
+
}
|
|
2235
2486
|
// [SYNC-C1] Released on EVERY exit -- the `continue`s above, a throw
|
|
2236
2487
|
// into the outer catch, and the happy path alike. A stranded lock
|
|
2237
2488
|
// would wedge every write to this branch until it went stale.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.65",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"minimatch": "^9.0.7",
|
|
104
104
|
"minimist": "^1.2.8",
|
|
105
105
|
"pathe": "^1.1.2",
|
|
106
|
+
"picocolors": "^1.1.1",
|
|
106
107
|
"proper-lockfile": "^4.1.2",
|
|
107
108
|
"react-easy-crop": "^6.2.2",
|
|
108
109
|
"react-split-pane": "^0.1.92",
|