canopycms 0.0.64 → 0.0.66-int.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- 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 +89 -4
- package/dist/cli/generate-ai-content.js +48 -0
- 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/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +39 -0
- package/dist/content-store.js +62 -3
- package/dist/context.d.ts +14 -4
- package/dist/context.js +15 -4
- 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/url-exclusivity-fixtures.d.ts +76 -0
- package/dist/url-exclusivity-fixtures.js +119 -0
- package/dist/url-path-resolver.js +8 -4
- 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
|
@@ -34,10 +34,14 @@ export function resolveUrlPathCandidates(urlPath, contentRoot) {
|
|
|
34
34
|
// `index` is not a contrived segment either — it is the slug the index convention requires on
|
|
35
35
|
// disk, so the collision was structural rather than accidental.
|
|
36
36
|
//
|
|
37
|
-
// This closes the `.../index` spelling
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
37
|
+
// This closes the `.../index` spelling only. The other extra URLs an entry answered at —
|
|
38
|
+
// `/<collection>/<entryTypeName>` via candidate 2, and `/<collection>/<entryTypeName>/<slug>`
|
|
39
|
+
// via candidate 1 — are closed DOWNSTREAM instead, by `readByUrlPath` requiring every
|
|
40
|
+
// candidate's `entryPath` to be a collection (`ReadContentInput.urlAddressableOnly`). They have
|
|
41
|
+
// to be: telling those apart needs the branch's schema, and this module is deliberately pure
|
|
42
|
+
// and schema-free so that the candidate shapes stay a fact about URLs rather than about
|
|
43
|
+
// content. The consequence to keep in mind when reading this file: the candidates below are
|
|
44
|
+
// what is ATTEMPTED, not what can resolve.
|
|
41
45
|
//
|
|
42
46
|
// Compared case-INSENSITIVELY, through the shared `isIndexSlug`. This function is the one
|
|
43
47
|
// consumer that sees a raw, un-normalized URL segment — everything downstream lowercases
|
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.66-int.81",
|
|
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",
|