cairn-ai 0.2.4 → 0.2.5
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/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cairn-lifecycle.mjs +118 -28
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { realpathSync } from "node:fs";
|
|
4
|
+
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
5
5
|
import {
|
|
6
6
|
copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile,
|
|
7
7
|
} from "node:fs/promises";
|
|
@@ -119,17 +119,18 @@ async function uninstall() {
|
|
|
119
119
|
const conflicts = [];
|
|
120
120
|
for (const record of ownership.targets) {
|
|
121
121
|
const current = await targetDigest(record.path, record.type);
|
|
122
|
-
if (current !== record.installedDigest) conflicts.push(record.path);
|
|
122
|
+
if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
|
|
123
123
|
}
|
|
124
124
|
if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
|
|
125
125
|
const transaction = await createTransaction();
|
|
126
126
|
try {
|
|
127
127
|
const configRecord = ownership.targets.find((record) => record.type === "config");
|
|
128
|
-
if (configRecord) {
|
|
128
|
+
if (configRecord && await exists(configRecord.path)) {
|
|
129
|
+
const snapshot = await readSharedConfigSnapshot(configRecord.path);
|
|
129
130
|
const staged = join(transaction.stageRoot, "uninstall-config.toml");
|
|
130
131
|
await mkdir(dirname(staged), { recursive: true });
|
|
131
|
-
await writeFile(staged, removeCairnConfig(
|
|
132
|
-
await replaceTarget({ ...configRecord, staged }, transaction);
|
|
132
|
+
await writeFile(staged, removeCairnConfig(snapshot.text));
|
|
133
|
+
await replaceTarget({ ...configRecord, staged, configSourceDigest: snapshot.digest }, transaction);
|
|
133
134
|
}
|
|
134
135
|
injectFailure("uninstall-after-config");
|
|
135
136
|
for (const phase of ["antigravity", "claude"]) {
|
|
@@ -166,7 +167,7 @@ async function doctor() {
|
|
|
166
167
|
checks.push(["installed CLI script", await exists(join(versionedPluginRoot, "scripts", "cairn.mjs"))]);
|
|
167
168
|
checks.push(["installed plan template", await exists(join(versionedPluginRoot, "templates", "work-plan.md"))]);
|
|
168
169
|
checks.push(["installed model guidance", await exists(join(versionedPluginRoot, "docs", "model-guidance", "codex.md"))]);
|
|
169
|
-
const config = await
|
|
170
|
+
const config = (await readSharedConfigSnapshot(configPath)).text;
|
|
170
171
|
checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
|
|
171
172
|
checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
|
|
172
173
|
checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath)]);
|
|
@@ -204,7 +205,7 @@ async function ownershipDigestsValid(ownership) {
|
|
|
204
205
|
|
|
205
206
|
async function stageInstall(stageRoot) {
|
|
206
207
|
const targets = [];
|
|
207
|
-
const add = (id, phase, destination, staged, type = "tree") => targets.push({ id, phase, path: destination, staged, type });
|
|
208
|
+
const add = (id, phase, destination, staged, type = "tree", details = {}) => targets.push({ id, phase, path: destination, staged, type, ...details });
|
|
208
209
|
const sourceStage = join(stageRoot, "codex-source");
|
|
209
210
|
const runtimeStage = join(stageRoot, "codex-runtime");
|
|
210
211
|
await copyPluginCandidate(sourceStage, versionedPluginRoot);
|
|
@@ -239,8 +240,9 @@ async function stageInstall(stageRoot) {
|
|
|
239
240
|
|
|
240
241
|
const configStage = join(stageRoot, "config.toml");
|
|
241
242
|
const hookStates = await trustedHookStates(runtimeStage);
|
|
242
|
-
|
|
243
|
-
|
|
243
|
+
const configSnapshot = await readSharedConfigSnapshot(configPath);
|
|
244
|
+
await writeFile(configStage, updateConfig(configSnapshot.text, hookStates));
|
|
245
|
+
add("codex-config", "config", configPath, configStage, "config", { configSourceDigest: configSnapshot.digest });
|
|
244
246
|
for (const target of targets) await assertRegularTree(target.staged);
|
|
245
247
|
return targets;
|
|
246
248
|
}
|
|
@@ -465,6 +467,15 @@ function pidIsAlive(pid) {
|
|
|
465
467
|
function delay(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); }
|
|
466
468
|
|
|
467
469
|
async function replaceTarget(target, transaction) {
|
|
470
|
+
let configProjection = null;
|
|
471
|
+
if (target.type === "config") {
|
|
472
|
+
const snapshot = await readSharedConfigSnapshot(target.path);
|
|
473
|
+
configProjection = cairnConfigProjection((await readSharedConfigSnapshot(target.staged)).text);
|
|
474
|
+
if (snapshot.digest !== target.configSourceDigest) {
|
|
475
|
+
await writeFile(target.staged, `${removeCairnConfig(snapshot.text)}${configProjection}`);
|
|
476
|
+
target.configSourceDigest = snapshot.digest;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
468
479
|
const existed = await exists(target.path);
|
|
469
480
|
const backup = join(transaction.backupRoot, String(transaction.entries.length));
|
|
470
481
|
await mkdir(dirname(backup), { recursive: true });
|
|
@@ -482,7 +493,30 @@ async function replaceTarget(target, transaction) {
|
|
|
482
493
|
};
|
|
483
494
|
transaction.entries.push(entry);
|
|
484
495
|
await writeTransactionJournal(transaction);
|
|
485
|
-
if (
|
|
496
|
+
if (target.type === "config" && process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE) {
|
|
497
|
+
const snapshot = await readSharedConfigSnapshot(target.path);
|
|
498
|
+
await writeFile(target.path, `${snapshot.text}${process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE}`);
|
|
499
|
+
}
|
|
500
|
+
if (target.type === "config" && process.env.CAIRN_TEST_REMOVE_CONFIG_BEFORE_REPLACE === "1") {
|
|
501
|
+
await rm(target.path, { force: true });
|
|
502
|
+
}
|
|
503
|
+
if (target.type === "config") {
|
|
504
|
+
let capturedExists = true;
|
|
505
|
+
try {
|
|
506
|
+
await rename(target.path, backup);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error?.code !== "ENOENT") throw error;
|
|
509
|
+
capturedExists = false;
|
|
510
|
+
}
|
|
511
|
+
if (process.env.CAIRN_TEST_CRASH_AFTER_CONFIG_CAPTURE === "1") process.exit(86);
|
|
512
|
+
const captured = await readSharedConfigSnapshot(backup);
|
|
513
|
+
if (capturedExists) await writeFile(target.staged, `${removeCairnConfig(captured.text)}${configProjection}`);
|
|
514
|
+
else await writeFile(target.staged, configProjection);
|
|
515
|
+
entry.existed = capturedExists;
|
|
516
|
+
entry.previousDigest = capturedExists ? await targetDigest(backup, target.type) : "missing";
|
|
517
|
+
entry.expectedNewDigest = await targetDigest(target.staged, target.type);
|
|
518
|
+
await writeTransactionJournal(transaction);
|
|
519
|
+
} else if (existed) await rename(target.path, backup);
|
|
486
520
|
await mkdir(dirname(target.path), { recursive: true });
|
|
487
521
|
await rename(target.staged, target.path);
|
|
488
522
|
entry.status = "applied";
|
|
@@ -632,6 +666,18 @@ async function rollbackEntries(transaction) {
|
|
|
632
666
|
const destinationExists = await exists(entry.path);
|
|
633
667
|
const backupExists = await exists(entry.backup);
|
|
634
668
|
if (backupExists) {
|
|
669
|
+
if (entry.type === "config" && entry.status === "prepared") {
|
|
670
|
+
const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
|
|
671
|
+
if (destinationExists) {
|
|
672
|
+
const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
|
|
673
|
+
await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
|
|
674
|
+
await rm(entry.backup, { force: true });
|
|
675
|
+
} else {
|
|
676
|
+
await mkdir(dirname(entry.path), { recursive: true });
|
|
677
|
+
await rename(entry.backup, entry.path);
|
|
678
|
+
}
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
635
681
|
if (!entry.existed) throw new Error(`Cannot recover transaction because an unexpected backup exists: ${entry.path}`);
|
|
636
682
|
if (await targetDigest(entry.backup, entry.type) !== entry.previousDigest) {
|
|
637
683
|
throw new Error(`Cannot recover transaction because its durable backup changed: ${entry.path}`);
|
|
@@ -640,8 +686,8 @@ async function rollbackEntries(transaction) {
|
|
|
640
686
|
const current = await targetDigest(entry.path, entry.type);
|
|
641
687
|
if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover transaction because destination changed: ${entry.path}`);
|
|
642
688
|
if (entry.type === "config") {
|
|
643
|
-
const currentConfig = await
|
|
644
|
-
const previousConfig = await
|
|
689
|
+
const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
|
|
690
|
+
const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
|
|
645
691
|
await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
|
|
646
692
|
await rm(entry.backup, { force: true });
|
|
647
693
|
continue;
|
|
@@ -650,12 +696,14 @@ async function rollbackEntries(transaction) {
|
|
|
650
696
|
}
|
|
651
697
|
await mkdir(dirname(entry.path), { recursive: true });
|
|
652
698
|
await rename(entry.backup, entry.path);
|
|
699
|
+
} else if (entry.type === "config" && entry.status === "prepared" && !destinationExists) {
|
|
700
|
+
continue;
|
|
653
701
|
} else if (!entry.existed) {
|
|
654
702
|
if (destinationExists) {
|
|
655
703
|
const current = await targetDigest(entry.path, entry.type);
|
|
656
704
|
if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover new transaction target because it changed: ${entry.path}`);
|
|
657
705
|
if (entry.type === "config") {
|
|
658
|
-
const preserved = removeCairnConfig(await
|
|
706
|
+
const preserved = removeCairnConfig((await readSharedConfigSnapshot(entry.path)).text);
|
|
659
707
|
if (preserved.length > 0) await writeFile(entry.path, preserved);
|
|
660
708
|
else await rm(entry.path, { force: true });
|
|
661
709
|
} else await rm(entry.path, { recursive: true, force: true });
|
|
@@ -688,7 +736,7 @@ async function assertTargetsReplaceable(targets, ownership) {
|
|
|
688
736
|
}
|
|
689
737
|
const record = records.get(target.id);
|
|
690
738
|
if (!record) {
|
|
691
|
-
if (target.type === "config" && cairnConfigProjection(await
|
|
739
|
+
if (target.type === "config" && cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length === 0) continue;
|
|
692
740
|
throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
|
|
693
741
|
}
|
|
694
742
|
if (target.id === "codex-runtime" && ownership?.version !== releaseVersion) {
|
|
@@ -696,13 +744,14 @@ async function assertTargetsReplaceable(targets, ownership) {
|
|
|
696
744
|
}
|
|
697
745
|
if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
|
|
698
746
|
const current = await targetDigest(target.path, target.type);
|
|
699
|
-
if (current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
|
|
747
|
+
if (target.type !== "config" && current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
|
|
700
748
|
}
|
|
701
749
|
}
|
|
702
750
|
|
|
703
751
|
async function assertOwnershipDigests(ownership) {
|
|
704
752
|
for (const record of ownership.targets) {
|
|
705
|
-
|
|
753
|
+
const current = await targetDigest(record.path, record.type);
|
|
754
|
+
if (record.type !== "config" && current !== record.installedDigest) {
|
|
706
755
|
throw new Error(`Managed artifact was modified: ${record.path}`);
|
|
707
756
|
}
|
|
708
757
|
}
|
|
@@ -806,7 +855,7 @@ function versionedRuntimeRoot(version) {
|
|
|
806
855
|
async function anyManagedArtifactExists(targets) {
|
|
807
856
|
for (const target of targets) {
|
|
808
857
|
if (!(await exists(target.path))) continue;
|
|
809
|
-
if (target.type !== "config" || cairnConfigProjection(await
|
|
858
|
+
if (target.type !== "config" || cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length > 0) return true;
|
|
810
859
|
}
|
|
811
860
|
return false;
|
|
812
861
|
}
|
|
@@ -826,7 +875,7 @@ async function adoptLegacy(targets, transaction) {
|
|
|
826
875
|
}
|
|
827
876
|
}
|
|
828
877
|
if (target.type === "config") {
|
|
829
|
-
const projection = cairnConfigProjection(await
|
|
878
|
+
const projection = cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text);
|
|
830
879
|
if (!projection.includes("[marketplaces.cairn]") || !projection.includes('[plugins."cairn@cairn"]')) {
|
|
831
880
|
throw new Error(`Legacy Cairn config cannot be identified: ${target.path}`);
|
|
832
881
|
}
|
|
@@ -923,7 +972,7 @@ async function verifyLegacyMirror(target) {
|
|
|
923
972
|
|
|
924
973
|
export async function targetDigest(path, type) {
|
|
925
974
|
if (!(await exists(path))) return "missing";
|
|
926
|
-
if (type === "config") return sha(cairnConfigProjection(await
|
|
975
|
+
if (type === "config") return sha(cairnConfigProjection((await readSharedConfigSnapshot(path)).text));
|
|
927
976
|
const info = await lstat(path);
|
|
928
977
|
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
|
|
929
978
|
if (info.isFile()) return sha(await readFile(path));
|
|
@@ -969,19 +1018,60 @@ export function updateConfig(config, hookStates, { marketplacePath = marketplace
|
|
|
969
1018
|
}
|
|
970
1019
|
|
|
971
1020
|
export function removeCairnConfig(config) {
|
|
972
|
-
return splitSections(config).
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
if (section.header === 'plugins."cairn@cairn"') return false;
|
|
976
|
-
return !(section.header.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
|
|
977
|
-
}).map((section) => section.text).join("");
|
|
1021
|
+
return splitSections(config).map((section) => isCairnConfigSection(section)
|
|
1022
|
+
? splitTrailingTomlTrivia(section.text).trivia
|
|
1023
|
+
: section.text).join("");
|
|
978
1024
|
}
|
|
979
1025
|
|
|
980
1026
|
function cairnConfigProjection(config) {
|
|
981
|
-
return splitSections(config).filter(
|
|
1027
|
+
return splitSections(config).filter(isCairnConfigSection)
|
|
1028
|
+
.map((section) => splitTrailingTomlTrivia(section.text).body).join("");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function isCairnConfigSection(section) {
|
|
1032
|
+
return section.header === "marketplaces.cairn"
|
|
982
1033
|
|| section.header === 'plugins."cairn@cairn"'
|
|
983
|
-
|| (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"))
|
|
984
|
-
|
|
1034
|
+
|| (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function splitTrailingTomlTrivia(text) {
|
|
1038
|
+
const lines = text.split(/(?<=\n)/);
|
|
1039
|
+
let boundary = lines.length;
|
|
1040
|
+
while (boundary > 0) {
|
|
1041
|
+
const line = lines[boundary - 1];
|
|
1042
|
+
if (line.length === 0 || line.trim().length === 0 || line.trimStart().startsWith("#")) boundary -= 1;
|
|
1043
|
+
else break;
|
|
1044
|
+
}
|
|
1045
|
+
return { body: lines.slice(0, boundary).join(""), trivia: lines.slice(boundary).join("") };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function readSharedConfigSnapshot(path) {
|
|
1049
|
+
let pathInfo;
|
|
1050
|
+
try {
|
|
1051
|
+
pathInfo = await lstat(path);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if (error?.code === "ENOENT") return { exists: false, text: "", digest: "missing" };
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`Managed config is not a regular file: ${path}`);
|
|
1057
|
+
let handle;
|
|
1058
|
+
try {
|
|
1059
|
+
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
1060
|
+
handle = await open(path, flags);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
if (["ELOOP", "EFTYPE", "ENXIO"].includes(error?.code)) throw new Error(`Managed config is not a regular file: ${path}`);
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
const openedInfo = await handle.stat();
|
|
1067
|
+
if (!openedInfo.isFile() || openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
|
|
1068
|
+
throw new Error(`Managed config changed identity while opening: ${path}`);
|
|
1069
|
+
}
|
|
1070
|
+
const text = await handle.readFile("utf8");
|
|
1071
|
+
return { exists: true, text, digest: sha(text) };
|
|
1072
|
+
} finally {
|
|
1073
|
+
await handle.close();
|
|
1074
|
+
}
|
|
985
1075
|
}
|
|
986
1076
|
|
|
987
1077
|
export function ensureSetting(config, sectionName, key, value) {
|