cairn-ai 0.2.6 → 0.2.7
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/.agents/workflows/cairn-memory.md +1 -1
- package/.agents/workflows/cairn-plan.md +2 -2
- package/.agents/workflows/cairn-review.md +1 -1
- package/.agents/workflows/cairn-work.md +3 -3
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.claude/commands/cairn-memory.md +1 -1
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-review.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +3 -2
- package/README.es.md +3 -2
- package/README.fr.md +3 -2
- package/README.ja.md +3 -2
- package/README.ko.md +7 -6
- package/README.md +5 -6
- package/README.pt.md +3 -2
- package/README.zh.md +3 -2
- package/agents/worker.md +1 -1
- package/commands/cairn-install.md +2 -1
- package/commands/cairn-memory.md +1 -1
- package/commands/cairn-plan.md +2 -2
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-work.md +3 -3
- package/docs/model-guidance/README.md +4 -4
- package/docs/model-guidance/claude.md +1 -1
- package/docs/model-guidance/codex.md +2 -2
- package/package.json +4 -2
- package/scripts/cairn-cleanup.mjs +12 -6
- package/scripts/cairn-goal-cli.mjs +233 -0
- package/scripts/cairn-goal-evidence.mjs +179 -0
- package/scripts/cairn-goal-operations-evidence.mjs +124 -0
- package/scripts/cairn-goal-operations-mutations.mjs +63 -0
- package/scripts/cairn-goal-operations.mjs +177 -0
- package/scripts/cairn-goal-policy.mjs +33 -0
- package/scripts/cairn-goal-state-validation.mjs +199 -0
- package/scripts/cairn-goal-state.mjs +89 -0
- package/scripts/cairn-goal.mjs +32 -1078
- package/scripts/cairn-lifecycle-commands.mjs +200 -0
- package/scripts/cairn-lifecycle-config.mjs +149 -0
- package/scripts/cairn-lifecycle-integrity.mjs +218 -0
- package/scripts/cairn-lifecycle-legacy.mjs +142 -0
- package/scripts/cairn-lifecycle-lock.mjs +205 -0
- package/scripts/cairn-lifecycle-mirror.mjs +91 -0
- package/scripts/cairn-lifecycle-staging.mjs +152 -0
- package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
- package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/cairn-lifecycle-transaction.mjs +12 -0
- package/scripts/cairn-lifecycle.mjs +103 -1185
- package/scripts/cairn-paths.mjs +35 -5
- package/scripts/cairn-safe-fs.mjs +61 -35
- package/scripts/cairn-state-context.mjs +148 -0
- package/scripts/cairn-state-hook.mjs +24 -0
- package/scripts/cairn-state-init.mjs +137 -0
- package/scripts/cairn-state.mjs +6 -286
- package/scripts/cairn-toolcheck-detect.mjs +229 -0
- package/scripts/cairn-toolcheck-runtime.mjs +178 -0
- package/scripts/cairn-toolcheck.mjs +79 -424
- package/scripts/cairn.mjs +17 -3
- package/skills/cairn-memory/SKILL.md +1 -1
- package/skills/cairn-plan/SKILL.md +3 -3
- package/skills/cairn-review/SKILL.md +1 -1
- package/skills/cairn-work/SKILL.md +4 -4
- package/scripts/cairn-state.sh +0 -167
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile,
|
|
7
|
-
} from "node:fs/promises";
|
|
8
|
-
import { homedir, hostname, tmpdir } from "node:os";
|
|
9
|
-
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { lstat, readFile, rmdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { resolvePluginRoot } from "./cairn-paths.mjs";
|
|
8
|
+
import {
|
|
9
|
+
canonical, ensureSetting, hasSetting, hookHash, removeCairnConfig, splitSections,
|
|
10
|
+
updateConfig as projectConfig,
|
|
11
|
+
} from "./cairn-lifecycle-config.mjs";
|
|
12
|
+
import { createCommands } from "./cairn-lifecycle-commands.mjs";
|
|
13
|
+
import { createIntegrity } from "./cairn-lifecycle-integrity.mjs";
|
|
14
|
+
import {
|
|
15
|
+
antigravityRuntimeLocatorPath, quoteShellArg, renderInstalledMirror as projectMirror,
|
|
16
|
+
} from "./cairn-lifecycle-mirror.mjs";
|
|
17
|
+
import {
|
|
18
|
+
pathHasSegment, shouldCopyPluginPath as projectShouldCopyPluginPath,
|
|
19
|
+
} from "./cairn-lifecycle-staging.mjs";
|
|
20
|
+
import { createStaging } from "./cairn-lifecycle-staging.mjs";
|
|
11
21
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
createTransactionManager, DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS,
|
|
23
|
+
} from "./cairn-lifecycle-transaction.mjs";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
canonical, DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS, ensureSetting, hasSetting, hookHash,
|
|
27
|
+
pathHasSegment, quoteShellArg, removeCairnConfig, splitSections,
|
|
28
|
+
};
|
|
17
29
|
|
|
18
30
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
19
31
|
const homeRoot = process.env.HOME ?? process.env.USERPROFILE ?? homedir() ?? ".";
|
|
20
32
|
const codexHome = resolve(process.env.CODEX_HOME ?? join(homeRoot, ".codex"));
|
|
21
33
|
const claudeHome = resolve(process.env.CLAUDE_HOME ?? join(homeRoot, ".claude"));
|
|
22
|
-
// ANTIGRAVITY_HOME denotes the IDE's current ~/.gemini/config root.
|
|
23
34
|
const antigravityHome = resolve(process.env.ANTIGRAVITY_HOME ?? join(homeRoot, ".gemini", "config"));
|
|
24
35
|
const legacyAntigravityHome = resolve(process.env.CAIRN_LEGACY_ANTIGRAVITY_HOME ?? join(homeRoot, ".agents"));
|
|
25
36
|
const antigravityCliHome = resolve(process.env.ANTIGRAVITY_CLI_HOME ?? join(homeRoot, ".gemini", "antigravity-cli"));
|
|
@@ -36,376 +47,95 @@ const lifecycleLockPath = join(marketplaceRoot, ".cairn", "lifecycle.lock");
|
|
|
36
47
|
const claudeRuntimeLocatorPath = join(claudeHome, "cairn", "runtime.json");
|
|
37
48
|
const sourceManifest = JSON.parse(await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"));
|
|
38
49
|
const releaseVersion = sourceManifest.version;
|
|
39
|
-
// Codex custom marketplace discovery expects <marketplace>/<plugin>/<version>.
|
|
40
50
|
const versionedPluginRoot = join(marketplaceRoot, pluginName, releaseVersion);
|
|
41
51
|
const installedRuntimeLocatorPath = join(versionedPluginRoot, ".cairn-runtime.json");
|
|
42
52
|
const phases = ["codex", "claude", "antigravity", "config", "cleanup"];
|
|
43
|
-
|
|
53
|
+
const MAX_LIFECYCLE_LOCK_TIMEOUT_MS = 2_147_483_647;
|
|
54
|
+
|
|
55
|
+
const lifecycleContext = {
|
|
56
|
+
agentNames, antigravityCliHome, antigravityHome, claudeHome, claudeRuntimeLocatorPath,
|
|
57
|
+
codexHome, commandNames, configPath, installedPluginRoot, installedRuntimeLocatorPath,
|
|
58
|
+
legacyAntigravityHome, lifecycleLockPath, marketplaceJsonPath, marketplaceName,
|
|
59
|
+
marketplaceRoot, ownershipPath, phases, pluginName, pluginRoot, releaseVersion,
|
|
60
|
+
samePath, skillNames, transactionPath, transactionRoot, updateConfig, versionedPluginRoot,
|
|
61
|
+
};
|
|
62
|
+
const integrity = createIntegrity(lifecycleContext);
|
|
63
|
+
const transaction = createTransactionManager({ ...lifecycleContext, ...integrity });
|
|
64
|
+
const staging = createStaging({ ...lifecycleContext, assertRegularTree: integrity.assertRegularTree });
|
|
65
|
+
const commands = createCommands({
|
|
66
|
+
...lifecycleContext, integrity, pruneUninstallScaffolds, staging, t, transaction,
|
|
67
|
+
});
|
|
44
68
|
|
|
45
69
|
if (isCliEntry()) {
|
|
46
70
|
try {
|
|
47
|
-
const command = process.argv
|
|
48
|
-
if (command === "install" || command === "upgrade")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
else
|
|
71
|
+
const command = parseLifecycleInvocation(process.argv.slice(2));
|
|
72
|
+
if (command === "install" || command === "upgrade") {
|
|
73
|
+
await assertLifecycleMutationPaths();
|
|
74
|
+
await transaction.withLifecycleLock(() => commands.install(command), { timeoutMs: lifecycleLockTimeout() });
|
|
75
|
+
} else if (command === "doctor") {
|
|
76
|
+
await assertNoSymlinkAncestor(codexHome, lifecycleLockPath);
|
|
77
|
+
await transaction.withLifecycleLock(commands.doctor, { timeoutMs: lifecycleLockTimeout(), afterRelease: pruneUninstallRoots });
|
|
78
|
+
} else if (command === "uninstall") {
|
|
79
|
+
await assertLifecycleMutationPaths();
|
|
80
|
+
await transaction.withLifecycleLock(commands.uninstall, { timeoutMs: lifecycleLockTimeout(), afterRelease: pruneUninstallRoots });
|
|
81
|
+
} else help();
|
|
52
82
|
} catch (error) {
|
|
53
83
|
console.error(`Cairn lifecycle error: ${error.message}`);
|
|
54
84
|
process.exitCode = 1;
|
|
55
85
|
}
|
|
56
86
|
}
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const targets = await stageInstall(transaction.stageRoot);
|
|
66
|
-
targets.push(...await legacyMigrationTargets());
|
|
67
|
-
let ownership = await readOwnership();
|
|
68
|
-
if (ownership) {
|
|
69
|
-
validateOwnershipShape(ownership);
|
|
70
|
-
await assertOwnershipDigests(ownership);
|
|
71
|
-
if (ownership.version !== releaseVersion) {
|
|
72
|
-
targets.push({
|
|
73
|
-
id: "previous-codex-runtime",
|
|
74
|
-
phase: "cleanup",
|
|
75
|
-
path: versionedRuntimeRoot(ownership.version),
|
|
76
|
-
type: "tree",
|
|
77
|
-
operation: "remove",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
else if (await anyManagedArtifactExists(targets)) ownership = await adoptLegacy(targets, transaction);
|
|
82
|
-
await assertTargetsReplaceable(targets, ownership);
|
|
83
|
-
for (const phase of phases) {
|
|
84
|
-
for (const target of targets.filter((candidate) => candidate.phase === phase)) {
|
|
85
|
-
if (target.operation === "remove") await removeManagedTarget(target, transaction);
|
|
86
|
-
else await replaceTarget(target, transaction);
|
|
87
|
-
}
|
|
88
|
-
injectFailure(`after-${phase}`);
|
|
89
|
-
}
|
|
90
|
-
const records = [];
|
|
91
|
-
for (const target of targets.filter((candidate) => candidate.operation !== "remove")) records.push(await targetRecord(target));
|
|
92
|
-
const nextOwnership = {
|
|
93
|
-
schemaVersion: 1,
|
|
94
|
-
plugin: pluginName,
|
|
95
|
-
version: releaseVersion,
|
|
96
|
-
transactionId: transaction.id,
|
|
97
|
-
installedAt: new Date().toISOString(),
|
|
98
|
-
targets: records,
|
|
99
|
-
};
|
|
100
|
-
const ownershipStage = join(transaction.stageRoot, "lifecycle.json");
|
|
101
|
-
await mkdir(dirname(ownershipStage), { recursive: true });
|
|
102
|
-
await writeFile(ownershipStage, `${JSON.stringify(nextOwnership, null, 2)}\n`);
|
|
103
|
-
await replaceTarget({ id: "ownership", phase: "manifest", path: ownershipPath, staged: ownershipStage, type: "file" }, transaction);
|
|
104
|
-
await finishTransaction(transaction);
|
|
105
|
-
console.log(t(mode === "upgrade" ? "upgradeComplete" : "installComplete"));
|
|
106
|
-
console.log(`Codex plugin source: ${installedPluginRoot}`);
|
|
107
|
-
console.log(`Codex versioned runtime: ${versionedPluginRoot}`);
|
|
108
|
-
console.log(`Ownership manifest: ${ownershipPath}`);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (transaction.state !== "committed") await rollback(transaction);
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function uninstall() {
|
|
116
|
-
await recoverInterruptedTransaction();
|
|
117
|
-
const ownership = await readOwnership({ required: true });
|
|
118
|
-
validateOwnershipShape(ownership, { required: true });
|
|
119
|
-
const conflicts = [];
|
|
120
|
-
for (const record of ownership.targets) {
|
|
121
|
-
const current = await targetDigest(record.path, record.type);
|
|
122
|
-
if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
|
|
123
|
-
}
|
|
124
|
-
if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
|
|
125
|
-
const transaction = await createTransaction();
|
|
126
|
-
try {
|
|
127
|
-
const configRecord = ownership.targets.find((record) => record.type === "config");
|
|
128
|
-
if (configRecord && await exists(configRecord.path)) {
|
|
129
|
-
const snapshot = await readSharedConfigSnapshot(configRecord.path);
|
|
130
|
-
const staged = join(transaction.stageRoot, "uninstall-config.toml");
|
|
131
|
-
await mkdir(dirname(staged), { recursive: true });
|
|
132
|
-
await writeFile(staged, removeCairnConfig(snapshot.text));
|
|
133
|
-
await replaceTarget({ ...configRecord, staged, configSourceDigest: snapshot.digest }, transaction);
|
|
134
|
-
}
|
|
135
|
-
injectFailure("uninstall-after-config");
|
|
136
|
-
for (const phase of ["antigravity", "claude"]) {
|
|
137
|
-
for (const record of ownership.targets.filter((candidate) => candidate.phase === phase).reverse()) await removeManagedTarget(record, transaction);
|
|
138
|
-
}
|
|
139
|
-
injectFailure("uninstall-after-external");
|
|
140
|
-
for (const record of ownership.targets.filter((candidate) => candidate.phase === "codex").reverse()) await removeManagedTarget(record, transaction);
|
|
141
|
-
injectFailure("uninstall-after-codex");
|
|
142
|
-
await removeManagedTarget({ id: "ownership", phase: "manifest", path: ownershipPath, type: "file" }, transaction);
|
|
143
|
-
injectFailure("uninstall-after-ownership");
|
|
144
|
-
await finishTransaction(transaction);
|
|
145
|
-
injectFailure("uninstall-after-commit");
|
|
146
|
-
} catch (error) {
|
|
147
|
-
if (transaction.state !== "committed") await rollback(transaction);
|
|
148
|
-
throw error;
|
|
149
|
-
}
|
|
150
|
-
await pruneUninstallScaffolds();
|
|
151
|
-
console.log(t("uninstallComplete"));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async function doctor() {
|
|
155
|
-
const checks = [];
|
|
156
|
-
const ownership = await readOwnership();
|
|
157
|
-
checks.push(["source", await exists(join(pluginRoot, ".codex-plugin", "plugin.json"))]);
|
|
158
|
-
checks.push(["ownership manifest", Boolean(ownership)]);
|
|
159
|
-
checks.push(["ownership digests", await ownershipDigestsValid(ownership)]);
|
|
160
|
-
checks.push(["custom marketplace source", await exists(join(installedPluginRoot, ".codex-plugin", "plugin.json"))]);
|
|
161
|
-
checks.push(["versioned runtime", await exists(join(versionedPluginRoot, ".codex-plugin", "plugin.json"))]);
|
|
162
|
-
checks.push(["marketplace", await exists(marketplaceJsonPath)]);
|
|
163
|
-
checks.push(["installed hooks manifest field", (await readJson(join(versionedPluginRoot, ".codex-plugin", "plugin.json")))?.hooks === "./hooks/hooks.json"]);
|
|
164
|
-
checks.push(["hooks file", await exists(join(versionedPluginRoot, "hooks", "hooks.json"))]);
|
|
165
|
-
checks.push(["installed runtime locator", await validRuntimeLocator(installedRuntimeLocatorPath)]);
|
|
166
|
-
checks.push(["installed skill locators", (await Promise.all(skillNames().map((name) => validRuntimeLocator(installedSkillLocatorPath(name))))).every(Boolean)]);
|
|
167
|
-
checks.push(["installed CLI script", await exists(join(versionedPluginRoot, "scripts", "cairn.mjs"))]);
|
|
168
|
-
checks.push(["installed plan template", await exists(join(versionedPluginRoot, "templates", "work-plan.md"))]);
|
|
169
|
-
checks.push(["installed model guidance", await exists(join(versionedPluginRoot, "docs", "model-guidance", "codex.md"))]);
|
|
170
|
-
const config = (await readSharedConfigSnapshot(configPath)).text;
|
|
171
|
-
checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
|
|
172
|
-
checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
|
|
173
|
-
checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath)]);
|
|
174
|
-
checks.push(["Antigravity IDE skills", await exists(join(antigravityHome, "skills", "cairn-plan", "SKILL.md"))]);
|
|
175
|
-
checks.push(["Antigravity CLI flat skills", await exists(join(antigravityCliHome, "skills", "cairn-plan.md"))]);
|
|
176
|
-
|
|
177
|
-
const codexStatus = hostJson(["plugin", "list", "--json"], { CODEX_HOME: codexHome });
|
|
178
|
-
const codexEntry = findPluginStatus(codexStatus, "cairn@cairn");
|
|
179
|
-
checks.push(["Codex plugin installed", Boolean(codexEntry?.installed)]);
|
|
180
|
-
checks.push(["Codex plugin enabled", Boolean(codexEntry?.enabled)]);
|
|
181
|
-
checks.push(["Codex plugin version", codexEntry?.version === releaseVersion]);
|
|
182
|
-
|
|
183
|
-
const featureResult = spawnSync("codex", ["features", "list"], { encoding: "utf8", env: { ...process.env, CODEX_HOME: codexHome } });
|
|
184
|
-
const featureStates = featureResult.status === 0 && !featureResult.error ? parseCodexFeatureList(featureResult.stdout) : null;
|
|
185
|
-
checks.push(["Codex feature plugins", featureStates?.plugins === true]);
|
|
186
|
-
checks.push(["Codex feature hooks", featureStates?.hooks === true]);
|
|
187
|
-
checks.push(["Codex feature multi_agent", featureStates?.multi_agent === true]);
|
|
188
|
-
|
|
189
|
-
const agy = spawnSync("agy", ["--version"], { encoding: "utf8", env: process.env });
|
|
190
|
-
checks.push(["Antigravity CLI readiness", !agy.error && agy.status === 0 && /^\d+\.\d+\.\d+/.test(agy.stdout.trim())]);
|
|
191
|
-
checks.push(["Antigravity CLI skill validation", await validateAntigravitySkills()]);
|
|
192
|
-
for (const [name, ok] of checks) console.log(`${ok ? "OK" : "FAIL"} ${name}`);
|
|
193
|
-
if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
|
|
88
|
+
function parseLifecycleInvocation(args) {
|
|
89
|
+
if (args.length === 0) return "help";
|
|
90
|
+
const [command, ...trailing] = args;
|
|
91
|
+
if (!["install", "upgrade", "doctor", "uninstall", "help"].includes(command)) throw new Error(`Unknown lifecycle command: ${command}`);
|
|
92
|
+
if (trailing.length > 0) throw new Error(`Lifecycle command ${command} does not accept arguments: ${trailing.join(" ")}`);
|
|
93
|
+
return command;
|
|
194
94
|
}
|
|
195
95
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
96
|
+
function lifecycleLockTimeout() {
|
|
97
|
+
const raw = process.env.CAIRN_LIFECYCLE_LOCK_TIMEOUT_MS;
|
|
98
|
+
if (raw === undefined) return DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS;
|
|
99
|
+
if (!/^[1-9]\d*$/.test(raw)) throw new Error(`CAIRN_LIFECYCLE_LOCK_TIMEOUT_MS must be an integer between 1 and ${MAX_LIFECYCLE_LOCK_TIMEOUT_MS}`);
|
|
100
|
+
const timeoutMs = Number(raw);
|
|
101
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs > MAX_LIFECYCLE_LOCK_TIMEOUT_MS) throw new Error(`CAIRN_LIFECYCLE_LOCK_TIMEOUT_MS must be an integer between 1 and ${MAX_LIFECYCLE_LOCK_TIMEOUT_MS}`);
|
|
102
|
+
return timeoutMs;
|
|
204
103
|
}
|
|
205
104
|
|
|
206
|
-
async function
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
interface: { displayName: "Cairn" },
|
|
220
|
-
plugins: [{ name: pluginName, source: { source: "local", path: "./plugins/cairn" }, policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }],
|
|
221
|
-
}, null, 2)}\n`);
|
|
222
|
-
add("marketplace", "codex", marketplaceJsonPath, marketplaceStage, "file");
|
|
223
|
-
|
|
224
|
-
const claudeLocator = join(stageRoot, "claude-runtime.json");
|
|
225
|
-
await writeLocator(claudeLocator, versionedPluginRoot);
|
|
226
|
-
add("claude-runtime", "claude", claudeRuntimeLocatorPath, claudeLocator, "file");
|
|
227
|
-
for (const name of commandNames()) {
|
|
228
|
-
const staged = join(stageRoot, "claude-commands", `cairn-${name}.md`);
|
|
229
|
-
await renderMirrorFile(join(runtimeStage, ".claude", "commands", `cairn-${name}.md`), staged, claudeRuntimeLocatorPath);
|
|
230
|
-
add(`claude-command-${name}`, "claude", join(claudeHome, "commands", `cairn-${name}.md`), staged, "file");
|
|
231
|
-
}
|
|
232
|
-
for (const name of ["explorer", "worker"]) {
|
|
233
|
-
const staged = join(stageRoot, "claude-agents", `cairn-${name}.md`);
|
|
234
|
-
await renderMirrorFile(join(runtimeStage, ".claude", "agents", `${name}.md`), staged, claudeRuntimeLocatorPath);
|
|
235
|
-
add(`claude-agent-${name}`, "claude", join(claudeHome, "agents", `cairn-${name}.md`), staged, "file");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
await stageAntigravity(targets, stageRoot, runtimeStage, antigravityHome, "ide", false);
|
|
239
|
-
await stageAntigravity(targets, stageRoot, runtimeStage, antigravityCliHome, "cli", true);
|
|
240
|
-
|
|
241
|
-
const configStage = join(stageRoot, "config.toml");
|
|
242
|
-
const hookStates = await trustedHookStates(runtimeStage);
|
|
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 });
|
|
246
|
-
for (const target of targets) await assertRegularTree(target.staged);
|
|
247
|
-
return targets;
|
|
105
|
+
async function assertLifecycleMutationPaths() {
|
|
106
|
+
const roots = [
|
|
107
|
+
[codexHome, lifecycleLockPath], [codexHome, installedPluginRoot], [codexHome, versionedPluginRoot],
|
|
108
|
+
[codexHome, marketplaceJsonPath], [isInside(codexHome, configPath) ? codexHome : dirname(configPath), configPath],
|
|
109
|
+
[claudeHome, join(claudeHome, "commands")], [claudeHome, join(claudeHome, "agents")],
|
|
110
|
+
[claudeHome, claudeRuntimeLocatorPath], [antigravityHome, join(antigravityHome, "skills")],
|
|
111
|
+
...skillNames().map((name) => [antigravityHome, join(antigravityHome, "skills", name)]),
|
|
112
|
+
[antigravityHome, join(antigravityHome, "workflows")], [antigravityHome, antigravityRuntimeLocatorPath(antigravityHome)],
|
|
113
|
+
[antigravityCliHome, join(antigravityCliHome, "skills")], [antigravityCliHome, join(antigravityCliHome, "workflows")],
|
|
114
|
+
[antigravityCliHome, antigravityRuntimeLocatorPath(antigravityCliHome)],
|
|
115
|
+
[legacyAntigravityHome, join(legacyAntigravityHome, "skills")], [legacyAntigravityHome, join(legacyAntigravityHome, "workflows")],
|
|
116
|
+
];
|
|
117
|
+
for (const [root, destination] of roots) await assertNoSymlinkAncestor(root, destination);
|
|
248
118
|
}
|
|
249
119
|
|
|
250
|
-
async function
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
for (const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), join(staged, "SKILL.md"), antigravitySkillLocatorPath(destinationRoot, name), { includeLocatorNotice: true });
|
|
264
|
-
await writeLocator(join(staged, "references", "cairn-runtime.json"), versionedPluginRoot);
|
|
265
|
-
targets.push({ id: `${label}-skill-${name}`, phase: "antigravity", path: join(destinationRoot, "skills", name), staged, type: "tree" });
|
|
120
|
+
async function assertNoSymlinkAncestor(root, destination) {
|
|
121
|
+
const relation = relative(root, destination);
|
|
122
|
+
if (relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation)) return;
|
|
123
|
+
const paths = [resolve(root)];
|
|
124
|
+
let current = resolve(root);
|
|
125
|
+
for (const segment of relation.split(sep).filter(Boolean)) { current = join(current, segment); paths.push(current); }
|
|
126
|
+
for (const path of paths) {
|
|
127
|
+
let info;
|
|
128
|
+
try { info = await lstat(path); }
|
|
129
|
+
catch (error) { if (error?.code === "ENOENT") continue; throw error; }
|
|
130
|
+
if (info.isSymbolicLink()) {
|
|
131
|
+
if (path === configPath) throw new Error(`Managed config is not a regular file: ${path}`);
|
|
132
|
+
throw new Error(`Lifecycle path ancestor must not be a symlink: ${path}`);
|
|
266
133
|
}
|
|
267
134
|
}
|
|
268
|
-
for (const name of commandNames()) {
|
|
269
|
-
const staged = join(stageRoot, `${label}-workflows`, `cairn-${name}.md`);
|
|
270
|
-
await renderMirrorFile(join(runtimeStage, ".agents", "workflows", `cairn-${name}.md`), staged, locatorPath);
|
|
271
|
-
targets.push({ id: `${label}-workflow-${name}`, phase: "antigravity", path: join(destinationRoot, "workflows", `cairn-${name}.md`), staged, type: "file" });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function legacyMigrationTargets() {
|
|
276
|
-
const candidates = [{ id: "legacy-ide-runtime", phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file", operation: "remove" }];
|
|
277
|
-
for (const name of skillNames()) {
|
|
278
|
-
candidates.push({ id: `legacy-ide-skill-${name}`, phase: "antigravity", path: join(legacyAntigravityHome, "skills", name), type: "tree", operation: "remove" });
|
|
279
|
-
candidates.push({ id: `legacy-cli-skill-${name}`, phase: "antigravity", path: join(antigravityCliHome, "skills", name), type: "tree", operation: "remove" });
|
|
280
|
-
}
|
|
281
|
-
for (const name of commandNames()) candidates.push({ id: `legacy-ide-workflow-${name}`, phase: "antigravity", path: join(legacyAntigravityHome, "workflows", `cairn-${name}.md`), type: "file", operation: "remove" });
|
|
282
|
-
const present = [];
|
|
283
|
-
for (const candidate of candidates) if (await exists(candidate.path)) present.push(candidate);
|
|
284
|
-
return present;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async function copyPluginCandidate(destination, runtimeRoot) {
|
|
288
|
-
await copyPluginTree(pluginRoot, destination);
|
|
289
|
-
const manifestPath = join(destination, ".codex-plugin", "plugin.json");
|
|
290
|
-
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
291
|
-
manifest.hooks = "./hooks/hooks.json";
|
|
292
|
-
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
293
|
-
await writeLocator(join(destination, ".cairn-runtime.json"), runtimeRoot);
|
|
294
|
-
for (const name of skillNames()) await writeLocator(join(destination, "skills", name, "references", "cairn-runtime.json"), runtimeRoot);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
async function copyPluginTree(source, destination) {
|
|
298
|
-
if (!shouldCopyPluginPath(source)) return;
|
|
299
|
-
const info = await lstat(source);
|
|
300
|
-
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
|
|
301
|
-
throw new Error(`Plugin source must contain only regular files and directories: ${source}`);
|
|
302
|
-
}
|
|
303
|
-
if (info.isFile()) {
|
|
304
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
305
|
-
await copyFile(source, destination);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
await mkdir(destination, { recursive: true });
|
|
309
|
-
const entries = await readdir(source);
|
|
310
|
-
entries.sort();
|
|
311
|
-
for (const entry of entries) await copyPluginTree(join(source, entry), join(destination, entry));
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
async function createTransaction() {
|
|
315
|
-
const root = await mkdtemp(join(tmpdir(), "cairn-lifecycle-"));
|
|
316
|
-
const transaction = {
|
|
317
|
-
schemaVersion: 1,
|
|
318
|
-
id: randomUUID(),
|
|
319
|
-
state: "active",
|
|
320
|
-
root,
|
|
321
|
-
stageRoot: join(root, "stage"),
|
|
322
|
-
backupRoot: "",
|
|
323
|
-
entries: [],
|
|
324
|
-
};
|
|
325
|
-
transaction.backupRoot = join(transactionRoot, transaction.id, "backup");
|
|
326
|
-
await mkdir(transaction.backupRoot, { recursive: true });
|
|
327
|
-
await writeTransactionJournal(transaction);
|
|
328
|
-
return transaction;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
async function withLifecycleLock(operation, {
|
|
332
|
-
timeoutMs = Number(process.env.CAIRN_LIFECYCLE_LOCK_TIMEOUT_MS ?? DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS),
|
|
333
|
-
retryMs = 25,
|
|
334
|
-
malformedStaleMs = 30_000,
|
|
335
|
-
afterRelease,
|
|
336
|
-
} = {}) {
|
|
337
|
-
await mkdir(dirname(lifecycleLockPath), { recursive: true });
|
|
338
|
-
if (process.env.CAIRN_TEST_PRUNE_LOCK_PARENT_BEFORE_ACQUIRE === "1") await rmdir(dirname(lifecycleLockPath));
|
|
339
|
-
const owner = { schemaVersion: 1, pid: process.pid, hostname: hostname(), nonce: randomUUID(), acquiredAt: new Date().toISOString() };
|
|
340
|
-
const deadline = Date.now() + timeoutMs;
|
|
341
|
-
while (true) {
|
|
342
|
-
try {
|
|
343
|
-
await writeFile(lifecycleLockPath, `${JSON.stringify(owner)}\n`, { flag: "wx" });
|
|
344
|
-
if (process.env.CAIRN_TEST_REPLACE_LOCK_AFTER_ACQUIRE === "1") {
|
|
345
|
-
await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
|
|
346
|
-
}
|
|
347
|
-
const acquired = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
|
|
348
|
-
if (acquired?.nonce !== owner.nonce) throw new Error("Cairn lifecycle lock ownership changed after acquisition");
|
|
349
|
-
break;
|
|
350
|
-
} catch (error) {
|
|
351
|
-
if (error?.code === "ENOENT") {
|
|
352
|
-
if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
|
|
353
|
-
await mkdir(dirname(lifecycleLockPath), { recursive: true });
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (error?.code !== "EEXIST") throw error;
|
|
357
|
-
if (await reclaimLifecycleLock(malformedStaleMs)) continue;
|
|
358
|
-
if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
|
|
359
|
-
await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
let operationSucceeded = false;
|
|
363
|
-
try {
|
|
364
|
-
const result = await operation();
|
|
365
|
-
operationSucceeded = true;
|
|
366
|
-
return result;
|
|
367
|
-
} finally {
|
|
368
|
-
if (process.env.CAIRN_TEST_REPLACE_LOCK_BEFORE_RELEASE === "1") {
|
|
369
|
-
await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
|
|
370
|
-
}
|
|
371
|
-
const released = await releaseLifecycleLock(owner);
|
|
372
|
-
if (operationSucceeded && released && afterRelease) await afterRelease();
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
async function releaseLifecycleLock(owner) {
|
|
377
|
-
let current;
|
|
378
|
-
try {
|
|
379
|
-
current = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
|
|
380
|
-
} catch (error) {
|
|
381
|
-
if (error?.code === "ENOENT") return false;
|
|
382
|
-
throw error;
|
|
383
|
-
}
|
|
384
|
-
if (current?.nonce !== owner.nonce) return false;
|
|
385
|
-
const releasedPath = `${lifecycleLockPath}.released.${owner.nonce}`;
|
|
386
|
-
try {
|
|
387
|
-
await rename(lifecycleLockPath, releasedPath);
|
|
388
|
-
} catch (error) {
|
|
389
|
-
if (error?.code === "ENOENT") return false;
|
|
390
|
-
throw error;
|
|
391
|
-
}
|
|
392
|
-
const released = parseLifecycleLock(await readFile(releasedPath, "utf8"));
|
|
393
|
-
if (released?.nonce !== owner.nonce) {
|
|
394
|
-
if (!(await exists(lifecycleLockPath))) await rename(releasedPath, lifecycleLockPath);
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
await rm(releasedPath, { force: true });
|
|
398
|
-
return true;
|
|
399
135
|
}
|
|
400
136
|
|
|
401
137
|
async function pruneUninstallScaffolds() {
|
|
402
|
-
for (const path of [
|
|
403
|
-
join(marketplaceRoot, "plugins"),
|
|
404
|
-
join(marketplaceRoot, pluginName),
|
|
405
|
-
join(marketplaceRoot, ".agents", "plugins"),
|
|
406
|
-
join(marketplaceRoot, ".agents"),
|
|
407
|
-
transactionRoot,
|
|
408
|
-
]) await removeEmptyDirectory(path);
|
|
138
|
+
for (const path of [join(marketplaceRoot, "plugins"), join(marketplaceRoot, pluginName), join(marketplaceRoot, ".agents", "plugins"), join(marketplaceRoot, ".agents"), transactionRoot]) await removeEmptyDirectory(path);
|
|
409
139
|
}
|
|
410
140
|
|
|
411
141
|
async function pruneUninstallRoots() {
|
|
@@ -414,306 +144,8 @@ async function pruneUninstallRoots() {
|
|
|
414
144
|
}
|
|
415
145
|
|
|
416
146
|
async function removeEmptyDirectory(path) {
|
|
417
|
-
try {
|
|
418
|
-
|
|
419
|
-
} catch (error) {
|
|
420
|
-
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async function reclaimLifecycleLock(malformedStaleMs) {
|
|
425
|
-
let observed;
|
|
426
|
-
let info;
|
|
427
|
-
try {
|
|
428
|
-
[observed, info] = await Promise.all([readFile(lifecycleLockPath, "utf8"), lstat(lifecycleLockPath)]);
|
|
429
|
-
} catch (error) {
|
|
430
|
-
if (error?.code === "ENOENT") return true;
|
|
431
|
-
throw error;
|
|
432
|
-
}
|
|
433
|
-
if (info.isSymbolicLink()) throw new Error(`Lifecycle lock must not be a symlink: ${lifecycleLockPath}`);
|
|
434
|
-
const record = parseLifecycleLock(observed);
|
|
435
|
-
if (record) {
|
|
436
|
-
if (record.hostname !== hostname() || pidIsAlive(record.pid)) return false;
|
|
437
|
-
} else if (Date.now() - info.mtimeMs <= malformedStaleMs) return false;
|
|
438
|
-
const quarantine = `${lifecycleLockPath}.stale.${record?.nonce ?? "malformed"}.${randomUUID()}`;
|
|
439
|
-
try {
|
|
440
|
-
await rename(lifecycleLockPath, quarantine);
|
|
441
|
-
} catch (error) {
|
|
442
|
-
if (error?.code === "ENOENT") return true;
|
|
443
|
-
throw error;
|
|
444
|
-
}
|
|
445
|
-
if (await readFile(quarantine, "utf8") !== observed) {
|
|
446
|
-
if (!(await exists(lifecycleLockPath))) await rename(quarantine, lifecycleLockPath);
|
|
447
|
-
throw new Error("Cairn lifecycle lock ownership changed during stale recovery");
|
|
448
|
-
}
|
|
449
|
-
await rm(quarantine, { force: true });
|
|
450
|
-
return true;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function parseLifecycleLock(text) {
|
|
454
|
-
try {
|
|
455
|
-
const value = JSON.parse(text);
|
|
456
|
-
if (value?.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid <= 0
|
|
457
|
-
|| typeof value.hostname !== "string" || value.hostname.length === 0 || typeof value.nonce !== "string" || value.nonce.length === 0
|
|
458
|
-
|| typeof value.acquiredAt !== "string" || Number.isNaN(Date.parse(value.acquiredAt))) return null;
|
|
459
|
-
return value;
|
|
460
|
-
} catch { return null; }
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function pidIsAlive(pid) {
|
|
464
|
-
try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; }
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function delay(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); }
|
|
468
|
-
|
|
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
|
-
}
|
|
479
|
-
const existed = await exists(target.path);
|
|
480
|
-
const backup = join(transaction.backupRoot, String(transaction.entries.length));
|
|
481
|
-
await mkdir(dirname(backup), { recursive: true });
|
|
482
|
-
const entry = {
|
|
483
|
-
id: target.id,
|
|
484
|
-
phase: target.phase,
|
|
485
|
-
path: target.path,
|
|
486
|
-
type: target.type,
|
|
487
|
-
backup,
|
|
488
|
-
existed,
|
|
489
|
-
previousDigest: existed ? await targetDigest(target.path, target.type) : "missing",
|
|
490
|
-
expectedNewDigest: await targetDigest(target.staged, target.type),
|
|
491
|
-
operation: "replace",
|
|
492
|
-
status: "prepared",
|
|
493
|
-
};
|
|
494
|
-
transaction.entries.push(entry);
|
|
495
|
-
await writeTransactionJournal(transaction);
|
|
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);
|
|
520
|
-
await mkdir(dirname(target.path), { recursive: true });
|
|
521
|
-
await rename(target.staged, target.path);
|
|
522
|
-
entry.status = "applied";
|
|
523
|
-
await writeTransactionJournal(transaction);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async function removeManagedTarget(target, transaction) {
|
|
527
|
-
if (!(await exists(target.path))) throw new Error(`Managed uninstall target is missing: ${target.path}`);
|
|
528
|
-
const backup = join(transaction.backupRoot, String(transaction.entries.length));
|
|
529
|
-
await mkdir(dirname(backup), { recursive: true });
|
|
530
|
-
const entry = {
|
|
531
|
-
id: target.id,
|
|
532
|
-
phase: target.phase,
|
|
533
|
-
path: target.path,
|
|
534
|
-
type: target.type,
|
|
535
|
-
backup,
|
|
536
|
-
existed: true,
|
|
537
|
-
previousDigest: await targetDigest(target.path, target.type),
|
|
538
|
-
expectedNewDigest: "missing",
|
|
539
|
-
operation: "remove",
|
|
540
|
-
status: "prepared",
|
|
541
|
-
};
|
|
542
|
-
transaction.entries.push(entry);
|
|
543
|
-
await writeTransactionJournal(transaction);
|
|
544
|
-
await rename(target.path, backup);
|
|
545
|
-
entry.status = "applied";
|
|
546
|
-
await writeTransactionJournal(transaction);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
async function rollback(transaction) {
|
|
550
|
-
await rollbackEntries(transaction);
|
|
551
|
-
await rm(transaction.root, { recursive: true, force: true });
|
|
552
|
-
await rm(join(transactionRoot, transaction.id), { recursive: true, force: true });
|
|
553
|
-
await rm(transactionPath, { force: true });
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
async function finishTransaction(transaction) {
|
|
557
|
-
transaction.state = "committed";
|
|
558
|
-
try {
|
|
559
|
-
await writeTransactionJournal(transaction);
|
|
560
|
-
} catch (error) {
|
|
561
|
-
transaction.state = "active";
|
|
562
|
-
throw error;
|
|
563
|
-
}
|
|
564
|
-
await rm(transaction.root, { recursive: true, force: true });
|
|
565
|
-
await rm(join(transactionRoot, transaction.id), { recursive: true, force: true });
|
|
566
|
-
await rm(transactionPath, { force: true });
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
async function writeTransactionJournal(transaction) {
|
|
570
|
-
const durable = {
|
|
571
|
-
schemaVersion: transaction.schemaVersion,
|
|
572
|
-
id: transaction.id,
|
|
573
|
-
state: transaction.state,
|
|
574
|
-
entries: transaction.entries,
|
|
575
|
-
};
|
|
576
|
-
await durableReplaceFile(transactionPath, `${JSON.stringify(durable, null, 2)}\n`, transaction.id);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
async function durableReplaceFile(path, content, nonce = randomUUID()) {
|
|
580
|
-
await mkdir(dirname(path), { recursive: true });
|
|
581
|
-
const candidate = `${path}.${nonce}.tmp`;
|
|
582
|
-
const file = await open(candidate, "w", 0o600);
|
|
583
|
-
try {
|
|
584
|
-
await file.writeFile(content, "utf8");
|
|
585
|
-
await file.sync();
|
|
586
|
-
} finally {
|
|
587
|
-
await file.close();
|
|
588
|
-
}
|
|
589
|
-
await rename(candidate, path);
|
|
590
|
-
// Directory fsync is unavailable on some Windows/filesystem combinations. The
|
|
591
|
-
// file fsync+atomic rename remains the portable floor; directory sync is best effort.
|
|
592
|
-
try {
|
|
593
|
-
const directory = await open(dirname(path), "r");
|
|
594
|
-
try { await directory.sync(); } finally { await directory.close(); }
|
|
595
|
-
} catch (error) {
|
|
596
|
-
if (!(["EINVAL", "ENOTSUP", "EISDIR", "EPERM", "EACCES"].includes(error?.code))) throw error;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async function recoverInterruptedTransaction() {
|
|
601
|
-
if (!(await exists(transactionPath))) return;
|
|
602
|
-
let journal;
|
|
603
|
-
try {
|
|
604
|
-
journal = JSON.parse(await readFile(transactionPath, "utf8"));
|
|
605
|
-
} catch {
|
|
606
|
-
throw new Error(`Lifecycle transaction journal is invalid: ${transactionPath}`);
|
|
607
|
-
}
|
|
608
|
-
validateTransactionJournal(journal, { strict: true });
|
|
609
|
-
if (journal.state === "committed") {
|
|
610
|
-
await rm(join(transactionRoot, journal.id), { recursive: true, force: true });
|
|
611
|
-
await rm(transactionPath, { force: true });
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
await rollbackEntries(journal);
|
|
615
|
-
await rm(join(transactionRoot, journal.id), { recursive: true, force: true });
|
|
616
|
-
await rm(transactionPath, { force: true });
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function validateTransactionJournal(journal, { strict = false } = {}) {
|
|
620
|
-
if (journal?.schemaVersion !== 1 || typeof journal.id !== "string" || !/^[a-f0-9-]{20,}$/i.test(journal.id)
|
|
621
|
-
|| !["active", "committed"].includes(journal.state) || !Array.isArray(journal.entries)) {
|
|
622
|
-
throw new Error("Lifecycle transaction journal has an invalid schema.");
|
|
623
|
-
}
|
|
624
|
-
if (strict && !sameKeys(journal, ["schemaVersion", "id", "state", "entries"])) throw new Error("Lifecycle transaction journal has unknown fields.");
|
|
625
|
-
const allowedBackupRoot = resolve(transactionRoot, journal.id, "backup");
|
|
626
|
-
const ids = new Set();
|
|
627
|
-
const paths = new Set();
|
|
628
|
-
for (const entry of journal.entries) {
|
|
629
|
-
const expected = entry.id === "ownership"
|
|
630
|
-
? { phase: "manifest", path: ownershipPath, type: "file" }
|
|
631
|
-
: entry.id === "previous-codex-runtime"
|
|
632
|
-
? previousRuntimeTarget(entry.path)
|
|
633
|
-
: expectedManagedTarget(entry.id);
|
|
634
|
-
if (!expected || expected.path !== entry.path || expected.phase !== entry.phase || expected.type !== entry.type
|
|
635
|
-
|| !["prepared", "applied"].includes(entry.status)
|
|
636
|
-
|| !["replace", "remove"].includes(entry.operation)
|
|
637
|
-
|| typeof entry.existed !== "boolean"
|
|
638
|
-
|| !isInside(allowedBackupRoot, entry.backup)
|
|
639
|
-
|| typeof entry.expectedNewDigest !== "string" || typeof entry.previousDigest !== "string") {
|
|
640
|
-
throw new Error(`Lifecycle transaction journal contains an unsafe entry: ${entry?.id ?? "unknown"}`);
|
|
641
|
-
}
|
|
642
|
-
if (strict && !sameKeys(entry, ["id", "phase", "path", "type", "backup", "existed", "previousDigest", "expectedNewDigest", "operation", "status"])) {
|
|
643
|
-
throw new Error(`Lifecycle transaction journal entry has unknown fields: ${entry.id}`);
|
|
644
|
-
}
|
|
645
|
-
if (ids.has(entry.id) || paths.has(entry.path)) throw new Error(`Lifecycle transaction journal contains duplicate targets: ${entry.id}`);
|
|
646
|
-
if (entry.operation === "remove" && entry.expectedNewDigest !== "missing") throw new Error(`Lifecycle remove entry has an invalid new digest: ${entry.id}`);
|
|
647
|
-
ids.add(entry.id);
|
|
648
|
-
paths.add(entry.path);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function previousRuntimeTarget(path) {
|
|
653
|
-
const parent = join(marketplaceRoot, pluginName);
|
|
654
|
-
const relation = relative(parent, resolve(path));
|
|
655
|
-
if (!relation || relation.includes(sep) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(relation) || relation === releaseVersion) return null;
|
|
656
|
-
return { phase: "cleanup", path: join(parent, relation), type: "tree" };
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function sameKeys(value, expected) {
|
|
660
|
-
return Object.keys(value).sort().join("\0") === [...expected].sort().join("\0");
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
async function rollbackEntries(transaction) {
|
|
664
|
-
validateTransactionJournal(transaction);
|
|
665
|
-
for (const entry of [...transaction.entries].reverse()) {
|
|
666
|
-
const destinationExists = await exists(entry.path);
|
|
667
|
-
const backupExists = await exists(entry.backup);
|
|
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
|
-
}
|
|
681
|
-
if (!entry.existed) throw new Error(`Cannot recover transaction because an unexpected backup exists: ${entry.path}`);
|
|
682
|
-
if (await targetDigest(entry.backup, entry.type) !== entry.previousDigest) {
|
|
683
|
-
throw new Error(`Cannot recover transaction because its durable backup changed: ${entry.path}`);
|
|
684
|
-
}
|
|
685
|
-
if (destinationExists) {
|
|
686
|
-
const current = await targetDigest(entry.path, entry.type);
|
|
687
|
-
if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover transaction because destination changed: ${entry.path}`);
|
|
688
|
-
if (entry.type === "config") {
|
|
689
|
-
const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
|
|
690
|
-
const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
|
|
691
|
-
await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
|
|
692
|
-
await rm(entry.backup, { force: true });
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
await rm(entry.path, { recursive: true, force: true });
|
|
696
|
-
}
|
|
697
|
-
await mkdir(dirname(entry.path), { recursive: true });
|
|
698
|
-
await rename(entry.backup, entry.path);
|
|
699
|
-
} else if (entry.type === "config" && entry.status === "prepared" && !destinationExists) {
|
|
700
|
-
continue;
|
|
701
|
-
} else if (!entry.existed) {
|
|
702
|
-
if (destinationExists) {
|
|
703
|
-
const current = await targetDigest(entry.path, entry.type);
|
|
704
|
-
if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover new transaction target because it changed: ${entry.path}`);
|
|
705
|
-
if (entry.type === "config") {
|
|
706
|
-
const preserved = removeCairnConfig((await readSharedConfigSnapshot(entry.path)).text);
|
|
707
|
-
if (preserved.length > 0) await writeFile(entry.path, preserved);
|
|
708
|
-
else await rm(entry.path, { force: true });
|
|
709
|
-
} else await rm(entry.path, { recursive: true, force: true });
|
|
710
|
-
}
|
|
711
|
-
} else {
|
|
712
|
-
if (!destinationExists || await targetDigest(entry.path, entry.type) !== entry.previousDigest) {
|
|
713
|
-
throw new Error(`Cannot recover transaction because its durable backup is missing: ${entry.path}`);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
147
|
+
try { await rmdir(path); }
|
|
148
|
+
catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error; }
|
|
717
149
|
}
|
|
718
150
|
|
|
719
151
|
function isInside(root, candidate) {
|
|
@@ -721,533 +153,19 @@ function isInside(root, candidate) {
|
|
|
721
153
|
return path === root || path.startsWith(`${root}${sep}`);
|
|
722
154
|
}
|
|
723
155
|
|
|
724
|
-
function
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
async function assertTargetsReplaceable(targets, ownership) {
|
|
729
|
-
const records = new Map((ownership?.targets ?? []).map((record) => [record.id, record]));
|
|
730
|
-
for (const target of targets) {
|
|
731
|
-
if (!(await exists(target.path))) continue;
|
|
732
|
-
if (target.id === "previous-codex-runtime") {
|
|
733
|
-
const previous = records.get("codex-runtime");
|
|
734
|
-
if (!previous || previous.path !== target.path || previous.type !== target.type) throw new Error("Previous runtime does not match ownership manifest.");
|
|
735
|
-
continue;
|
|
736
|
-
}
|
|
737
|
-
const record = records.get(target.id);
|
|
738
|
-
if (!record) {
|
|
739
|
-
if (target.type === "config" && cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length === 0) continue;
|
|
740
|
-
throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
|
|
741
|
-
}
|
|
742
|
-
if (target.id === "codex-runtime" && ownership?.version !== releaseVersion) {
|
|
743
|
-
throw new Error(`Refusing to overwrite unmanaged current-version runtime: ${target.path}`);
|
|
744
|
-
}
|
|
745
|
-
if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
|
|
746
|
-
const current = await targetDigest(target.path, target.type);
|
|
747
|
-
if (target.type !== "config" && current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
async function assertOwnershipDigests(ownership) {
|
|
752
|
-
for (const record of ownership.targets) {
|
|
753
|
-
const current = await targetDigest(record.path, record.type);
|
|
754
|
-
if (record.type !== "config" && current !== record.installedDigest) {
|
|
755
|
-
throw new Error(`Managed artifact was modified: ${record.path}`);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
async function targetRecord(target) {
|
|
761
|
-
return { id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null };
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
async function readOwnership({ required = false } = {}) {
|
|
765
|
-
if (!(await exists(ownershipPath))) {
|
|
766
|
-
if (required) throw new Error(`Ownership manifest is missing: ${ownershipPath}`);
|
|
767
|
-
return null;
|
|
768
|
-
}
|
|
769
|
-
try {
|
|
770
|
-
return JSON.parse(await readFile(ownershipPath, "utf8"));
|
|
771
|
-
} catch {
|
|
772
|
-
throw new Error(`Ownership manifest is invalid: ${ownershipPath}`);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function validateOwnershipShape(ownership, { required = false } = {}) {
|
|
777
|
-
if (!ownership) {
|
|
778
|
-
if (required) throw new Error("Ownership manifest is required.");
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
if (!sameKeys(ownership, ["schemaVersion", "plugin", "version", "transactionId", "installedAt", "targets"])
|
|
782
|
-
|| ownership.schemaVersion !== 1 || ownership.plugin !== pluginName
|
|
783
|
-
|| typeof ownership.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ownership.version)
|
|
784
|
-
|| typeof ownership.transactionId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(ownership.transactionId)
|
|
785
|
-
|| typeof ownership.installedAt !== "string" || Number.isNaN(Date.parse(ownership.installedAt))
|
|
786
|
-
|| !Array.isArray(ownership.targets)) {
|
|
787
|
-
throw new Error("Ownership manifest schema or plugin identity is invalid.");
|
|
788
|
-
}
|
|
789
|
-
const ids = new Set();
|
|
790
|
-
const paths = new Set();
|
|
791
|
-
for (const record of ownership.targets) {
|
|
792
|
-
if (!record || !sameKeys(record, ["id", "phase", "path", "type", "installedDigest", "previous"])
|
|
793
|
-
|| typeof record.id !== "string" || record.id.length === 0 || ids.has(record.id)
|
|
794
|
-
|| typeof record.path !== "string" || record.path.length === 0 || paths.has(record.path)
|
|
795
|
-
|| typeof record.phase !== "string" || !phases.includes(record.phase)
|
|
796
|
-
|| typeof record.type !== "string" || !["file", "tree", "config"].includes(record.type)
|
|
797
|
-
|| typeof record.installedDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(record.installedDigest)
|
|
798
|
-
|| record.previous !== null) {
|
|
799
|
-
throw new Error("Ownership manifest contains an invalid or duplicate target.");
|
|
800
|
-
}
|
|
801
|
-
const expected = expectedManagedTarget(record.id, ownership.version);
|
|
802
|
-
if (!expected || expected.path !== record.path || expected.phase !== record.phase || expected.type !== record.type) {
|
|
803
|
-
throw new Error(`Ownership manifest target is outside the Cairn allowlist: ${record.id}`);
|
|
804
|
-
}
|
|
805
|
-
ids.add(record.id);
|
|
806
|
-
paths.add(record.path);
|
|
807
|
-
}
|
|
808
|
-
const requiredTargets = requiredOwnershipTargets(ownership.version);
|
|
809
|
-
if (ids.size !== requiredTargets.size || [...requiredTargets.keys()].some((id) => !ids.has(id))) {
|
|
810
|
-
throw new Error("Ownership manifest does not contain the complete managed target set.");
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
function requiredOwnershipTargets(version = releaseVersion) {
|
|
815
|
-
const ids = ["codex-source", "codex-runtime", "marketplace", "claude-runtime", "ide-runtime", "cli-runtime", "codex-config"];
|
|
816
|
-
for (const name of commandNames()) ids.push(`claude-command-${name}`, `ide-workflow-${name}`, `cli-workflow-${name}`);
|
|
817
|
-
for (const name of ["explorer", "worker"]) ids.push(`claude-agent-${name}`);
|
|
818
|
-
for (const name of skillNames()) ids.push(`ide-skill-${name}`, `cli-skill-${name}`);
|
|
819
|
-
return new Map(ids.map((id) => [id, expectedManagedTarget(id, version)]));
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function expectedManagedTarget(id, version = releaseVersion) {
|
|
823
|
-
const exact = {
|
|
824
|
-
"codex-source": { phase: "codex", path: installedPluginRoot, type: "tree" },
|
|
825
|
-
"codex-runtime": { phase: "codex", path: versionedRuntimeRoot(version), type: "tree" },
|
|
826
|
-
marketplace: { phase: "codex", path: marketplaceJsonPath, type: "file" },
|
|
827
|
-
"claude-runtime": { phase: "claude", path: claudeRuntimeLocatorPath, type: "file" },
|
|
828
|
-
"ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityHome), type: "file" },
|
|
829
|
-
"cli-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityCliHome), type: "file" },
|
|
830
|
-
"legacy-ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file" },
|
|
831
|
-
"codex-config": { phase: "config", path: configPath, type: "config" },
|
|
832
|
-
};
|
|
833
|
-
if (exact[id]) return exact[id];
|
|
834
|
-
for (const name of commandNames()) {
|
|
835
|
-
if (id === `claude-command-${name}`) return { phase: "claude", path: join(claudeHome, "commands", `cairn-${name}.md`), type: "file" };
|
|
836
|
-
if (id === `ide-workflow-${name}`) return { phase: "antigravity", path: join(antigravityHome, "workflows", `cairn-${name}.md`), type: "file" };
|
|
837
|
-
if (id === `cli-workflow-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "workflows", `cairn-${name}.md`), type: "file" };
|
|
838
|
-
if (id === `legacy-ide-workflow-${name}`) return { phase: "antigravity", path: join(legacyAntigravityHome, "workflows", `cairn-${name}.md`), type: "file" };
|
|
839
|
-
}
|
|
840
|
-
for (const name of ["explorer", "worker"]) if (id === `claude-agent-${name}`) return { phase: "claude", path: join(claudeHome, "agents", `cairn-${name}.md`), type: "file" };
|
|
841
|
-
for (const name of skillNames()) {
|
|
842
|
-
if (id === `ide-skill-${name}`) return { phase: "antigravity", path: join(antigravityHome, "skills", name), type: "tree" };
|
|
843
|
-
if (id === `cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", `${name}.md`), type: "file" };
|
|
844
|
-
if (id === `legacy-ide-skill-${name}`) return { phase: "antigravity", path: join(legacyAntigravityHome, "skills", name), type: "tree" };
|
|
845
|
-
if (id === `legacy-cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", name), type: "tree" };
|
|
846
|
-
}
|
|
847
|
-
return null;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
function versionedRuntimeRoot(version) {
|
|
851
|
-
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error(`Invalid Cairn runtime version: ${version}`);
|
|
852
|
-
return join(marketplaceRoot, pluginName, version);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
async function anyManagedArtifactExists(targets) {
|
|
856
|
-
for (const target of targets) {
|
|
857
|
-
if (!(await exists(target.path))) continue;
|
|
858
|
-
if (target.type !== "config" || cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length > 0) return true;
|
|
859
|
-
}
|
|
860
|
-
return false;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
async function adoptLegacy(targets, transaction) {
|
|
864
|
-
if (!(await exists(installedPluginRoot))) throw new Error("Existing lifecycle artifacts are unmanaged; legacy Cairn root is missing.");
|
|
865
|
-
await verifyLegacy022Root(installedPluginRoot);
|
|
866
|
-
const records = [];
|
|
867
|
-
for (const target of targets) {
|
|
868
|
-
if (!(await exists(target.path))) continue;
|
|
869
|
-
if (target.id === "codex-runtime") continue;
|
|
870
|
-
if (target.id === "marketplace") {
|
|
871
|
-
const marketplace = await readJson(target.path);
|
|
872
|
-
const entry = marketplace?.plugins?.find((plugin) => plugin.name === pluginName);
|
|
873
|
-
if (marketplace?.name !== marketplaceName || entry?.source?.source !== "local" || entry?.source?.path !== "./plugins/cairn") {
|
|
874
|
-
throw new Error(`Legacy Cairn marketplace cannot be identified: ${target.path}`);
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
if (target.type === "config") {
|
|
878
|
-
const projection = cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text);
|
|
879
|
-
if (!projection.includes("[marketplaces.cairn]") || !projection.includes('[plugins."cairn@cairn"]')) {
|
|
880
|
-
throw new Error(`Legacy Cairn config cannot be identified: ${target.path}`);
|
|
881
|
-
}
|
|
882
|
-
} else if (target.id.endsWith("-runtime")) {
|
|
883
|
-
const locator = await readJson(target.path);
|
|
884
|
-
if (!validLegacyLocator(locator, installedPluginRoot)) throw new Error(`Legacy runtime mirror is invalid: ${target.path}`);
|
|
885
|
-
} else if (target.id !== "codex-source" && target.id !== "marketplace") {
|
|
886
|
-
await verifyLegacyMirror(target);
|
|
887
|
-
}
|
|
888
|
-
const backup = join(transaction.backupRoot, `legacy-${records.length}`);
|
|
889
|
-
await mkdir(dirname(backup), { recursive: true });
|
|
890
|
-
await cp(target.path, backup, { recursive: true });
|
|
891
|
-
records.push({ id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null });
|
|
892
|
-
}
|
|
893
|
-
return { schemaVersion: 1, plugin: pluginName, version: "0.2.2", transactionId: `legacy-${transaction.id}`, targets: records };
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
export async function verifyLegacy022Root(root) {
|
|
897
|
-
const integrity = JSON.parse(await readFile(join(pluginRoot, "scripts", "release-integrity-0.2.2.json"), "utf8"));
|
|
898
|
-
const allowedGenerated = new Set([".cairn-runtime.json", ...skillNames().map((name) => `skills/${name}/references/cairn-runtime.json`)]);
|
|
899
|
-
const actual = await regularFiles(root);
|
|
900
|
-
const expected = new Set([...Object.keys(integrity.files), ...allowedGenerated]);
|
|
901
|
-
if (actual.length !== expected.size || actual.some((file) => !expected.has(file))) throw new Error("Legacy Cairn 0.2.2 root file set does not match the signed release allowlist.");
|
|
902
|
-
for (const [path, expectedHash] of Object.entries(integrity.files)) {
|
|
903
|
-
const content = await readFile(join(root, path));
|
|
904
|
-
if (path === ".codex-plugin/plugin.json") {
|
|
905
|
-
const manifest = JSON.parse(content);
|
|
906
|
-
if (manifest.name !== "cairn" || manifest.version !== "0.2.2" || manifest.hooks !== "./hooks/hooks.json") throw new Error("Legacy Cairn manifest identity is invalid.");
|
|
907
|
-
delete manifest.hooks;
|
|
908
|
-
if (sha(`${JSON.stringify(manifest, null, 2)}\n`) !== `sha256:${expectedHash}`) throw new Error("Legacy Cairn manifest differs from release 0.2.2.");
|
|
909
|
-
} else if (sha(content) !== `sha256:${expectedHash}`) throw new Error(`Legacy Cairn release file was modified: ${path}`);
|
|
910
|
-
}
|
|
911
|
-
for (const path of allowedGenerated) {
|
|
912
|
-
const locator = JSON.parse(await readFile(join(root, path), "utf8"));
|
|
913
|
-
if (!validLegacyLocator(locator, root)) throw new Error(`Legacy runtime locator is invalid: ${path}`);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
function validLegacyLocator(locator, root) {
|
|
918
|
-
const expected = createRuntimeLocator(root);
|
|
919
|
-
return locator?.schemaVersion === RUNTIME_LOCATOR_SCHEMA_VERSION
|
|
920
|
-
&& locator.pluginRoot === expected.pluginRoot
|
|
921
|
-
&& JSON.stringify(locator.entrypoints) === JSON.stringify(expected.entrypoints)
|
|
922
|
-
&& JSON.stringify(locator.resources) === JSON.stringify(expected.resources);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
async function verifyLegacyMirror(target) {
|
|
926
|
-
// Legacy mirrors are accepted only when byte-identical to a deterministic render
|
|
927
|
-
// from the verified release tree. Unknown or user-modified mirrors are preserved.
|
|
928
|
-
const temp = await mkdtemp(join(tmpdir(), "cairn-legacy-mirror-"));
|
|
929
|
-
try {
|
|
930
|
-
const expected = join(temp, "expected");
|
|
931
|
-
if (target.id.startsWith("claude-command-")) {
|
|
932
|
-
const name = target.id.slice("claude-command-".length);
|
|
933
|
-
await renderMirrorFile(join(installedPluginRoot, ".claude", "commands", `cairn-${name}.md`), expected, claudeRuntimeLocatorPath, {}, installedPluginRoot);
|
|
934
|
-
} else if (target.id.startsWith("claude-agent-")) {
|
|
935
|
-
const name = target.id.slice("claude-agent-".length);
|
|
936
|
-
await renderMirrorFile(join(installedPluginRoot, ".claude", "agents", `${name}.md`), expected, claudeRuntimeLocatorPath, {}, installedPluginRoot);
|
|
937
|
-
} else if (target.id.startsWith("ide-skill-")) {
|
|
938
|
-
const name = target.id.slice("ide-skill-".length);
|
|
939
|
-
await cp(join(installedPluginRoot, "skills", name), expected, { recursive: true });
|
|
940
|
-
const locator = antigravitySkillLocatorPath(antigravityHome, name);
|
|
941
|
-
await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), join(expected, "SKILL.md"), locator, { includeLocatorNotice: true }, installedPluginRoot);
|
|
942
|
-
await writeLocator(join(expected, "references", "cairn-runtime.json"), installedPluginRoot);
|
|
943
|
-
} else if (target.id.startsWith("cli-skill-")) {
|
|
944
|
-
const name = target.id.slice("cli-skill-".length);
|
|
945
|
-
await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), expected, antigravityRuntimeLocatorPath(antigravityCliHome), { includeLocatorNotice: true }, installedPluginRoot);
|
|
946
|
-
} else if (target.id.startsWith("legacy-ide-skill-") || target.id.startsWith("legacy-cli-skill-")) {
|
|
947
|
-
const isIde = target.id.startsWith("legacy-ide-skill-");
|
|
948
|
-
const prefix = isIde ? "legacy-ide-skill-" : "legacy-cli-skill-";
|
|
949
|
-
const name = target.id.slice(prefix.length);
|
|
950
|
-
const root = isIde ? legacyAntigravityHome : antigravityCliHome;
|
|
951
|
-
const locator = antigravitySkillLocatorPath(root, name);
|
|
952
|
-
await cp(join(installedPluginRoot, "skills", name), expected, { recursive: true });
|
|
953
|
-
await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), join(expected, "SKILL.md"), locator, { includeLocatorNotice: true }, installedPluginRoot);
|
|
954
|
-
await writeLocator(join(expected, "references", "cairn-runtime.json"), installedPluginRoot);
|
|
955
|
-
} else if (target.id.startsWith("legacy-ide-workflow-")) {
|
|
956
|
-
const name = target.id.slice("legacy-ide-workflow-".length);
|
|
957
|
-
await renderMirrorFile(join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`), expected, antigravityRuntimeLocatorPath(legacyAntigravityHome), {}, installedPluginRoot);
|
|
958
|
-
} else if (target.id.startsWith("ide-workflow-") || target.id.startsWith("cli-workflow-")) {
|
|
959
|
-
const isIde = target.id.startsWith("ide-workflow-");
|
|
960
|
-
const prefix = isIde ? "ide-workflow-" : "cli-workflow-";
|
|
961
|
-
const name = target.id.slice(prefix.length);
|
|
962
|
-
const root = isIde ? antigravityHome : antigravityCliHome;
|
|
963
|
-
await renderMirrorFile(join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`), expected, antigravityRuntimeLocatorPath(root), {}, installedPluginRoot);
|
|
964
|
-
} else {
|
|
965
|
-
throw new Error(`Legacy mirror target is not recognized: ${target.id}`);
|
|
966
|
-
}
|
|
967
|
-
if (await targetDigest(expected, "file") !== await targetDigest(target.path, target.type)) throw new Error(`Legacy mirror was modified: ${target.path}`);
|
|
968
|
-
} finally {
|
|
969
|
-
await rm(temp, { recursive: true, force: true });
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
export async function targetDigest(path, type) {
|
|
974
|
-
if (!(await exists(path))) return "missing";
|
|
975
|
-
if (type === "config") return sha(cairnConfigProjection((await readSharedConfigSnapshot(path)).text));
|
|
976
|
-
const info = await lstat(path);
|
|
977
|
-
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
|
|
978
|
-
if (info.isFile()) return sha(await readFile(path));
|
|
979
|
-
const files = await regularFiles(path);
|
|
980
|
-
const hash = createHash("sha256");
|
|
981
|
-
for (const file of files) {
|
|
982
|
-
hash.update(file).update("\0").update(await readFile(join(path, file))).update("\0");
|
|
983
|
-
}
|
|
984
|
-
return `sha256:${hash.digest("hex")}`;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
async function regularFiles(root) {
|
|
988
|
-
const output = [];
|
|
989
|
-
async function walk(directory) {
|
|
990
|
-
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
991
|
-
const path = join(directory, entry.name);
|
|
992
|
-
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) throw new Error(`Symlink or special file is not allowed in managed tree: ${path}`);
|
|
993
|
-
if (entry.isDirectory()) await walk(path);
|
|
994
|
-
else output.push(relative(root, path).split(sep).join("/"));
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
const info = await lstat(root);
|
|
998
|
-
if (info.isSymbolicLink()) throw new Error(`Symlink is not allowed: ${root}`);
|
|
999
|
-
if (info.isFile()) return [basename(root)];
|
|
1000
|
-
await walk(root);
|
|
1001
|
-
return output.sort();
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
async function assertRegularTree(path) {
|
|
1005
|
-
await regularFiles(path);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function sha(value) {
|
|
1009
|
-
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
export function updateConfig(config, hookStates, { marketplacePath = marketplaceRoot } = {}) {
|
|
1013
|
-
let next = removeCairnConfig(config);
|
|
1014
|
-
next = append(next, `[marketplaces.cairn]\nlast_updated = ${JSON.stringify(new Date().toISOString().replace(/\.\d{3}Z$/, "Z"))}\nsource_type = "local"\nsource = ${JSON.stringify(marketplacePath)}\n`);
|
|
1015
|
-
next = append(next, '[plugins."cairn@cairn"]\nenabled = true\n');
|
|
1016
|
-
for (const state of hookStates) next = append(next, `[hooks.state.${JSON.stringify(state.key)}]\ntrusted_hash = ${JSON.stringify(state.trustedHash)}\n`);
|
|
1017
|
-
return next;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
export function removeCairnConfig(config) {
|
|
1021
|
-
return splitSections(config).map((section) => isCairnConfigSection(section)
|
|
1022
|
-
? splitTrailingTomlTrivia(section.text).trivia
|
|
1023
|
-
: section.text).join("");
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
function cairnConfigProjection(config) {
|
|
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"
|
|
1033
|
-
|| section.header === 'plugins."cairn@cairn"'
|
|
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
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
export function ensureSetting(config, sectionName, key, value) {
|
|
1078
|
-
const sections = splitSections(config);
|
|
1079
|
-
const index = sections.findIndex((section) => section.header === sectionName);
|
|
1080
|
-
if (index === -1) return append(config, `[${sectionName}]\n${key} = ${value}\n`);
|
|
1081
|
-
const lines = sections[index].text.trimEnd().split("\n");
|
|
1082
|
-
const settingIndex = lines.findIndex((line) => line.trim().startsWith(`${key} =`));
|
|
1083
|
-
if (settingIndex === -1) lines.push(`${key} = ${value}`);
|
|
1084
|
-
else lines[settingIndex] = `${key} = ${value}`;
|
|
1085
|
-
sections[index].text = `${lines.join("\n")}\n`;
|
|
1086
|
-
return sections.map((section) => section.text).join("");
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
export function hasSetting(config, sectionName, key, value) {
|
|
1090
|
-
const section = splitSections(config).find((candidate) => candidate.header === sectionName);
|
|
1091
|
-
return section?.text.split("\n").some((line) => line.trim() === `${key} = ${value}`) ?? false;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
export function splitSections(config) {
|
|
1095
|
-
const result = [];
|
|
1096
|
-
let current = { header: null, text: "" };
|
|
1097
|
-
let multiline = null;
|
|
1098
|
-
for (const line of config.split(/(?<=\n)/)) {
|
|
1099
|
-
const delimiterCount = (delimiter) => line.split(delimiter).length - 1;
|
|
1100
|
-
if (multiline) {
|
|
1101
|
-
current.text += line;
|
|
1102
|
-
if (delimiterCount(multiline) % 2 === 1) multiline = null;
|
|
1103
|
-
continue;
|
|
1104
|
-
}
|
|
1105
|
-
const doubleCount = delimiterCount('"""');
|
|
1106
|
-
const singleCount = delimiterCount("'''");
|
|
1107
|
-
const match = line.trim().match(/^\[([^\]]+)\]$/);
|
|
1108
|
-
if (match) {
|
|
1109
|
-
if (current.text.length > 0) result.push(current);
|
|
1110
|
-
current = { header: match[1], text: line };
|
|
1111
|
-
} else current.text += line;
|
|
1112
|
-
if (doubleCount % 2 === 1) multiline = '"""';
|
|
1113
|
-
else if (singleCount % 2 === 1) multiline = "'''";
|
|
1114
|
-
}
|
|
1115
|
-
if (current.text.length > 0) result.push(current);
|
|
1116
|
-
return result;
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
async function trustedHookStates(root) {
|
|
1120
|
-
const parsed = await readJson(join(root, "hooks", "hooks.json"));
|
|
1121
|
-
const states = [];
|
|
1122
|
-
const labels = { SessionStart: "session_start", UserPromptSubmit: "user_prompt_submit", PostToolUse: "post_tool_use", Stop: "stop", SubagentStop: "subagent_stop" };
|
|
1123
|
-
for (const [eventName, groups] of Object.entries(parsed?.hooks ?? {})) {
|
|
1124
|
-
const eventLabel = labels[eventName];
|
|
1125
|
-
if (!eventLabel || !Array.isArray(groups)) continue;
|
|
1126
|
-
for (const [groupIndex, group] of groups.entries()) for (const [handlerIndex, handler] of (group.hooks ?? []).entries()) {
|
|
1127
|
-
states.push({ key: `cairn@cairn:hooks/hooks.json:${eventLabel}:${groupIndex}:${handlerIndex}`, trustedHash: hookHash(eventLabel, group.matcher, handler) });
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
return states;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
export function hookHash(eventName, matcher, handler) {
|
|
1134
|
-
const normalized = { type: "command", command: handler.command, timeout: Math.max(Number(handler.timeout ?? 600), 1), async: false };
|
|
1135
|
-
if (typeof handler.statusMessage === "string") normalized.statusMessage = handler.statusMessage;
|
|
1136
|
-
const identity = { event_name: eventName, hooks: [normalized] };
|
|
1137
|
-
if (typeof matcher === "string") identity.matcher = matcher;
|
|
1138
|
-
return sha(JSON.stringify(canonical(identity)));
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
export function canonical(value) {
|
|
1142
|
-
if (Array.isArray(value)) return value.map(canonical);
|
|
1143
|
-
if (value === null || typeof value !== "object") return value;
|
|
1144
|
-
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
function append(config, block) {
|
|
1148
|
-
if (config.length === 0) return `${block.trimEnd()}\n`;
|
|
1149
|
-
return `${config}${config.endsWith("\n") ? "" : "\n"}${block.trimEnd()}\n`;
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
export function pathHasSegment(path, segment) { return path.split(/[\\/]+/).includes(segment); }
|
|
1153
|
-
export function shouldCopyPluginPath(source) {
|
|
1154
|
-
const name = source.split(/[\\/]+/).at(-1);
|
|
1155
|
-
if ([".git", "node_modules", ".cairn"].includes(name)) return false;
|
|
1156
|
-
if (sep === "/" && source.includes("\\")) return true;
|
|
1157
|
-
const relativePath = relative(pluginRoot, source);
|
|
1158
|
-
// Windows fs.cp filters can surface an absolute path when source and pluginRoot
|
|
1159
|
-
// use different drive/callback representations. Excluded basenames remain denied.
|
|
1160
|
-
if (isAbsolute(relativePath)) return true;
|
|
1161
|
-
const path = relativePath.split(sep).join("/");
|
|
1162
|
-
if (path === "") return true;
|
|
1163
|
-
if (path.startsWith("../") || path === "..") return true;
|
|
1164
|
-
if (/^(package\.json|LICENSE|README(?:\.[^.]+)?\.md)$/.test(path)) return true;
|
|
1165
|
-
if (path === "docs") return true;
|
|
1166
|
-
if (path.startsWith("docs/")) return path === "docs/model-guidance" || path.startsWith("docs/model-guidance/");
|
|
1167
|
-
return [".agents", ".claude", ".codex-plugin", "agents", "commands", "hooks", "scripts", "skills", "templates"]
|
|
1168
|
-
.some((directory) => path === directory || path.startsWith(`${directory}/`));
|
|
1169
|
-
}
|
|
156
|
+
export async function verifyLegacy022Root(root) { return integrity.verifyLegacy022Root(root); }
|
|
157
|
+
export async function targetDigest(path, type) { return integrity.targetDigest(path, type); }
|
|
158
|
+
export function updateConfig(config, hookStates, options = {}) { return projectConfig(config, hookStates, { marketplacePath: options.marketplacePath ?? marketplaceRoot }); }
|
|
159
|
+
export function shouldCopyPluginPath(source) { return projectShouldCopyPluginPath(source, pluginRoot); }
|
|
1170
160
|
export function samePath(left, right) { try { return realpathSync(left) === realpathSync(right); } catch { return resolve(left) === resolve(right); } }
|
|
1171
161
|
export function commandNames() { return ["install", "upgrade", "doctor", "uninstall", "memory", "plan", "work", "review", "toolcheck"]; }
|
|
162
|
+
export function agentNames() { return ["explorer", "worker"]; }
|
|
1172
163
|
export function skillNames() { return ["cairn-memory", "cairn-plan", "cairn-work", "cairn-review"]; }
|
|
164
|
+
export function renderInstalledMirror(content, options = {}) { return projectMirror(content, { runtimeRoot: options.runtimeRoot ?? versionedPluginRoot, locatorPath: options.locatorPath ?? installedRuntimeLocatorPath, platform: options.platform ?? process.platform }); }
|
|
165
|
+
export function findPluginStatus(value, id) { const entries = Array.isArray(value) ? value : value?.installed ?? value?.plugins ?? value?.data ?? []; return entries.find((entry) => entry.pluginId === id || entry.id === id || entry.name === id || `${entry.name}@${entry.marketplaceName ?? entry.marketplace}` === id) ?? null; }
|
|
166
|
+
export function parseCodexFeatureList(output) { if (typeof output !== "string" || output.trim().length === 0) return null; const features = {}; for (const line of output.split(/\r?\n/)) { const match = line.trim().match(/^([A-Za-z0-9_]+)\s+.+?\s+(true|false)$/); if (match) features[match[1]] = match[2] === "true"; } return Object.keys(features).length > 0 ? features : null; }
|
|
167
|
+
export function localeFamily() { const locale = [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG].find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale; return locale.toLowerCase().startsWith("ko") ? "ko" : "en"; }
|
|
1173
168
|
|
|
1174
|
-
export function renderInstalledMirror(content, { runtimeRoot = versionedPluginRoot, locatorPath = installedRuntimeLocatorPath, platform = process.platform } = {}) {
|
|
1175
|
-
const platformJoin = platform === "win32" ? win32.join : posix.join;
|
|
1176
|
-
const quotedCli = quoteShellArg(platformJoin(runtimeRoot, "scripts", "cairn.mjs"), platform);
|
|
1177
|
-
let rendered = content.replaceAll("{{CAIRN_RUNTIME_LOCATOR_JSON}}", JSON.stringify(locatorPath))
|
|
1178
|
-
.replace(/\bnode\s+scripts\/cairn\.mjs\b/g, `node ${quotedCli}`)
|
|
1179
|
-
.replace(/\bnode\s+scripts\/([A-Za-z0-9._/-]+)/g, (_, path) => `node ${quoteShellArg(join(runtimeRoot, "scripts", path), platform)}`);
|
|
1180
|
-
rendered = rendered.replace(/(^|[\s`("'=])(commands|agents|templates|docs\/model-guidance)\/([A-Za-z0-9._<>/-]+)/gm,
|
|
1181
|
-
(_, prefix, directory, suffix) => `${prefix}${platformJoin(runtimeRoot, directory, suffix)}`);
|
|
1182
|
-
return rendered;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
export function quoteShellArg(value, platform = process.platform) { return platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`; }
|
|
1186
|
-
function antigravityRuntimeLocatorPath(root) { return join(root, "cairn", "runtime.json"); }
|
|
1187
|
-
function installedSkillLocatorPath(name) { return join(versionedPluginRoot, "skills", name, "references", "cairn-runtime.json"); }
|
|
1188
|
-
function antigravitySkillLocatorPath(root, name) { return join(root, "skills", name, "references", "cairn-runtime.json"); }
|
|
1189
|
-
|
|
1190
|
-
async function writeLocator(path, root) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(createRuntimeLocator(root), null, 2)}\n`); }
|
|
1191
|
-
async function validRuntimeLocator(path) {
|
|
1192
|
-
try {
|
|
1193
|
-
const locator = await readJson(path);
|
|
1194
|
-
const expected = createRuntimeLocator(versionedPluginRoot);
|
|
1195
|
-
if (locator?.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || resolve(locator.pluginRoot ?? ".") !== resolve(versionedPluginRoot)) return false;
|
|
1196
|
-
for (const [name, value] of Object.entries(expected.entrypoints)) if (locator.entrypoints?.[name] !== value) return false;
|
|
1197
|
-
for (const [name, value] of Object.entries(expected.resources)) if (locator.resources?.[name] !== value) return false;
|
|
1198
|
-
return (await Promise.all(runtimeRequiredPaths(locator).map(exists))).every(Boolean);
|
|
1199
|
-
} catch { return false; }
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
async function renderMirrorFile(source, destination, locatorPath, { includeLocatorNotice = false } = {}, runtimeRoot = versionedPluginRoot) {
|
|
1203
|
-
const content = await readFile(source, "utf8");
|
|
1204
|
-
const notice = includeLocatorNotice ? `\n\n## Installed Cairn runtime\n\nRead the structured runtime locator at \`${JSON.stringify(locatorPath)}\`. Resolve Cairn scripts and static resources from its absolute paths, never from the target project.\n` : "";
|
|
1205
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
1206
|
-
await writeFile(destination, `${renderInstalledMirror(content, { runtimeRoot, locatorPath }).trimEnd()}${notice}`);
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
function hostJson(args, extraEnv = {}) {
|
|
1210
|
-
const result = spawnSync("codex", args, { encoding: "utf8", env: { ...process.env, ...extraEnv } });
|
|
1211
|
-
if (result.error || result.status !== 0) return null;
|
|
1212
|
-
try { return JSON.parse(result.stdout); } catch { return null; }
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
export function findPluginStatus(value, id) {
|
|
1216
|
-
const entries = Array.isArray(value) ? value : value?.installed ?? value?.plugins ?? value?.data ?? [];
|
|
1217
|
-
return entries.find((entry) => entry.pluginId === id || entry.id === id || entry.name === id || `${entry.name}@${entry.marketplaceName ?? entry.marketplace}` === id) ?? null;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
export function parseCodexFeatureList(output) {
|
|
1221
|
-
if (typeof output !== "string" || output.trim().length === 0) return null;
|
|
1222
|
-
const features = {};
|
|
1223
|
-
for (const line of output.split(/\r?\n/)) {
|
|
1224
|
-
const match = line.trim().match(/^([A-Za-z0-9_]+)\s+.+?\s+(true|false)$/);
|
|
1225
|
-
if (match) features[match[1]] = match[2] === "true";
|
|
1226
|
-
}
|
|
1227
|
-
return Object.keys(features).length > 0 ? features : null;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
async function validateAntigravitySkills() {
|
|
1231
|
-
try {
|
|
1232
|
-
if (!(await validRuntimeLocator(antigravityRuntimeLocatorPath(antigravityCliHome)))) return false;
|
|
1233
|
-
for (const name of skillNames()) {
|
|
1234
|
-
const content = await readFile(join(antigravityCliHome, "skills", `${name}.md`), "utf8");
|
|
1235
|
-
if (!/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(content) || !/^name:\s*\S+/m.test(content) || !/^description:\s*\S+/m.test(content)) return false;
|
|
1236
|
-
if (!content.includes(JSON.stringify(antigravityRuntimeLocatorPath(antigravityCliHome)))) return false;
|
|
1237
|
-
}
|
|
1238
|
-
return true;
|
|
1239
|
-
} catch {
|
|
1240
|
-
return false;
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
async function readJson(path) { if (!(await exists(path))) return null; return JSON.parse(await readFile(path, "utf8")); }
|
|
1245
|
-
async function readText(path) { if (!(await exists(path))) return ""; return readFile(path, "utf8"); }
|
|
1246
|
-
async function exists(path) { try { await lstat(path); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } }
|
|
1247
169
|
function help() { console.log(t("usage")); }
|
|
1248
|
-
function t(key) {
|
|
1249
|
-
const messages = { en: { installComplete: "Cairn install complete", upgradeComplete: "Cairn upgrade complete", uninstallComplete: "Cairn uninstall complete", usage: "Usage: cairn install|upgrade|doctor|uninstall|toolcheck" }, ko: { installComplete: "Cairn 설치 완료", upgradeComplete: "Cairn 업그레이드 완료", uninstallComplete: "Cairn 언인스톨 완료", usage: "사용법: cairn install|upgrade|doctor|uninstall|toolcheck" } };
|
|
1250
|
-
return messages[localeFamily()]?.[key] ?? messages.en[key];
|
|
1251
|
-
}
|
|
1252
|
-
export function localeFamily() { const locale = [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG].find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale; return locale.toLowerCase().startsWith("ko") ? "ko" : "en"; }
|
|
170
|
+
function t(key) { const messages = { en: { installComplete: "Cairn install complete", upgradeComplete: "Cairn upgrade complete", uninstallComplete: "Cairn uninstall complete", usage: "Usage: cairn install|upgrade|doctor|uninstall|toolcheck" }, ko: { installComplete: "Cairn 설치 완료", upgradeComplete: "Cairn 업그레이드 완료", uninstallComplete: "Cairn 언인스톨 완료", usage: "사용법: cairn install|upgrade|doctor|uninstall|toolcheck" } }; return messages[localeFamily()]?.[key] ?? messages.en[key]; }
|
|
1253
171
|
function isCliEntry() { if (!process.argv[1]) return false; try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return import.meta.url === pathToFileURL(process.argv[1]).href; } }
|