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.
@@ -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);
@@ -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. */
@@ -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 scanDuplicateContentIds(branchRoot, contentRootName);
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
- await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
868
- await writeFile(path7.join(projectDir, "middleware.ts"), await middleware2({ authProvider }), writeOpts);
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, "\n# CanopyCMS\n.canopy-dev/\n");
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
- await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
1644
- await writeFile(path7.join(projectDir, "middleware.ts"), await middleware2({ authProvider }), writeOpts);
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, "\n# CanopyCMS\n.canopy-dev/\n");
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
- FROM public.ecr.aws/docker/library/node:20-slim AS builder
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:20-slim AS runner
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). Uncomment to give
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
- // const assetSupport = new AssetSupport(this, 'Assets', {})
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). Every one of them is read by `required()` in
43
- # infrastructure/bin/app.ts, so a missing value fails the deploy at synth:
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
- # GITHUB_TOKEN_SECRET_ARN FULL Secrets Manager ARN (with suffix)
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: 20
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.GITHUB_TOKEN_SECRET_ARN }}
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
- const MAX_LISTED_FILES = 10;
26
- // Dedupe watchers by working-tree content dir so HMR re-evaluating the adapter module (which discards
27
- // the disposer) doesn't accumulate watchers. This module lives in canopycms/server, so its state
28
- // survives app-side HMR. Each new start disposes any prior watcher for the same dir.
29
- const activeWatchers = new Map();
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 null;
33
- const shown = files.slice(0, MAX_LISTED_FILES).join(', ');
34
- const extra = files.length > MAX_LISTED_FILES ? `, …(+${files.length - MAX_LISTED_FILES} more)` : '';
35
- return ` ${label}: ${shown}${extra}`;
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 lines = [
39
- `CanopyCMS: working-tree content has diverged from the dev branch clone "${branch}" — the dev ` +
40
- 'server is serving stale content. Run `npx canopycms sync push` to update it ' +
41
- "(set dev.contentSync: 'off' to silence this).",
42
- formatList('changed', diff.changed),
43
- formatList('only in working tree', diff.added),
44
- formatList('only in branch clone', diff.removed),
45
- ].filter((line) => line !== null);
46
- return lines.join('\n');
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) → nothing to compare.
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
- warn(formatDivergenceWarning(branch, diff));
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
- warn(`CanopyCMS: dev content-sync check failed: ${getErrorMessage(err)}`);
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) => warn(`CanopyCMS: dev content-sync watcher error: ${getErrorMessage(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 (activeWatchers.get(workingTreeContentDir) === dispose) {
119
- activeWatchers.delete(workingTreeContentDir);
120
- }
210
+ if (watcherState.dispose === dispose)
211
+ watcherState.dispose = null;
121
212
  };
122
- activeWatchers.set(workingTreeContentDir, dispose);
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
- for (const subdir of ['completed', 'failed']) {
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 {
@@ -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
- await git.clone(this.buildGitHubUrl(), this.remoteGitPath, ['--bare']);
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 this.verifyBaseBranchExists(this.remoteGitPath);
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 base branch verification failed after clone: ${getErrorMessage(err)}`);
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(this.remoteGitPath, { recursive: true, force: true });
492
- throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} has no branch '${this.baseBranch}' - the GitHub repository is empty or the base branch does not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
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
- // Remove the origin remote so the token doesn't persist in config
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
- await branchGit.raw(['checkout', '--theirs', file]);
2036
- await branchGit.add(file);
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.64",
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",