@cairnkeep/cli 2.9.0 → 2.12.0
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/CHANGELOG.md +66 -1
- package/README.md +83 -29
- package/bin/cairn +6 -165
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +42 -5
- package/docs/containers.md +10 -0
- package/docs/harness-compatibility.md +64 -3
- package/docs/learning/CURRICULUM-MAP.md +13 -6
- package/docs/learning/FEATURE-GUIDE.md +14 -3
- package/docs/learning/README.md +5 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +24 -8
- package/docs/learning/lessons/L03-first-project.md +34 -15
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +142 -0
- package/docs/learning/tracks/operator.md +8 -3
- package/docs/learning/tracks/quickstart.md +9 -4
- package/docs/native-windows.md +136 -0
- package/docs/operating.md +142 -20
- package/docs/privacy-and-data-flow.md +41 -1
- package/docs/quickstart.md +86 -0
- package/docs/storage.md +14 -2
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack.js +84 -25
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +7 -5
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +19 -20
- package/mcp-memory-server/dist/mcp-tool-profile.js +6 -4
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +2 -1
- package/package.json +6 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/scripts/bootstrap.sh +7 -15
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +17 -6
- package/scripts/doctor.sh +58 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/harness-registry.mjs +126 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +312 -0
- package/scripts/setup-reconcile.mjs +332 -0
- package/scripts/setup.mjs +342 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-bootstrap-untracked.sh +2 -1
- package/scripts/test-completion.sh +41 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-harness-registry.sh +94 -0
- package/scripts/test-learning-docs.sh +52 -3
- package/scripts/test-package-install.sh +69 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-phase19-eval-lifecycle.sh +12 -2
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +187 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +50 -0
- package/scripts/test-windows-native.mjs +159 -0
- package/scripts/uninstall.sh +73 -6
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +733 -0
- package/templates/codex-config.toml.template +5 -0
- package/templates/start-codex.sh.template +30 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
existsSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { open, rename } from "node:fs/promises";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
19
|
+
import { HARNESS_IDS } from "./harness-registry.mjs";
|
|
20
|
+
|
|
21
|
+
export const SETUP_STATE_SCHEMA_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
const HARNESSES = HARNESS_IDS;
|
|
24
|
+
const GIT_MODES = Object.freeze(["init", "existing", "none"]);
|
|
25
|
+
const MEMORY_MODES = Object.freeze(["local", "none"]);
|
|
26
|
+
const STATE_KEYS = Object.freeze(["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"]);
|
|
27
|
+
const ASSET_KEYS = Object.freeze(["digest", "mode", "template"]);
|
|
28
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
29
|
+
const RELATIVE_PATH_PATTERN = /^\.(?:ai|planning|agentfs|codex)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
|
|
30
|
+
const TEMPLATE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
31
|
+
|
|
32
|
+
export function hashSetupAsset(bytes) {
|
|
33
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exactKeys(value, expected, label) {
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid setup ${label}.`);
|
|
38
|
+
const keys = Object.keys(value).sort();
|
|
39
|
+
if (keys.join(",") !== [...expected].sort().join(",")) throw new Error(`Invalid setup ${label}: unknown or missing fields.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeHarnesses(harnesses) {
|
|
43
|
+
if (!Array.isArray(harnesses) || harnesses.length === 0 || harnesses.some((value) => typeof value !== "string" || !HARNESSES.includes(value))) {
|
|
44
|
+
throw new Error("Invalid setup state harnesses.");
|
|
45
|
+
}
|
|
46
|
+
if (new Set(harnesses).size !== harnesses.length) throw new Error("Invalid setup state duplicate harnesses.");
|
|
47
|
+
return HARNESSES.filter((name) => harnesses.includes(name));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeRelativePath(path) {
|
|
51
|
+
return typeof path === "string"
|
|
52
|
+
&& path.length <= 512
|
|
53
|
+
&& !isAbsolute(path)
|
|
54
|
+
&& !path.includes("\\")
|
|
55
|
+
&& RELATIVE_PATH_PATTERN.test(path)
|
|
56
|
+
&& !path.split("/").includes("..");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeState(state) {
|
|
60
|
+
exactKeys(state, STATE_KEYS, "state");
|
|
61
|
+
if (state.schema_version !== SETUP_STATE_SCHEMA_VERSION) throw new Error("Invalid setup state schema version.");
|
|
62
|
+
if (typeof state.cairnkeep_version !== "string" || state.cairnkeep_version.length === 0 || state.cairnkeep_version.length > 128) {
|
|
63
|
+
throw new Error("Invalid setup state version.");
|
|
64
|
+
}
|
|
65
|
+
if (!GIT_MODES.includes(state.git) || !MEMORY_MODES.includes(state.memory)) throw new Error("Invalid setup state mode.");
|
|
66
|
+
const harnesses = normalizeHarnesses(state.harnesses);
|
|
67
|
+
if (!state.assets || typeof state.assets !== "object" || Array.isArray(state.assets)) throw new Error("Invalid setup state assets.");
|
|
68
|
+
const assets = {};
|
|
69
|
+
for (const path of Object.keys(state.assets).sort()) {
|
|
70
|
+
if (!safeRelativePath(path) || path === ".ai/cairnkeep.json") throw new Error("Invalid or unsafe setup state asset path.");
|
|
71
|
+
const record = state.assets[path];
|
|
72
|
+
exactKeys(record, ASSET_KEYS, "state asset record");
|
|
73
|
+
if (!DIGEST_PATTERN.test(record.digest)
|
|
74
|
+
|| !Number.isInteger(record.mode) || record.mode < 0 || record.mode > 0o777
|
|
75
|
+
|| typeof record.template !== "string" || !TEMPLATE_PATTERN.test(record.template)) {
|
|
76
|
+
throw new Error("Invalid setup state asset record.");
|
|
77
|
+
}
|
|
78
|
+
assets[path] = { digest: record.digest, mode: record.mode, template: record.template };
|
|
79
|
+
}
|
|
80
|
+
return { schema_version: SETUP_STATE_SCHEMA_VERSION, cairnkeep_version: state.cairnkeep_version, git: state.git, memory: state.memory, harnesses, assets };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertContained(target, path) {
|
|
84
|
+
const absolute = resolve(target, ...path.split("/"));
|
|
85
|
+
const rel = relative(target, absolute);
|
|
86
|
+
if (!safeRelativePath(path) || rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Unsafe setup asset path: ${path}.`);
|
|
87
|
+
return absolute;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertExistingAncestors(target, destination) {
|
|
91
|
+
const targetInfo = lstatSync(target);
|
|
92
|
+
if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) throw new Error("Setup target must be a regular directory.");
|
|
93
|
+
const segments = relative(target, dirname(destination)).split(/[\\/]/).filter(Boolean);
|
|
94
|
+
let cursor = target;
|
|
95
|
+
for (const segment of segments) {
|
|
96
|
+
cursor = join(cursor, segment);
|
|
97
|
+
if (!existsSync(cursor)) break;
|
|
98
|
+
const info = lstatSync(cursor);
|
|
99
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup asset ancestor: ${relative(target, cursor)}.`);
|
|
100
|
+
}
|
|
101
|
+
if (existsSync(destination)) {
|
|
102
|
+
const destinationInfo = lstatSync(destination);
|
|
103
|
+
if (!destinationInfo.isFile() || destinationInfo.isSymbolicLink()) throw new Error(`Unsafe setup asset destination: ${relative(target, destination)}.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateAsset(asset, target, selectedHarnesses) {
|
|
108
|
+
if (!asset || typeof asset !== "object" || Array.isArray(asset)) throw new Error("Invalid setup asset.");
|
|
109
|
+
if (asset.harness && !selectedHarnesses.includes(asset.harness)) return null;
|
|
110
|
+
if (asset.harness && !HARNESSES.includes(asset.harness)) throw new Error("Invalid setup asset harness.");
|
|
111
|
+
if (!safeRelativePath(asset.path) || asset.path === ".ai/cairnkeep.json") throw new Error(`Unsafe setup asset path: ${asset.path}.`);
|
|
112
|
+
if (!Buffer.isBuffer(asset.bytes) && !(asset.bytes instanceof Uint8Array)) throw new Error(`Invalid setup asset bytes: ${asset.path}.`);
|
|
113
|
+
if (!Number.isInteger(asset.mode) || ![0o600, 0o644, 0o755].includes(asset.mode)) throw new Error(`Invalid setup asset mode: ${asset.path}.`);
|
|
114
|
+
if (typeof asset.template !== "string" || !TEMPLATE_PATTERN.test(asset.template)) throw new Error(`Invalid setup asset template: ${asset.path}.`);
|
|
115
|
+
const destination = assertContained(target, asset.path);
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
path: asset.path,
|
|
118
|
+
destination,
|
|
119
|
+
bytes: Buffer.from(asset.bytes),
|
|
120
|
+
digest: hashSetupAsset(asset.bytes),
|
|
121
|
+
mode: asset.mode,
|
|
122
|
+
template: asset.template,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateTargetAncestors(target) {
|
|
127
|
+
const segments = resolve(target).split(/[\\/]/).filter(Boolean);
|
|
128
|
+
let cursor = resolve(target).startsWith("/") ? "/" : `${segments.shift()}\\`;
|
|
129
|
+
for (const segment of segments) {
|
|
130
|
+
cursor = join(cursor, segment);
|
|
131
|
+
if (!existsSync(cursor)) return;
|
|
132
|
+
const info = lstatSync(cursor);
|
|
133
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup target ancestor: ${cursor}.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createTargetChain(target) {
|
|
138
|
+
const absolute = resolve(target);
|
|
139
|
+
const segments = absolute.split(/[\\/]/).filter(Boolean);
|
|
140
|
+
let cursor = absolute.startsWith("/") ? "/" : `${segments.shift()}\\`;
|
|
141
|
+
for (const segment of segments) {
|
|
142
|
+
cursor = join(cursor, segment);
|
|
143
|
+
if (existsSync(cursor)) {
|
|
144
|
+
const info = lstatSync(cursor);
|
|
145
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup target ancestor: ${cursor}.`);
|
|
146
|
+
} else mkdirSync(cursor, { mode: cursor === absolute ? 0o755 : 0o700 });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeMkdirChain(target, directory) {
|
|
151
|
+
const segments = relative(target, directory).split(/[\\/]/).filter(Boolean);
|
|
152
|
+
let cursor = target;
|
|
153
|
+
for (const segment of segments) {
|
|
154
|
+
cursor = join(cursor, segment);
|
|
155
|
+
if (existsSync(cursor)) {
|
|
156
|
+
const info = lstatSync(cursor);
|
|
157
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup directory: ${relative(target, cursor)}.`);
|
|
158
|
+
} else mkdirSync(cursor, { mode: 0o700 });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function defaultAtomicReplace(source, destination) {
|
|
163
|
+
if (process.platform !== "win32") {
|
|
164
|
+
await rename(source, destination);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let lastError = "Windows atomic setup replacement failed.";
|
|
168
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
169
|
+
if (!existsSync(destination)) {
|
|
170
|
+
try {
|
|
171
|
+
await rename(source, destination);
|
|
172
|
+
return;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
175
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25 * (attempt + 1)));
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const backup = `${destination}.replace-backup-${randomUUID()}`;
|
|
180
|
+
try {
|
|
181
|
+
const result = spawnSync("powershell.exe", [
|
|
182
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
|
|
183
|
+
"[IO.File]::Replace($env:CK_INTERNAL_ATOMIC_SOURCE,$env:CK_INTERNAL_ATOMIC_DESTINATION,$env:CK_INTERNAL_ATOMIC_BACKUP,$true)",
|
|
184
|
+
], {
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
windowsHide: true,
|
|
187
|
+
shell: false,
|
|
188
|
+
env: {
|
|
189
|
+
...process.env,
|
|
190
|
+
CK_INTERNAL_ATOMIC_SOURCE: source,
|
|
191
|
+
CK_INTERNAL_ATOMIC_DESTINATION: destination,
|
|
192
|
+
CK_INTERNAL_ATOMIC_BACKUP: backup,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
if (result.status === 0) return;
|
|
196
|
+
lastError = result.stderr.trim() || result.stdout.trim() || lastError;
|
|
197
|
+
} finally {
|
|
198
|
+
rmSync(backup, { force: true });
|
|
199
|
+
}
|
|
200
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25 * (attempt + 1)));
|
|
201
|
+
}
|
|
202
|
+
throw new Error(lastError);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function hardenPrivatePath(path) {
|
|
206
|
+
if (process.platform !== "win32") {
|
|
207
|
+
chmodSync(path, lstatSync(path).isDirectory() ? 0o700 : 0o600);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const identity = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true, shell: false });
|
|
211
|
+
const sid = identity.stdout.match(/"(S-1-[0-9-]+)"/i)?.[1];
|
|
212
|
+
if (identity.status !== 0 || !sid) throw new Error("Unable to resolve the current Windows security identity.");
|
|
213
|
+
const result = spawnSync("icacls.exe", [path, "/inheritance:r", "/grant:r", `*${sid}:(F)`, "/grant:r", "*S-1-5-18:(F)", "/grant:r", "*S-1-5-32-544:(F)"], {
|
|
214
|
+
encoding: "utf8",
|
|
215
|
+
windowsHide: true,
|
|
216
|
+
shell: false,
|
|
217
|
+
});
|
|
218
|
+
if (result.status !== 0) throw new Error("Unable to restrict private setup state.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function atomicWrite(path, bytes, mode, atomicReplace = defaultAtomicReplace, privateState = false) {
|
|
222
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
223
|
+
let handle;
|
|
224
|
+
try {
|
|
225
|
+
handle = await open(temporary, "wx", mode);
|
|
226
|
+
await handle.writeFile(bytes);
|
|
227
|
+
await handle.sync();
|
|
228
|
+
await handle.close();
|
|
229
|
+
handle = undefined;
|
|
230
|
+
chmodSync(temporary, mode);
|
|
231
|
+
if (privateState) hardenPrivatePath(temporary);
|
|
232
|
+
await atomicReplace(temporary, path);
|
|
233
|
+
chmodSync(path, mode);
|
|
234
|
+
if (privateState) hardenPrivatePath(path);
|
|
235
|
+
} finally {
|
|
236
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
237
|
+
rmSync(temporary, { force: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readPriorState(statePath) {
|
|
242
|
+
if (!existsSync(statePath)) return null;
|
|
243
|
+
const info = lstatSync(statePath);
|
|
244
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("Existing setup state is unsafe.");
|
|
245
|
+
let descriptor;
|
|
246
|
+
try {
|
|
247
|
+
descriptor = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
248
|
+
const opened = fstatSync(descriptor);
|
|
249
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) {
|
|
250
|
+
throw new Error("Existing setup state changed during validation.");
|
|
251
|
+
}
|
|
252
|
+
return normalizeState(JSON.parse(readFileSync(descriptor, "utf8")));
|
|
253
|
+
} finally {
|
|
254
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function writeSetupStateAtomic(path, state, options = {}) {
|
|
259
|
+
const normalized = normalizeState(state);
|
|
260
|
+
const directory = dirname(path);
|
|
261
|
+
const target = dirname(directory);
|
|
262
|
+
if (basename(directory) !== ".ai" || basename(path) !== "cairnkeep.json") throw new Error("Setup state path is unsafe.");
|
|
263
|
+
if (!existsSync(target)) throw new Error("Setup state target does not exist.");
|
|
264
|
+
assertExistingAncestors(target, path);
|
|
265
|
+
safeMkdirChain(target, directory);
|
|
266
|
+
const bytes = Buffer.from(`${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
267
|
+
if (existsSync(path) && readFileSync(path).equals(bytes) && (statSync(path).mode & 0o777) === 0o600) return normalized;
|
|
268
|
+
await atomicWrite(path, bytes, 0o600, options.atomicReplace ?? defaultAtomicReplace, true);
|
|
269
|
+
return normalized;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function reconcileSetupPlan(plan, options = {}) {
|
|
273
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("Invalid setup plan.");
|
|
274
|
+
const target = resolve(plan.target);
|
|
275
|
+
validateTargetAncestors(target);
|
|
276
|
+
if (typeof plan.version !== "string" || !GIT_MODES.includes(plan.git) || !MEMORY_MODES.includes(plan.memory)) throw new Error("Invalid setup plan modes.");
|
|
277
|
+
const harnesses = normalizeHarnesses(plan.harnesses);
|
|
278
|
+
if (!Array.isArray(plan.assets)) throw new Error("Invalid setup plan assets.");
|
|
279
|
+
const selected = plan.assets.map((asset) => validateAsset(asset, target, harnesses)).filter(Boolean);
|
|
280
|
+
const paths = selected.map(({ path }) => path);
|
|
281
|
+
if (new Set(paths).size !== paths.length) throw new Error("Setup plan contains duplicate asset paths.");
|
|
282
|
+
createTargetChain(target);
|
|
283
|
+
const targetInfo = lstatSync(target);
|
|
284
|
+
if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) throw new Error("Setup target is unsafe.");
|
|
285
|
+
for (const asset of selected) assertExistingAncestors(target, asset.destination);
|
|
286
|
+
const statePath = join(target, ".ai", "cairnkeep.json");
|
|
287
|
+
assertExistingAncestors(target, statePath);
|
|
288
|
+
const previousState = options.previousState === undefined ? readPriorState(statePath) : options.previousState === null ? null : normalizeState(options.previousState);
|
|
289
|
+
|
|
290
|
+
const decisions = selected.map((asset) => {
|
|
291
|
+
if (!existsSync(asset.destination)) return { ...asset, status: "created" };
|
|
292
|
+
const currentInfo = lstatSync(asset.destination);
|
|
293
|
+
if (!currentInfo.isFile() || currentInfo.isSymbolicLink()) throw new Error(`Unsafe setup asset destination: ${asset.path}.`);
|
|
294
|
+
const currentDigest = hashSetupAsset(readFileSync(asset.destination));
|
|
295
|
+
const currentMode = currentInfo.mode & 0o777;
|
|
296
|
+
if (currentDigest === asset.digest && currentMode === asset.mode) return { ...asset, status: "unchanged" };
|
|
297
|
+
const prior = previousState?.assets?.[asset.path];
|
|
298
|
+
if (prior && prior.digest === currentDigest && prior.mode === currentMode) return { ...asset, status: "updated" };
|
|
299
|
+
return { ...asset, status: "skipped" };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
for (const decision of decisions) {
|
|
303
|
+
if (decision.status !== "created" && decision.status !== "updated") continue;
|
|
304
|
+
safeMkdirChain(target, dirname(decision.destination));
|
|
305
|
+
await atomicWrite(decision.destination, decision.bytes, decision.mode, options.atomicReplace ?? defaultAtomicReplace, decision.mode === 0o600);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const assets = {};
|
|
309
|
+
for (const decision of decisions) {
|
|
310
|
+
if (decision.status === "skipped") {
|
|
311
|
+
const prior = previousState?.assets?.[decision.path];
|
|
312
|
+
if (prior) assets[decision.path] = prior;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
assets[decision.path] = { digest: decision.digest, mode: decision.mode, template: decision.template };
|
|
316
|
+
}
|
|
317
|
+
const state = normalizeState({
|
|
318
|
+
schema_version: SETUP_STATE_SCHEMA_VERSION,
|
|
319
|
+
cairnkeep_version: plan.version,
|
|
320
|
+
git: plan.git,
|
|
321
|
+
memory: plan.memory,
|
|
322
|
+
harnesses,
|
|
323
|
+
assets,
|
|
324
|
+
});
|
|
325
|
+
await writeSetupStateAtomic(statePath, state, { atomicReplace: options.atomicReplace ?? defaultAtomicReplace });
|
|
326
|
+
const counts = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
|
|
327
|
+
const changes = decisions.map(({ path, status }) => {
|
|
328
|
+
counts[status] += 1;
|
|
329
|
+
return Object.freeze({ path, status });
|
|
330
|
+
});
|
|
331
|
+
return Object.freeze({ counts: Object.freeze(counts), changes: Object.freeze(changes), state: Object.freeze(state) });
|
|
332
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
15
|
+
import { stdin as defaultInput, stdout as defaultOutput, stderr as defaultError } from "node:process";
|
|
16
|
+
import {
|
|
17
|
+
buildSetupPlan,
|
|
18
|
+
classifySetupTarget,
|
|
19
|
+
parseSetupArgs,
|
|
20
|
+
readSetupPolicy,
|
|
21
|
+
resolveSetupChoices,
|
|
22
|
+
} from "./setup-core.mjs";
|
|
23
|
+
import { reconcileSetupPlan } from "./setup-reconcile.mjs";
|
|
24
|
+
import { HARNESS_IDS, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
|
|
25
|
+
|
|
26
|
+
const HARNESSES = HARNESS_IDS;
|
|
27
|
+
const GIT_MODES = Object.freeze(["init", "existing", "none"]);
|
|
28
|
+
const MEMORY_MODES = Object.freeze(["local", "none"]);
|
|
29
|
+
const STATE_KEYS = Object.freeze(["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"]);
|
|
30
|
+
const ASSET_KEYS = Object.freeze(["digest", "mode", "template"]);
|
|
31
|
+
const SAFE_ASSET = /^\.(?:ai|planning|agentfs|codex)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
|
|
32
|
+
const COMMON_SETUP_ASSETS = Object.freeze([
|
|
33
|
+
".ai/env.example",
|
|
34
|
+
".ai/trajectory-redaction.json",
|
|
35
|
+
".ai/capabilities.json",
|
|
36
|
+
".agentfs/.gitignore",
|
|
37
|
+
".planning/config.json",
|
|
38
|
+
".planning/PROJECT-BRIEF.md",
|
|
39
|
+
".planning/wiki/index.md",
|
|
40
|
+
".planning/wiki/policy.md",
|
|
41
|
+
".planning/wiki/CONTRADICTIONS.md",
|
|
42
|
+
".planning/wiki/LOG.md",
|
|
43
|
+
".planning/alignment/policy.md",
|
|
44
|
+
".planning/alignment/gap-register.yaml",
|
|
45
|
+
".planning/graphs/policy.md",
|
|
46
|
+
".planning/graphs/.gitignore",
|
|
47
|
+
".planning/security/policy.md",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function operational(kind, message) {
|
|
51
|
+
const error = new Error(message);
|
|
52
|
+
error.name = "SetupOperationalError";
|
|
53
|
+
error.kind = kind;
|
|
54
|
+
error.status = 1;
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeLine(stream, value = "") {
|
|
59
|
+
stream.write(`${value}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseInteractiveHarnesses(value) {
|
|
63
|
+
const selected = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
64
|
+
if (selected.length === 0 || new Set(selected).size !== selected.length || selected.some((item) => !HARNESSES.includes(item))) {
|
|
65
|
+
throw operational("interactive-input", `Harnesses must be a comma-separated subset of ${HARNESSES.join(", ")}.`);
|
|
66
|
+
}
|
|
67
|
+
return HARNESSES.filter((name) => selected.includes(name));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function promptSetupChoices(parsed, streams) {
|
|
71
|
+
const terminal = typeof streams.question === "function"
|
|
72
|
+
? { question: streams.question, close: streams.closePrompt ?? (() => {}) }
|
|
73
|
+
: createInterface({ input: streams.input, output: streams.output });
|
|
74
|
+
try {
|
|
75
|
+
const target = parsed.target ?? (await terminal.question("Target path: ")).trim();
|
|
76
|
+
if (!target) throw operational("interactive-input", "A setup target path is required.");
|
|
77
|
+
const preflight = classifySetupTarget(target);
|
|
78
|
+
const gitQuestion = preflight.targetState === "missing" || preflight.targetState === "empty"
|
|
79
|
+
? "Git mode (init recommended; choices: init, existing, none): "
|
|
80
|
+
: preflight.repository === "work-tree"
|
|
81
|
+
? "Git mode (existing recommended; choices: init, existing, none): "
|
|
82
|
+
: "Git mode (init requires explicit choice for existing non-Git target; choices: init, existing, none): ";
|
|
83
|
+
const git = parsed.git ?? (await terminal.question(gitQuestion)).trim();
|
|
84
|
+
const harnesses = parsed.harnesses ?? parseInteractiveHarnesses(await terminal.question(`Harnesses (${HARNESSES.join(", ")}): `));
|
|
85
|
+
const memory = parsed.memory ?? (await terminal.question("Memory mode (local, none): ")).trim();
|
|
86
|
+
return { target, git, harnesses, memory, preflight, terminal };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
terminal.close();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderPlan(plan, stream) {
|
|
94
|
+
writeLine(stream, `Target: ${plan.target}`);
|
|
95
|
+
writeLine(stream, `Git mode: ${plan.git}`);
|
|
96
|
+
writeLine(stream, `Harnesses: ${plan.harnesses.join(", ")}`);
|
|
97
|
+
writeLine(stream, `Memory mode: ${plan.memory}`);
|
|
98
|
+
writeLine(stream, "Planned assets:");
|
|
99
|
+
for (const asset of plan.assets) writeLine(stream, ` ${asset.path}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function launchCommands(plan, platform) {
|
|
103
|
+
const suffix = platform === "win32" ? "cmd" : "sh";
|
|
104
|
+
return plan.harnesses.map((harness) => `.ai/start-${harness}.${suffix}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setupRecovery(plan) {
|
|
108
|
+
return [`cairn setup . --git ${plan.git} --harness ${plan.harnesses.join(",")} --memory ${plan.memory} --yes`];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resultFor(plan, reconciliation, platform) {
|
|
112
|
+
const machineCommand = machineSyncCommand(plan.harnesses);
|
|
113
|
+
return Object.freeze({
|
|
114
|
+
schema_version: 1,
|
|
115
|
+
status: plan.limited ? "limited" : "complete",
|
|
116
|
+
target: plan.target,
|
|
117
|
+
git: plan.git,
|
|
118
|
+
memory: plan.memory,
|
|
119
|
+
harnesses: plan.harnesses,
|
|
120
|
+
counts: reconciliation.counts,
|
|
121
|
+
changes: reconciliation.changes,
|
|
122
|
+
verification: Object.freeze(["cairn doctor"]),
|
|
123
|
+
launch_commands: Object.freeze(launchCommands(plan, platform)),
|
|
124
|
+
recovery: Object.freeze(setupRecovery(plan)),
|
|
125
|
+
limitations: Object.freeze(plan.limited ? ["Git integration is disabled; repository-aware features are limited."] : []),
|
|
126
|
+
machine_sync: Object.freeze({ automatic: false, command: machineCommand }),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderSetupResult(result, stream) {
|
|
131
|
+
writeLine(stream, `Setup status: ${result.status}`);
|
|
132
|
+
writeLine(stream, `Harnesses: ${result.harnesses.join(", ")}`);
|
|
133
|
+
writeLine(stream, `Git mode: ${result.git}`);
|
|
134
|
+
writeLine(stream, `Memory mode: ${result.memory}`);
|
|
135
|
+
writeLine(stream, `Created: ${result.counts.created}`);
|
|
136
|
+
writeLine(stream, `Updated: ${result.counts.updated}`);
|
|
137
|
+
writeLine(stream, `Unchanged: ${result.counts.unchanged}`);
|
|
138
|
+
writeLine(stream, `Skipped: ${result.counts.skipped}`);
|
|
139
|
+
writeLine(stream, `Verification: ${result.verification.join(", ")}`);
|
|
140
|
+
for (const command of result.launch_commands) writeLine(stream, `Launch: ${command}`);
|
|
141
|
+
for (const command of result.recovery) writeLine(stream, `Recovery: ${command}`);
|
|
142
|
+
for (const limitation of result.limitations) writeLine(stream, `Limitation: ${limitation}`);
|
|
143
|
+
if (result.harnesses.includes("codex") && result.memory === "local") {
|
|
144
|
+
writeLine(stream, "Codex: review .codex/config.toml and accept the project trust prompt before use");
|
|
145
|
+
}
|
|
146
|
+
writeLine(stream, result.machine_sync.command
|
|
147
|
+
? `Machine sync: ${result.machine_sync.command} (not run automatically)`
|
|
148
|
+
: "Machine sync: not required for the selected harnesses");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createTarget(target) {
|
|
152
|
+
try {
|
|
153
|
+
if (!existsSync(target)) mkdirSync(target, { recursive: true, mode: 0o755 });
|
|
154
|
+
const info = lstatSync(target);
|
|
155
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("target is not a regular directory");
|
|
156
|
+
} catch {
|
|
157
|
+
throw operational("target-denied", "Setup target could not be created safely.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function initializeGit(target) {
|
|
162
|
+
const result = spawnSync("git", ["init", "-q", target], { encoding: "utf8", shell: false });
|
|
163
|
+
if (result.error?.code === "ENOENT") throw operational("missing-git", "Git executable is required for --git init.");
|
|
164
|
+
if (result.error || result.status !== 0) throw operational("git-init", "Git repository initialization failed.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function executeSetup(args, options) {
|
|
168
|
+
const isTTY = options.isTTY ?? Boolean(options.input.isTTY);
|
|
169
|
+
const parsed = parseSetupArgs(args, { isTTY });
|
|
170
|
+
const policy = parsed.policyPath ? readSetupPolicy(parsed.policyPath) : null;
|
|
171
|
+
|
|
172
|
+
let interactive = null;
|
|
173
|
+
let target = parsed.target;
|
|
174
|
+
let preflight = null;
|
|
175
|
+
if (isTTY && (!target || !parsed.git || !parsed.harnesses || !parsed.memory || !parsed.confirmed)) {
|
|
176
|
+
interactive = await promptSetupChoices(parsed, options);
|
|
177
|
+
target = interactive.target;
|
|
178
|
+
preflight = interactive.preflight;
|
|
179
|
+
}
|
|
180
|
+
preflight ??= classifySetupTarget(target);
|
|
181
|
+
const choices = resolveSetupChoices({
|
|
182
|
+
parsed,
|
|
183
|
+
preflight,
|
|
184
|
+
policy,
|
|
185
|
+
interactive: interactive ? { ...interactive, confirmed: true } : null,
|
|
186
|
+
});
|
|
187
|
+
let plan = buildSetupPlan({ target, preflight, choices });
|
|
188
|
+
if (options.augmentPlan) plan = await options.augmentPlan(plan);
|
|
189
|
+
|
|
190
|
+
if (interactive && !parsed.confirmed) {
|
|
191
|
+
renderPlan(plan, options.output);
|
|
192
|
+
const answer = (await interactive.terminal.question("Apply this setup plan? [y/N] ")).trim().toLowerCase();
|
|
193
|
+
interactive.terminal.close();
|
|
194
|
+
if (answer !== "y" && answer !== "yes") {
|
|
195
|
+
writeLine(options.output, "Setup cancelled; no files were changed.");
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
} else if (interactive) interactive.terminal.close();
|
|
199
|
+
|
|
200
|
+
createTarget(plan.target);
|
|
201
|
+
if (plan.git === "init") initializeGit(plan.target);
|
|
202
|
+
const reconciliation = await reconcileSetupPlan(plan);
|
|
203
|
+
return resultFor(plan, reconciliation, options.platform);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function runSetup(args, options = {}) {
|
|
207
|
+
const streams = {
|
|
208
|
+
input: options.input ?? defaultInput,
|
|
209
|
+
output: options.output ?? defaultOutput,
|
|
210
|
+
error: options.error ?? defaultError,
|
|
211
|
+
isTTY: options.isTTY,
|
|
212
|
+
platform: options.platform ?? process.platform,
|
|
213
|
+
augmentPlan: options.augmentPlan,
|
|
214
|
+
question: options.question,
|
|
215
|
+
closePrompt: options.closePrompt,
|
|
216
|
+
};
|
|
217
|
+
try {
|
|
218
|
+
const result = await executeSetup(args, streams);
|
|
219
|
+
if (!result) return 0;
|
|
220
|
+
if (args.includes("--json")) writeLine(streams.output, JSON.stringify(result));
|
|
221
|
+
else renderSetupResult(result, streams.output);
|
|
222
|
+
return 0;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
writeLine(streams.error, error instanceof Error ? error.message : "Setup failed.");
|
|
225
|
+
return Number.isInteger(error?.status) ? error.status : 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function incompleteDiagnosis(recovery = "cairn setup . --git existing --harness claude --memory local --yes") {
|
|
230
|
+
return Object.freeze({ schema_version: 1, status: "incomplete", code: "setup-state", recovery: Object.freeze([recovery]) });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function exactKeys(value, expected) {
|
|
234
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
235
|
+
&& Object.keys(value).sort().join(",") === [...expected].sort().join(",");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function safeStateAsset(path, record) {
|
|
239
|
+
return typeof path === "string"
|
|
240
|
+
&& path.length <= 512
|
|
241
|
+
&& SAFE_ASSET.test(path)
|
|
242
|
+
&& !path.includes("\\")
|
|
243
|
+
&& !path.split("/").includes("..")
|
|
244
|
+
&& exactKeys(record, ASSET_KEYS)
|
|
245
|
+
&& typeof record.digest === "string"
|
|
246
|
+
&& /^[a-f0-9]{64}$/.test(record.digest)
|
|
247
|
+
&& Number.isInteger(record.mode)
|
|
248
|
+
&& record.mode >= 0
|
|
249
|
+
&& record.mode <= 0o777
|
|
250
|
+
&& typeof record.template === "string"
|
|
251
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(record.template);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readPrivateState(path) {
|
|
255
|
+
const info = lstatSync(path);
|
|
256
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("unsafe");
|
|
257
|
+
if (process.platform !== "win32" && (info.mode & 0o777) !== 0o600) throw new Error("unsafe");
|
|
258
|
+
let descriptor;
|
|
259
|
+
try {
|
|
260
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
261
|
+
const opened = fstatSync(descriptor);
|
|
262
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) throw new Error("unsafe");
|
|
263
|
+
return JSON.parse(readFileSync(descriptor, "utf8"));
|
|
264
|
+
} finally {
|
|
265
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hasCodexMemoryConfig(project) {
|
|
270
|
+
const path = join(project, ".codex", "config.toml");
|
|
271
|
+
if (!existsSync(path)) return false;
|
|
272
|
+
const info = lstatSync(path);
|
|
273
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) return false;
|
|
274
|
+
let descriptor;
|
|
275
|
+
let text;
|
|
276
|
+
try {
|
|
277
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
278
|
+
const opened = fstatSync(descriptor);
|
|
279
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) return false;
|
|
280
|
+
text = readFileSync(descriptor, "utf8");
|
|
281
|
+
} finally {
|
|
282
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
283
|
+
}
|
|
284
|
+
const lines = text.split(/\r?\n/);
|
|
285
|
+
const headers = lines.flatMap((line, index) => (
|
|
286
|
+
["[mcp_servers.cairn-memory]", "[mcp_servers.\"cairn-memory\"]"].includes(line.trim()) ? [index] : []
|
|
287
|
+
));
|
|
288
|
+
if (headers.length !== 1) return false;
|
|
289
|
+
const [start] = headers;
|
|
290
|
+
const next = lines.findIndex((line, index) => index > start && /^\s*\[[^\]]+\]\s*$/.test(line));
|
|
291
|
+
const section = lines.slice(start + 1, next < 0 ? lines.length : next).join("\n");
|
|
292
|
+
return (section.match(/^\s*command\s*=\s*["']cairn["']\s*$/gm) ?? []).length === 1
|
|
293
|
+
&& (section.match(/^\s*args\s*=\s*\[\s*["']memory-server["']\s*\]\s*$/gm) ?? []).length === 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function diagnoseSetup(target = ".") {
|
|
297
|
+
const project = resolve(target);
|
|
298
|
+
const statePath = join(project, ".ai", "cairnkeep.json");
|
|
299
|
+
if (!existsSync(statePath)) return Object.freeze({ schema_version: 1, status: "absent", code: "not-configured", recovery: Object.freeze([]) });
|
|
300
|
+
try {
|
|
301
|
+
const state = readPrivateState(statePath);
|
|
302
|
+
if (!exactKeys(state, STATE_KEYS)
|
|
303
|
+
|| state.schema_version !== 1
|
|
304
|
+
|| typeof state.cairnkeep_version !== "string"
|
|
305
|
+
|| state.cairnkeep_version.length === 0
|
|
306
|
+
|| state.cairnkeep_version.length > 128
|
|
307
|
+
|| !GIT_MODES.includes(state.git)
|
|
308
|
+
|| !MEMORY_MODES.includes(state.memory)
|
|
309
|
+
|| !Array.isArray(state.harnesses)
|
|
310
|
+
|| state.harnesses.length === 0
|
|
311
|
+
|| new Set(state.harnesses).size !== state.harnesses.length
|
|
312
|
+
|| state.harnesses.some((name) => !HARNESSES.includes(name))
|
|
313
|
+
|| !state.assets
|
|
314
|
+
|| typeof state.assets !== "object"
|
|
315
|
+
|| Array.isArray(state.assets)
|
|
316
|
+
|| Object.keys(state.assets).length === 0) return incompleteDiagnosis();
|
|
317
|
+
|
|
318
|
+
const recovery = `cairn setup . --git ${state.git} --harness ${state.harnesses.join(",")} --memory ${state.memory} --yes`;
|
|
319
|
+
for (const [path, record] of Object.entries(state.assets)) {
|
|
320
|
+
if (!safeStateAsset(path, record)) return incompleteDiagnosis(recovery);
|
|
321
|
+
const destination = join(project, ...path.split("/"));
|
|
322
|
+
if (!existsSync(destination)) return incompleteDiagnosis(recovery);
|
|
323
|
+
const info = lstatSync(destination);
|
|
324
|
+
if (!info.isFile() || info.isSymbolicLink()) return incompleteDiagnosis(recovery);
|
|
325
|
+
const digest = createHash("sha256").update(readFileSync(destination)).digest("hex");
|
|
326
|
+
if (digest !== record.digest || (process.platform !== "win32" && (info.mode & 0o777) !== record.mode)) return incompleteDiagnosis(recovery);
|
|
327
|
+
}
|
|
328
|
+
const requiredAssets = [...COMMON_SETUP_ASSETS, ...requiredHarnessAssetPaths(state.harnesses, state.memory)];
|
|
329
|
+
if (requiredAssets.some((path) => !Object.hasOwn(state.assets, path))) return incompleteDiagnosis(recovery);
|
|
330
|
+
if (state.memory === "local" && state.harnesses.includes("codex") && !hasCodexMemoryConfig(project)) {
|
|
331
|
+
return incompleteDiagnosis(recovery);
|
|
332
|
+
}
|
|
333
|
+
return Object.freeze({
|
|
334
|
+
schema_version: 1,
|
|
335
|
+
status: state.git === "none" ? "limited" : "complete",
|
|
336
|
+
code: state.git === "none" ? "git-disabled" : "configured",
|
|
337
|
+
recovery: Object.freeze([]),
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
return incompleteDiagnosis();
|
|
341
|
+
}
|
|
342
|
+
}
|