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