canopycms 0.0.68-int.111 → 0.0.68-int.112
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/branch-health.js +14 -9
- package/dist/git-manager.d.ts +10 -0
- package/dist/git-manager.js +4 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +30 -2
- package/dist/worker/cms-worker.js +192 -14
- package/dist/worker/git-sync.js +4 -3
- package/dist/worker/github-mirror.d.ts +29 -1
- package/dist/worker/github-mirror.js +91 -1
- package/dist/worker/task-runner.js +19 -1
- package/package.json +1 -1
package/dist/branch-health.js
CHANGED
|
@@ -45,15 +45,17 @@ async function readMetaMtime(branchRoot) {
|
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* Scan a healthy branch's content tree for duplicate embedded IDs, giving up
|
|
48
|
-
*
|
|
48
|
+
* when `budget` runs out. Never throws: one branch's unreadable content tree
|
|
49
49
|
* must not take down the whole health scan. Costs a full recursive readdir, so
|
|
50
50
|
* it runs only on request and under a budget. A readdir cannot be cancelled: a
|
|
51
51
|
* scan that misses the deadline keeps running unobserved until it settles.
|
|
52
52
|
*/
|
|
53
|
-
async function scanDuplicateContentIds(branchRoot, contentRootName,
|
|
54
|
-
const remainingMs = deadline - Date.now();
|
|
55
|
-
if (remainingMs <= 0)
|
|
53
|
+
async function scanDuplicateContentIds(branchRoot, contentRootName, budget) {
|
|
54
|
+
const remainingMs = budget.deadline - Date.now();
|
|
55
|
+
if (budget.spent || remainingMs <= 0) {
|
|
56
|
+
budget.spent = true;
|
|
56
57
|
return { state: 'unknown', reason: 'out-of-time' };
|
|
58
|
+
}
|
|
57
59
|
const scan = (async () => {
|
|
58
60
|
try {
|
|
59
61
|
const idIndex = new ContentIdIndex(branchRoot);
|
|
@@ -67,7 +69,10 @@ async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
|
|
|
67
69
|
})();
|
|
68
70
|
let timer;
|
|
69
71
|
const outOfTime = new Promise((resolve) => {
|
|
70
|
-
timer = setTimeout(() =>
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
budget.spent = true;
|
|
74
|
+
resolve({ state: 'unknown', reason: 'out-of-time' });
|
|
75
|
+
}, remainingMs);
|
|
71
76
|
});
|
|
72
77
|
try {
|
|
73
78
|
return await Promise.race([scan, outOfTime]);
|
|
@@ -88,8 +93,8 @@ async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
|
|
|
88
93
|
* reached after the budget is spent reports `unknown`/`out-of-time`.
|
|
89
94
|
*/
|
|
90
95
|
export async function scanBranchHealth(baseRoot, opts) {
|
|
91
|
-
const
|
|
92
|
-
? Date.now() + opts.duplicateIdScan.budgetMs
|
|
96
|
+
const duplicateScanBudget = opts.duplicateIdScan
|
|
97
|
+
? { deadline: Date.now() + opts.duplicateIdScan.budgetMs, spent: false }
|
|
93
98
|
: undefined;
|
|
94
99
|
const resolvedRoot = path.resolve(baseRoot);
|
|
95
100
|
const sanitizedBaseBranchName = sanitizeBranchName(opts.baseBranchName);
|
|
@@ -154,9 +159,9 @@ export async function scanBranchHealth(baseRoot, opts) {
|
|
|
154
159
|
}
|
|
155
160
|
if (meta) {
|
|
156
161
|
const [duplicateIdScan, rebaseInProgress] = await Promise.all([
|
|
157
|
-
|
|
162
|
+
duplicateScanBudget === undefined
|
|
158
163
|
? undefined
|
|
159
|
-
: scanDuplicateContentIds(branchRoot, contentRootName,
|
|
164
|
+
: scanDuplicateContentIds(branchRoot, contentRootName, duplicateScanBudget),
|
|
160
165
|
isRebaseInProgress(branchRoot),
|
|
161
166
|
]);
|
|
162
167
|
entries.push({
|
package/dist/git-manager.d.ts
CHANGED
|
@@ -49,6 +49,16 @@ export type BareRemoteRepackResult = {
|
|
|
49
49
|
* it packed and their emptied fan-out directories. `git` as for `ensureRemoteGitConfig`.
|
|
50
50
|
*/
|
|
51
51
|
export declare function repackBareRemoteIfNeeded(gitDir: string, git?: SimpleGit): Promise<BareRemoteRepackResult>;
|
|
52
|
+
/**
|
|
53
|
+
* simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
|
|
54
|
+
* so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
|
|
55
|
+
* GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
|
|
56
|
+
* remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
|
|
57
|
+
* it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
|
|
58
|
+
*/
|
|
59
|
+
export declare function failOnSignalExit(error: Buffer | Error | undefined, result: {
|
|
60
|
+
exitCode: number;
|
|
61
|
+
}): Buffer | Error | undefined;
|
|
52
62
|
/**
|
|
53
63
|
* Config every workspace clone writes into its own `.git/config`: the managed
|
|
54
64
|
* marker that ensureRemote's guard checks, and a fallback author for internal
|
package/dist/git-manager.js
CHANGED
|
@@ -179,10 +179,11 @@ export async function repackBareRemoteIfNeeded(gitDir, git = simpleGit().env(git
|
|
|
179
179
|
/**
|
|
180
180
|
* simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
|
|
181
181
|
* so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
|
|
182
|
-
* GitManager's own instance
|
|
183
|
-
*
|
|
182
|
+
* GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
|
|
183
|
+
* remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
|
|
184
|
+
* it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
|
|
184
185
|
*/
|
|
185
|
-
function failOnSignalExit(error, result) {
|
|
186
|
+
export function failOnSignalExit(error, result) {
|
|
186
187
|
if (error)
|
|
187
188
|
return error;
|
|
188
189
|
if (result.exitCode === null) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.0.68-int.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.0.68-int.112";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.0.68-int.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.0.68-int.112';
|
|
@@ -278,9 +278,19 @@ export declare class CmsWorker {
|
|
|
278
278
|
/**
|
|
279
279
|
* An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
|
|
280
280
|
* reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
|
|
281
|
-
* branch, which the clone then records as that HEAD.
|
|
281
|
+
* branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
|
|
282
|
+
* outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
|
|
283
|
+
* replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
|
|
282
284
|
*/
|
|
283
285
|
private resolveBaseBranch;
|
|
286
|
+
/**
|
|
287
|
+
* Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
|
|
288
|
+
* something wrote there, and listing the refs a replacement would lose means running git under
|
|
289
|
+
* that config. The message says so, so System health does not read it as a routine failure.
|
|
290
|
+
*/
|
|
291
|
+
private assertRemoteGitConfig;
|
|
292
|
+
/** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
|
|
293
|
+
private hasContentBranch;
|
|
284
294
|
/**
|
|
285
295
|
* Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
|
|
286
296
|
* (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
|
|
@@ -337,9 +347,27 @@ export declare class CmsWorker {
|
|
|
337
347
|
* pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
|
|
338
348
|
* left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
|
|
339
349
|
* The base branch is therefore verified right after seeding AND on the already-exists fast
|
|
340
|
-
* path,
|
|
350
|
+
* path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
|
|
341
351
|
*/
|
|
342
352
|
private ensureRemoteGit;
|
|
353
|
+
/**
|
|
354
|
+
* Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
|
|
355
|
+
* when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
|
|
356
|
+
* a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
|
|
357
|
+
* naming the refs at stake: unpushed work such as a settings branch.
|
|
358
|
+
*
|
|
359
|
+
* The refs are compared in the same mirror session that fetches GitHub and seeds the
|
|
360
|
+
* replacement, and listed again just before the swap, since the Lambda can push into remote.git
|
|
361
|
+
* while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
|
|
362
|
+
* the new one, or for an instant nothing, never a half-built or half-deleted one.
|
|
363
|
+
*/
|
|
364
|
+
private replacePoisonedRemoteGit;
|
|
365
|
+
/**
|
|
366
|
+
* Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
|
|
367
|
+
* return its path for the caller to rename into place. `check` runs in the mirror session right
|
|
368
|
+
* after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
|
|
369
|
+
*/
|
|
370
|
+
private seedRemoteGitStaging;
|
|
343
371
|
/**
|
|
344
372
|
* Refuse a state directory on the shared filesystem: the mirror there would be as writable by
|
|
345
373
|
* the Lambda as remote.git is.
|
|
@@ -6,9 +6,9 @@ import lockfile from 'proper-lockfile';
|
|
|
6
6
|
import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
|
|
7
7
|
import { createCanopyOctokit } from '../github-service.js';
|
|
8
8
|
import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
|
|
9
|
-
import { ensureRemoteGitConfig } from '../git-manager.js';
|
|
9
|
+
import { GITHUB_TRACKING_REF_PREFIX, ensureRemoteGitConfig, failOnSignalExit } from '../git-manager.js';
|
|
10
10
|
import { readHeadBranch } from '../utils/git.js';
|
|
11
|
-
import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
11
|
+
import { isSettingsBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, } from '../paths/branch-name.js';
|
|
12
12
|
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
13
13
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
14
14
|
import { readCarriedOverStatus, readWorkerStatusStartedAt, writeWorkerStatus, } from '../task-queue/worker-status.js';
|
|
@@ -55,6 +55,44 @@ function defaultStateDirectory(workspacePath) {
|
|
|
55
55
|
const key = createHash('sha256').update(path.resolve(workspacePath)).digest('hex').slice(0, 16);
|
|
56
56
|
return path.join(os.tmpdir(), `canopycms-worker-${key}`);
|
|
57
57
|
}
|
|
58
|
+
/** How many refs a refusal to replace remote.git names before summarising the rest. */
|
|
59
|
+
const MAX_REFS_LISTED = 20;
|
|
60
|
+
/** A refusal to replace a poisoned remote.git because it holds work GitHub does not have. */
|
|
61
|
+
class RemoteGitKeptError extends Error {
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Every ref in the bare repo at `gitDir` outside the GitHub tracking namespace, which only ever
|
|
65
|
+
* holds GitHub's own branches, mapped to the object it names. A ref missing from this list is one
|
|
66
|
+
* a replacement would discard unchecked, so it throws when git reports anything on stderr
|
|
67
|
+
* (`for-each-ref` skips a ref it cannot read with only a warning and exit 0) and when git is killed
|
|
68
|
+
* by a signal, which simple-git otherwise resolves as empty output. A dangling symbolic ref is
|
|
69
|
+
* skipped without a warning, and names nothing to lose.
|
|
70
|
+
*/
|
|
71
|
+
async function listRemoteGitRefs(gitDir) {
|
|
72
|
+
let stderr = '';
|
|
73
|
+
const output = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit })
|
|
74
|
+
.outputHandler((_command, _stdout, err) => {
|
|
75
|
+
err.on('data', (chunk) => {
|
|
76
|
+
stderr += String(chunk);
|
|
77
|
+
});
|
|
78
|
+
})
|
|
79
|
+
.raw(['for-each-ref', '--format=%(objectname) %(refname)']);
|
|
80
|
+
if (stderr.trim() !== '')
|
|
81
|
+
throw new Error(stderr.trim());
|
|
82
|
+
const refs = new Map();
|
|
83
|
+
for (const line of output.split('\n')) {
|
|
84
|
+
const space = line.indexOf(' ');
|
|
85
|
+
if (space === -1)
|
|
86
|
+
continue;
|
|
87
|
+
const ref = line.slice(space + 1);
|
|
88
|
+
if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX))
|
|
89
|
+
refs.set(ref, line.slice(0, space));
|
|
90
|
+
}
|
|
91
|
+
return refs;
|
|
92
|
+
}
|
|
93
|
+
function sameRefs(a, b) {
|
|
94
|
+
return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
|
|
95
|
+
}
|
|
58
96
|
const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
59
97
|
const DEFAULT_MAX_RETRIES = 3;
|
|
60
98
|
const DEFAULT_LOCK_STALE_MS = 60_000;
|
|
@@ -675,7 +713,9 @@ export class CmsWorker {
|
|
|
675
713
|
/**
|
|
676
714
|
* An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
|
|
677
715
|
* reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
|
|
678
|
-
* branch, which the clone then records as that HEAD.
|
|
716
|
+
* branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
|
|
717
|
+
* outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
|
|
718
|
+
* replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
|
|
679
719
|
*/
|
|
680
720
|
async resolveBaseBranch() {
|
|
681
721
|
if (this.resolvedBaseBranch)
|
|
@@ -698,10 +738,11 @@ export class CmsWorker {
|
|
|
698
738
|
// Before the first git to read it, and on its own: a refusal is recorded as itself, not as a
|
|
699
739
|
// base branch to configure or a remote.git to delete.
|
|
700
740
|
if (remoteGitExists)
|
|
701
|
-
await
|
|
741
|
+
await this.assertRemoteGitConfig();
|
|
702
742
|
let name;
|
|
703
743
|
try {
|
|
704
|
-
|
|
744
|
+
const fromRemoteGit = remoteGitExists && (await this.hasContentBranch(this.remoteGitPath));
|
|
745
|
+
name = fromRemoteGit
|
|
705
746
|
? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, 'bare'))
|
|
706
747
|
: (await this.octokitClient().repos.get({
|
|
707
748
|
owner: this.config.githubOwner,
|
|
@@ -714,6 +755,33 @@ export class CmsWorker {
|
|
|
714
755
|
this.setBaseBranch(name);
|
|
715
756
|
workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
|
|
716
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
|
|
760
|
+
* something wrote there, and listing the refs a replacement would lose means running git under
|
|
761
|
+
* that config. The message says so, so System health does not read it as a routine failure.
|
|
762
|
+
*/
|
|
763
|
+
async assertRemoteGitConfig() {
|
|
764
|
+
try {
|
|
765
|
+
await assertSharedRepoConfig(this.remoteGitPath, 'bare');
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
if (!(err instanceof SharedRepoRefusalError))
|
|
769
|
+
throw err;
|
|
770
|
+
throw new SharedRepoRefusalError(`${err.message.replace(/\.$/, '')}. This needs an operator: the worker never repairs or ` +
|
|
771
|
+
`replaces a remote.git whose config was tampered with or cannot be read.`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
|
|
775
|
+
async hasContentBranch(gitDir) {
|
|
776
|
+
const branches = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit }).raw([
|
|
777
|
+
'for-each-ref',
|
|
778
|
+
'--format=%(refname:strip=2)',
|
|
779
|
+
'refs/heads/',
|
|
780
|
+
]);
|
|
781
|
+
return branches
|
|
782
|
+
.split('\n')
|
|
783
|
+
.some((branch) => branch !== '' && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
|
|
784
|
+
}
|
|
717
785
|
/**
|
|
718
786
|
* Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
|
|
719
787
|
* (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
|
|
@@ -843,7 +911,7 @@ export class CmsWorker {
|
|
|
843
911
|
* pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
|
|
844
912
|
* left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
|
|
845
913
|
* The base branch is therefore verified right after seeding AND on the already-exists fast
|
|
846
|
-
* path,
|
|
914
|
+
* path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
|
|
847
915
|
*/
|
|
848
916
|
async ensureRemoteGit() {
|
|
849
917
|
let exists;
|
|
@@ -857,7 +925,7 @@ export class CmsWorker {
|
|
|
857
925
|
if (exists) {
|
|
858
926
|
// At boot, so a refusal lands in worker-status.json as a startup failure, and first: the
|
|
859
927
|
// scrub is git reading this config too.
|
|
860
|
-
await
|
|
928
|
+
await this.assertRemoteGitConfig();
|
|
861
929
|
// SELF-HEAL, before anything else changes this repo: a remote.git cloned from GitHub by an
|
|
862
930
|
// older worker recorded the token-bearing clone URL in its config.
|
|
863
931
|
await this.scrubPersistedRemote(this.remoteGitPath);
|
|
@@ -866,15 +934,121 @@ export class CmsWorker {
|
|
|
866
934
|
}
|
|
867
935
|
catch (err) {
|
|
868
936
|
workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
// deletion is the operator's call.
|
|
872
|
-
throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
|
|
937
|
+
await this.replacePoisonedRemoteGit();
|
|
938
|
+
return;
|
|
873
939
|
}
|
|
874
940
|
await this.applyRemoteGitConfig(this.remoteGitPath);
|
|
875
941
|
return; // Already exists and has the base branch
|
|
876
942
|
}
|
|
877
943
|
workerLog('Initializing remote.git from GitHub...');
|
|
944
|
+
const stagingPath = await this.seedRemoteGitStaging();
|
|
945
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
946
|
+
workerLog('remote.git initialized');
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
|
|
950
|
+
* when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
|
|
951
|
+
* a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
|
|
952
|
+
* naming the refs at stake: unpushed work such as a settings branch.
|
|
953
|
+
*
|
|
954
|
+
* The refs are compared in the same mirror session that fetches GitHub and seeds the
|
|
955
|
+
* replacement, and listed again just before the swap, since the Lambda can push into remote.git
|
|
956
|
+
* while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
|
|
957
|
+
* the new one, or for an instant nothing, never a half-built or half-deleted one.
|
|
958
|
+
*/
|
|
959
|
+
async replacePoisonedRemoteGit() {
|
|
960
|
+
const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the ` +
|
|
961
|
+
`GitHub repository was empty, or the base branch is set to one it never had)`;
|
|
962
|
+
const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
|
|
963
|
+
let refs;
|
|
964
|
+
try {
|
|
965
|
+
refs = await listRemoteGitRefs(this.remoteGitPath);
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
throw notReplaced(`its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so ` +
|
|
969
|
+
`the worker cannot tell whether replacing it would lose work. Deleting ` +
|
|
970
|
+
`${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`);
|
|
971
|
+
}
|
|
972
|
+
// Only a base branch the listing confirms absent: a check that failed for any other reason
|
|
973
|
+
// is no evidence the repo is poisoned.
|
|
974
|
+
if (refs.has(`refs/heads/${this.baseBranch}`)) {
|
|
975
|
+
throw new Error(`remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not ` +
|
|
976
|
+
`verify it, so the worker will not replace remote.git. Restarting the worker checks again.`);
|
|
977
|
+
}
|
|
978
|
+
workerLog(`remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`);
|
|
979
|
+
const stagingPath = await this.seedRemoteGitStaging(async (mirror) => {
|
|
980
|
+
const unpushed = [];
|
|
981
|
+
for (const [ref, id] of refs) {
|
|
982
|
+
if (!(await mirror.isOnGitHub(id)))
|
|
983
|
+
unpushed.push(ref);
|
|
984
|
+
}
|
|
985
|
+
if (unpushed.length === 0)
|
|
986
|
+
return;
|
|
987
|
+
const shown = unpushed.slice(0, MAX_REFS_LISTED);
|
|
988
|
+
const more = unpushed.length - shown.length;
|
|
989
|
+
throw new RemoteGitKeptError(`${poisoned}, and it holds ${unpushed.length === 1 ? 'a ref' : `${unpushed.length} refs`} ` +
|
|
990
|
+
`GitHub does not have, so the worker will not replace it: ${shown.join(', ')}` +
|
|
991
|
+
`${more > 0 ? ` and ${more} more` : ''}. Deleting ${this.remoteGitPath} and restarting ` +
|
|
992
|
+
`the worker re-clones it and discards ${unpushed.length === 1 ? 'that ref' : 'those refs'}.`);
|
|
993
|
+
}).catch((err) => {
|
|
994
|
+
if (err instanceof RemoteGitKeptError)
|
|
995
|
+
throw err;
|
|
996
|
+
throw notReplaced(redactCredentials(getErrorMessage(err)));
|
|
997
|
+
});
|
|
998
|
+
const discardStaging = () => fs.rm(stagingPath, { recursive: true, force: true });
|
|
999
|
+
let current;
|
|
1000
|
+
try {
|
|
1001
|
+
current = await listRemoteGitRefs(this.remoteGitPath);
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
await discardStaging();
|
|
1005
|
+
throw notReplaced(`its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the ` +
|
|
1006
|
+
`worker checks again.`);
|
|
1007
|
+
}
|
|
1008
|
+
if (!sameRefs(refs, current)) {
|
|
1009
|
+
await discardStaging();
|
|
1010
|
+
throw notReplaced('its refs changed while the worker seeded its replacement. Restarting the worker checks again.');
|
|
1011
|
+
}
|
|
1012
|
+
const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
|
|
1013
|
+
try {
|
|
1014
|
+
await fs.rename(this.remoteGitPath, replaced);
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
await discardStaging();
|
|
1018
|
+
throw err;
|
|
1019
|
+
}
|
|
1020
|
+
try {
|
|
1021
|
+
await fs.rename(stagingPath, this.remoteGitPath);
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
await fs.rename(replaced, this.remoteGitPath);
|
|
1025
|
+
await discardStaging();
|
|
1026
|
+
throw err;
|
|
1027
|
+
}
|
|
1028
|
+
// A push that resolved remote.git before the rename can still land in the old repo. One that
|
|
1029
|
+
// lands before this listing keeps it; a later one is lost from remote.git, though the clone
|
|
1030
|
+
// that pushed still has its commit.
|
|
1031
|
+
const after = await listRemoteGitRefs(replaced).catch(() => null);
|
|
1032
|
+
if (after !== null && sameRefs(refs, after)) {
|
|
1033
|
+
// remote.git is already replaced, so a push still writing here costs only this directory.
|
|
1034
|
+
await fs
|
|
1035
|
+
.rm(replaced, { recursive: true, force: true, maxRetries: 3 })
|
|
1036
|
+
.catch((err) => {
|
|
1037
|
+
workerLogWarn(`Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
workerLogError(`Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so ` +
|
|
1042
|
+
`it may hold work GitHub does not have.`);
|
|
1043
|
+
}
|
|
1044
|
+
workerLog('remote.git replaced');
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
|
|
1048
|
+
* return its path for the caller to rename into place. `check` runs in the mirror session right
|
|
1049
|
+
* after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
|
|
1050
|
+
*/
|
|
1051
|
+
async seedRemoteGitStaging(check) {
|
|
878
1052
|
// Seeded under a TEMP name and renamed into place only once verified, so a crash mid-seed
|
|
879
1053
|
// leaves only this staging directory, which the next boot deletes, rather than a poisoned
|
|
880
1054
|
// `remote.git` that fs.stat cannot distinguish from a healthy one.
|
|
@@ -884,6 +1058,7 @@ export class CmsWorker {
|
|
|
884
1058
|
const githubUrl = await this.buildGitHubUrl();
|
|
885
1059
|
await this.githubMirror.exclusive(async (mirror) => {
|
|
886
1060
|
await mirror.fetchFromGitHub(githubUrl);
|
|
1061
|
+
await check?.(mirror);
|
|
887
1062
|
if ((await mirror.branchTip(this.baseBranch)) === null) {
|
|
888
1063
|
throw new Error(`GitHub has no branch '${this.baseBranch}'`);
|
|
889
1064
|
}
|
|
@@ -899,11 +1074,15 @@ export class CmsWorker {
|
|
|
899
1074
|
await this.applyRemoteGitConfig(stagingPath);
|
|
900
1075
|
}
|
|
901
1076
|
catch (err) {
|
|
902
|
-
|
|
1077
|
+
if (!(err instanceof RemoteGitKeptError)) {
|
|
1078
|
+
workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
|
|
1079
|
+
}
|
|
903
1080
|
// Deleting before throwing is what makes this recoverable: the next
|
|
904
1081
|
// start() sees no remote.git and re-clones, instead of sticking forever
|
|
905
1082
|
// behind a poisoned bare repo fs.stat alone cannot detect.
|
|
906
1083
|
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
1084
|
+
if (err instanceof RemoteGitKeptError)
|
|
1085
|
+
throw err;
|
|
907
1086
|
if (err instanceof SharedRepoRefusalError) {
|
|
908
1087
|
// Not the refusal's own advice: the directory it names is gone.
|
|
909
1088
|
throw new SharedRepoRefusalError(`Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded ` +
|
|
@@ -915,8 +1094,7 @@ export class CmsWorker {
|
|
|
915
1094
|
}
|
|
916
1095
|
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).`);
|
|
917
1096
|
}
|
|
918
|
-
|
|
919
|
-
workerLog('remote.git initialized');
|
|
1097
|
+
return stagingPath;
|
|
920
1098
|
}
|
|
921
1099
|
/**
|
|
922
1100
|
* Refuse a state directory on the shared filesystem: the mirror there would be as writable by
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -87,9 +87,10 @@ export async function pushSettingsBranches(ctx, git, trackedNames) {
|
|
|
87
87
|
// worker's private mirror, which alone ever sees the credential.
|
|
88
88
|
const sha = (await git.revparse(['--verify', `refs/heads/${settingsBranch}`])).trim();
|
|
89
89
|
const githubUrl = await ctx.buildGitHubUrl();
|
|
90
|
-
await ctx
|
|
91
|
-
.
|
|
92
|
-
|
|
90
|
+
await ctx.githubMirror().exclusive((mirror) => mirror.pushToGitHub(githubUrl, settingsBranch, sha, {
|
|
91
|
+
signal: ctx.shutdownSignal(),
|
|
92
|
+
protectedBranches: [ctx.baseBranch],
|
|
93
|
+
}));
|
|
93
94
|
workerLog(`Pushed settings branch ${settingsBranch} to GitHub`);
|
|
94
95
|
}
|
|
95
96
|
catch (err) {
|
|
@@ -43,6 +43,22 @@ export declare class GitHubMirror {
|
|
|
43
43
|
/** Repack when it needs it, and say once if it has grown past {@link MIRROR_SIZE_WARN_KIB}. */
|
|
44
44
|
maintain(): Promise<BareRemoteRepackResult>;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A push the worker never makes: to a name that is not a plain branch name, to the base branch, or
|
|
48
|
+
* to GitHub's default branch. No CanopyCMS flow asks for any of these, so a task that does was not
|
|
49
|
+
* written by CanopyCMS.
|
|
50
|
+
*/
|
|
51
|
+
export declare class RefusedPushError extends Error {
|
|
52
|
+
readonly branch: string;
|
|
53
|
+
constructor(branch: string, reason: 'invalid' | 'protected');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Throw {@link RefusedPushError} unless `branch` is a plain branch name: one
|
|
57
|
+
* `git check-ref-format --branch` accepts unchanged. That rules out a `:` (git would read what
|
|
58
|
+
* follows it in a refspec as another destination), `..`, `^`, `~`, `@{`, spaces and a leading
|
|
59
|
+
* `-`, so `refs/heads/<branch>` is exactly the ref a push names.
|
|
60
|
+
*/
|
|
61
|
+
export declare function assertPlainBranchName(branch: string): Promise<void>;
|
|
46
62
|
/** What one {@link GitHubMirror.exclusive} call may do. */
|
|
47
63
|
export declare class MirrorSession {
|
|
48
64
|
private readonly gitDir;
|
|
@@ -66,8 +82,19 @@ export declare class MirrorSession {
|
|
|
66
82
|
* objects the Lambda can write.
|
|
67
83
|
*/
|
|
68
84
|
private fetchFromRemoteGit;
|
|
85
|
+
/**
|
|
86
|
+
* The branch GitHub's HEAD names, or null for a repository with none (empty, or HEAD unborn). A
|
|
87
|
+
* failure to reach GitHub throws, so a push that cannot learn it does not happen.
|
|
88
|
+
*/
|
|
89
|
+
private githubDefaultBranch;
|
|
69
90
|
/** The mirror's tip for `branch`, or null when GitHub had no such branch at the last fetch. */
|
|
70
91
|
branchTip(branch: string): Promise<string | null>;
|
|
92
|
+
/**
|
|
93
|
+
* Whether a branch GitHub had at the last fetch contains the commit `id`, or the commit an
|
|
94
|
+
* annotated tag `id` points at. False for an object the mirror does not have, or one git cannot
|
|
95
|
+
* read as a commit.
|
|
96
|
+
*/
|
|
97
|
+
isOnGitHub(id: string): Promise<boolean>;
|
|
71
98
|
/**
|
|
72
99
|
* Copy GitHub's branches into `remote.git`'s tracking namespace, pruning the ones GitHub no
|
|
73
100
|
* longer has. `reconcileTrackedBranches` (worker/git-sync.ts) moves `refs/heads/*` from there.
|
|
@@ -83,8 +110,9 @@ export declare class MirrorSession {
|
|
|
83
110
|
* commit from `remote.git` first is the step that brings the Lambda's objects across, through
|
|
84
111
|
* the pinned `upload-pack`.
|
|
85
112
|
*/
|
|
86
|
-
pushToGitHub(githubUrl: string, branch: string, sha: string, options
|
|
113
|
+
pushToGitHub(githubUrl: string, branch: string, sha: string, options: {
|
|
87
114
|
lease?: string;
|
|
88
115
|
signal?: AbortSignal;
|
|
116
|
+
protectedBranches: readonly string[];
|
|
89
117
|
}): Promise<void>;
|
|
90
118
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { simpleGit } from 'simple-git';
|
|
4
4
|
import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv, repackBareRemoteIfNeeded, } from '../git-manager.js';
|
|
5
|
+
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
5
6
|
import { getErrorMessage, redactCredentials } from '../utils/error.js';
|
|
6
7
|
import { workerLogWarn } from './log.js';
|
|
7
8
|
import { mirrorGitOptions, pinnedReceivePack, pinnedUploadPack } from './shared-repo-git.js';
|
|
@@ -119,6 +120,44 @@ async function assertOwnDirectory(dir) {
|
|
|
119
120
|
`credential is used in the repository there`);
|
|
120
121
|
}
|
|
121
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* A push the worker never makes: to a name that is not a plain branch name, to the base branch, or
|
|
125
|
+
* to GitHub's default branch. No CanopyCMS flow asks for any of these, so a task that does was not
|
|
126
|
+
* written by CanopyCMS.
|
|
127
|
+
*/
|
|
128
|
+
export class RefusedPushError extends Error {
|
|
129
|
+
constructor(branch, reason) {
|
|
130
|
+
super(reason === 'invalid'
|
|
131
|
+
? `Refusing to push ${JSON.stringify(branch)} to GitHub: it is not a valid branch name.`
|
|
132
|
+
: `Refusing to push "${branch}" to GitHub: it is the base branch or GitHub's default ` +
|
|
133
|
+
`branch, which the CanopyCMS worker never pushes. Nothing in CanopyCMS queues such a ` +
|
|
134
|
+
`push; find out what wrote this task.`);
|
|
135
|
+
this.branch = branch;
|
|
136
|
+
this.name = 'RefusedPushError';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Throw {@link RefusedPushError} unless `branch` is a plain branch name: one
|
|
141
|
+
* `git check-ref-format --branch` accepts unchanged. That rules out a `:` (git would read what
|
|
142
|
+
* follows it in a refspec as another destination), `..`, `^`, `~`, `@{`, spaces and a leading
|
|
143
|
+
* `-`, so `refs/heads/<branch>` is exactly the ref a push names.
|
|
144
|
+
*/
|
|
145
|
+
export async function assertPlainBranchName(branch) {
|
|
146
|
+
let normalized;
|
|
147
|
+
try {
|
|
148
|
+
normalized = (await simpleGit().raw(['check-ref-format', '--branch', branch])).trim();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// Only git's own verdict is a refusal; a failure to run git at all is transient.
|
|
152
|
+
if (/is not a valid branch name/.test(getErrorMessage(err))) {
|
|
153
|
+
throw new RefusedPushError(branch, 'invalid');
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
// `@{-1}` and the like resolve to some other name.
|
|
158
|
+
if (normalized !== branch)
|
|
159
|
+
throw new RefusedPushError(branch, 'invalid');
|
|
160
|
+
}
|
|
122
161
|
/** What one {@link GitHubMirror.exclusive} call may do. */
|
|
123
162
|
export class MirrorSession {
|
|
124
163
|
constructor(gitDir, remoteGitPath, timeoutMs) {
|
|
@@ -186,6 +225,21 @@ export class MirrorSession {
|
|
|
186
225
|
...refspecs,
|
|
187
226
|
]);
|
|
188
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* The branch GitHub's HEAD names, or null for a repository with none (empty, or HEAD unborn). A
|
|
230
|
+
* failure to reach GitHub throws, so a push that cannot learn it does not happen.
|
|
231
|
+
*/
|
|
232
|
+
async githubDefaultBranch(githubUrl, signal) {
|
|
233
|
+
const out = await this.git(signal).raw([
|
|
234
|
+
'ls-remote',
|
|
235
|
+
'--symref',
|
|
236
|
+
'--end-of-options',
|
|
237
|
+
githubUrl,
|
|
238
|
+
'HEAD',
|
|
239
|
+
]);
|
|
240
|
+
const match = /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(out);
|
|
241
|
+
return match ? match[1] : null;
|
|
242
|
+
}
|
|
189
243
|
/** The mirror's tip for `branch`, or null when GitHub had no such branch at the last fetch. */
|
|
190
244
|
async branchTip(branch) {
|
|
191
245
|
try {
|
|
@@ -196,6 +250,28 @@ export class MirrorSession {
|
|
|
196
250
|
return null;
|
|
197
251
|
}
|
|
198
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Whether a branch GitHub had at the last fetch contains the commit `id`, or the commit an
|
|
255
|
+
* annotated tag `id` points at. False for an object the mirror does not have, or one git cannot
|
|
256
|
+
* read as a commit.
|
|
257
|
+
*/
|
|
258
|
+
async isOnGitHub(id) {
|
|
259
|
+
if (!isObjectId(id))
|
|
260
|
+
return false;
|
|
261
|
+
try {
|
|
262
|
+
const found = await this.git().raw([
|
|
263
|
+
'for-each-ref',
|
|
264
|
+
'--count=1',
|
|
265
|
+
'--format=%(refname)',
|
|
266
|
+
`--contains=${id}`,
|
|
267
|
+
'refs/heads/',
|
|
268
|
+
]);
|
|
269
|
+
return found.trim() !== '';
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
199
275
|
/**
|
|
200
276
|
* Copy GitHub's branches into `remote.git`'s tracking namespace, pruning the ones GitHub no
|
|
201
277
|
* longer has. `reconcileTrackedBranches` (worker/git-sync.ts) moves `refs/heads/*` from there.
|
|
@@ -225,11 +301,25 @@ export class MirrorSession {
|
|
|
225
301
|
* commit from `remote.git` first is the step that brings the Lambda's objects across, through
|
|
226
302
|
* the pinned `upload-pack`.
|
|
227
303
|
*/
|
|
228
|
-
async pushToGitHub(githubUrl, branch, sha, options
|
|
304
|
+
async pushToGitHub(githubUrl, branch, sha, options) {
|
|
229
305
|
for (const id of [sha, ...(options.lease === undefined ? [] : [options.lease])]) {
|
|
230
306
|
if (!isObjectId(id))
|
|
231
307
|
throw new Error(`Not a commit ID: ${JSON.stringify(id)}`);
|
|
232
308
|
}
|
|
309
|
+
// Here, at the one place every GitHub push (plain or under a lease) goes through, so no caller
|
|
310
|
+
// can skip it, and before any git runs with `branch` in a refspec. The task queue, remote.git
|
|
311
|
+
// and the lease marker are all Lambda-writable, and the worker's base branch comes from
|
|
312
|
+
// Lambda-writable state when it is not configured (`CmsWorker.resolveBaseBranch`); GitHub's default branch is read from GitHub
|
|
313
|
+
// itself, per push.
|
|
314
|
+
await assertPlainBranchName(branch);
|
|
315
|
+
const protectedNames = [...options.protectedBranches];
|
|
316
|
+
const githubDefault = await this.githubDefaultBranch(githubUrl, options.signal);
|
|
317
|
+
if (githubDefault !== null)
|
|
318
|
+
protectedNames.push(githubDefault);
|
|
319
|
+
const target = sanitizeBranchName(branch);
|
|
320
|
+
if (protectedNames.some((name) => name === branch || sanitizeBranchName(name) === target)) {
|
|
321
|
+
throw new RefusedPushError(branch, 'protected');
|
|
322
|
+
}
|
|
233
323
|
const staging = `${STAGING_PREFIX}${branch}`;
|
|
234
324
|
const git = this.git(options.signal);
|
|
235
325
|
try {
|
|
@@ -5,6 +5,7 @@ import { BranchMetadataCorruptError, BranchMetadataFileManager, getBranchMetadat
|
|
|
5
5
|
import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
6
6
|
import { getErrorMessage, redactCredentials } from '../utils/error.js';
|
|
7
7
|
import { isNonFastForwardRejection, isStaleLeaseRejection, workflowPushRefusalFile, } from '../utils/git.js';
|
|
8
|
+
import { RefusedPushError, assertPlainBranchName } from './github-mirror.js';
|
|
8
9
|
import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
|
|
9
10
|
import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
10
11
|
import { workerLog, workerLogError, workerLogWarn } from './log.js';
|
|
@@ -565,6 +566,16 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
565
566
|
// commit remote.git held when it was read, so what the marker logic at the end compares is
|
|
566
567
|
// what GitHub received.
|
|
567
568
|
await assertSharedRepoConfig(ctx.remoteGitPath, 'bare');
|
|
569
|
+
// Before `branch` reaches any git: `readPublishedSha` below would read a `<rev>:<path>` form as a
|
|
570
|
+
// tree lookup in remote.git. A refusal is permanent; a failure to run git is retried.
|
|
571
|
+
try {
|
|
572
|
+
await assertPlainBranchName(branch);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
if (err instanceof RefusedPushError)
|
|
576
|
+
throw new PermanentTaskError(err.message);
|
|
577
|
+
throw err;
|
|
578
|
+
}
|
|
568
579
|
// Resolve the tokenized URL ONCE, here: all three pushes below use this
|
|
569
580
|
// const and none calls ctx.buildGitHubUrl() again. A correctness
|
|
570
581
|
// requirement, not tidiness, since resolution is async: the retry push sits
|
|
@@ -591,12 +602,19 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
591
602
|
// only after receiving the whole pack: a push killed before that changes nothing, and one
|
|
592
603
|
// killed after it is found already done by the re-run.
|
|
593
604
|
const outcome = await ctx.githubMirror().exclusive(async (mirror) => {
|
|
594
|
-
const push = (lease) => mirror.pushToGitHub(githubUrl, branch, outgoingSha, {
|
|
605
|
+
const push = (lease) => mirror.pushToGitHub(githubUrl, branch, outgoingSha, {
|
|
606
|
+
lease,
|
|
607
|
+
signal,
|
|
608
|
+
protectedBranches: [ctx.baseBranch],
|
|
609
|
+
});
|
|
595
610
|
try {
|
|
596
611
|
await push(marker);
|
|
597
612
|
return 'pushed';
|
|
598
613
|
}
|
|
599
614
|
catch (err) {
|
|
615
|
+
// A retry can never make the branch not protected.
|
|
616
|
+
if (err instanceof RefusedPushError)
|
|
617
|
+
throw new PermanentTaskError(err.message);
|
|
600
618
|
const message = getErrorMessage(err);
|
|
601
619
|
throwIfWorkflowRefusal(branch, message);
|
|
602
620
|
// A refused lease means GitHub is not at the commit we rewrote, so the
|
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.68-int.
|
|
4
|
+
"version": "0.0.68-int.112",
|
|
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": {
|