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