@cairnkeep/cli 2.8.0 → 2.11.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 +68 -1
- package/README.md +82 -27
- package/bin/cairn +6 -153
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/context-packs.md +94 -0
- package/docs/harness-compatibility.md +37 -3
- package/docs/learning/CURRICULUM-MAP.md +16 -5
- package/docs/learning/FEATURE-GUIDE.md +15 -4
- package/docs/learning/README.md +8 -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 +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +10 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +157 -18
- package/docs/privacy-and-data-flow.md +51 -1
- package/docs/storage.md +29 -0
- 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-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +889 -0
- 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 +8 -5
- package/mcp-memory-server/dist/eval-runner.js +2 -0
- package/mcp-memory-server/dist/eval-schema.js +1 -0
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +87 -37
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
- 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 +4 -1
- package/package.json +5 -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/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +1 -0
- 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 +33 -5
- package/scripts/doctor.sh +96 -0
- package/scripts/fix-permissions.mjs +12 -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 +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +44 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +53 -3
- package/scripts/test-package-install.sh +81 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -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 +71 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +95 -7
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +725 -0
- package/templates/env.example.template +11 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { accessSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const TEMPLATE_ROOT = join(ROOT, "templates");
|
|
9
|
+
const POLICY_LIMIT_BYTES = 1024 * 1024;
|
|
10
|
+
const GIT_MODES = Object.freeze(["init", "existing", "none"]);
|
|
11
|
+
const HARNESSES = Object.freeze(["claude", "opencode", "pi", "kimi", "qwen"]);
|
|
12
|
+
const MEMORY_MODES = Object.freeze(["local", "none"]);
|
|
13
|
+
|
|
14
|
+
const COMMON_ASSETS = Object.freeze([
|
|
15
|
+
["env.example.template", ".ai/env.example", 0o644],
|
|
16
|
+
["trajectory-redaction.json.template", ".ai/trajectory-redaction.json", 0o644],
|
|
17
|
+
["capabilities.json.template", ".ai/capabilities.json", 0o600],
|
|
18
|
+
["agentfs-gitignore.template", ".agentfs/.gitignore", 0o644],
|
|
19
|
+
["planning-config.json.template", ".planning/config.json", 0o644],
|
|
20
|
+
["project-brief.md.template", ".planning/PROJECT-BRIEF.md", 0o644],
|
|
21
|
+
["wiki-index.md.template", ".planning/wiki/index.md", 0o644],
|
|
22
|
+
["wiki-policy.md.template", ".planning/wiki/policy.md", 0o644],
|
|
23
|
+
["wiki-contradictions.md.template", ".planning/wiki/CONTRADICTIONS.md", 0o644],
|
|
24
|
+
["wiki-log.md.template", ".planning/wiki/LOG.md", 0o644],
|
|
25
|
+
["alignment-policy.md.template", ".planning/alignment/policy.md", 0o644],
|
|
26
|
+
["alignment-gap-register.yaml.template", ".planning/alignment/gap-register.yaml", 0o644],
|
|
27
|
+
["graph-policy.md.template", ".planning/graphs/policy.md", 0o644],
|
|
28
|
+
["graphs-gitignore.template", ".planning/graphs/.gitignore", 0o644],
|
|
29
|
+
["security-policy.md.template", ".planning/security/policy.md", 0o644],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
class SetupInputError extends Error {
|
|
33
|
+
constructor(kind, message, status = 1) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "SetupInputError";
|
|
36
|
+
this.kind = kind;
|
|
37
|
+
this.status = status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function usage(message) {
|
|
42
|
+
return new SetupInputError("usage", `${message} Usage: cairn setup PATH --git init|existing|none --harness LIST --memory local|none [--policy PATH] --yes [--json]`, 2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function operational(kind, message) {
|
|
46
|
+
return new SetupInputError(kind, message, 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function exactKeys(value, allowed, label) {
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw operational("unsafe-policy", `Invalid setup policy ${label}.`);
|
|
51
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
52
|
+
if (unknown.length) throw operational("unsafe-policy", `Invalid setup policy: unknown ${label} field ${unknown.join(", ")}.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function enumValue(value, allowed, label, optional = false) {
|
|
56
|
+
if (optional && value === undefined) return undefined;
|
|
57
|
+
if (typeof value !== "string" || !allowed.includes(value)) throw operational("unsafe-policy", `Invalid setup policy ${label}; allowed values are ${allowed.join(", ")}.`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function enumArray(value, allowed, label, optional = false) {
|
|
62
|
+
if (optional && value === undefined) return undefined;
|
|
63
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !allowed.includes(item))) {
|
|
64
|
+
throw operational("unsafe-policy", `Invalid setup policy ${label}; allowed values are ${allowed.join(", ")}.`);
|
|
65
|
+
}
|
|
66
|
+
if (new Set(value).size !== value.length) throw operational("unsafe-policy", `Invalid setup policy ${label}; duplicate values are not allowed.`);
|
|
67
|
+
return allowed.filter((item) => value.includes(item));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function deepFreeze(value) {
|
|
71
|
+
if (!value || typeof value !== "object" || ArrayBuffer.isView(value)) return value;
|
|
72
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
73
|
+
return Object.freeze(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePolicy(value) {
|
|
77
|
+
exactKeys(value, ["schema_version", "defaults", "constraints"], "root");
|
|
78
|
+
if (value.schema_version !== 1) throw operational("unsafe-policy", "Invalid setup policy schema version; expected schema_version 1.");
|
|
79
|
+
exactKeys(value.defaults, ["git", "harnesses", "memory"], "defaults");
|
|
80
|
+
exactKeys(value.constraints, ["git", "harnesses", "required_harnesses", "memory"], "constraints");
|
|
81
|
+
const policy = {
|
|
82
|
+
schema_version: 1,
|
|
83
|
+
defaults: {
|
|
84
|
+
...(value.defaults.git !== undefined ? { git: enumValue(value.defaults.git, GIT_MODES, "defaults.git") } : {}),
|
|
85
|
+
...(value.defaults.harnesses !== undefined ? { harnesses: enumArray(value.defaults.harnesses, HARNESSES, "defaults.harnesses") } : {}),
|
|
86
|
+
...(value.defaults.memory !== undefined ? { memory: enumValue(value.defaults.memory, MEMORY_MODES, "defaults.memory") } : {}),
|
|
87
|
+
},
|
|
88
|
+
constraints: {
|
|
89
|
+
...(value.constraints.git !== undefined ? { git: enumArray(value.constraints.git, GIT_MODES, "constraints.git") } : {}),
|
|
90
|
+
...(value.constraints.harnesses !== undefined ? { harnesses: enumArray(value.constraints.harnesses, HARNESSES, "constraints.harnesses") } : {}),
|
|
91
|
+
...(value.constraints.required_harnesses !== undefined ? { required_harnesses: enumArray(value.constraints.required_harnesses, HARNESSES, "constraints.required_harnesses") } : {}),
|
|
92
|
+
...(value.constraints.memory !== undefined ? { memory: enumArray(value.constraints.memory, MEMORY_MODES, "constraints.memory") } : {}),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
if (policy.defaults.git && policy.constraints.git && !policy.constraints.git.includes(policy.defaults.git)) {
|
|
96
|
+
throw operational("unsafe-policy", "Invalid setup policy: defaults.git violates constraints.git.");
|
|
97
|
+
}
|
|
98
|
+
if (policy.defaults.memory && policy.constraints.memory && !policy.constraints.memory.includes(policy.defaults.memory)) {
|
|
99
|
+
throw operational("unsafe-policy", "Invalid setup policy: defaults.memory violates constraints.memory.");
|
|
100
|
+
}
|
|
101
|
+
if (policy.defaults.harnesses && policy.constraints.harnesses
|
|
102
|
+
&& policy.defaults.harnesses.some((name) => !policy.constraints.harnesses.includes(name))) {
|
|
103
|
+
throw operational("unsafe-policy", "Invalid setup policy: defaults.harnesses violates constraints.harnesses.");
|
|
104
|
+
}
|
|
105
|
+
if (policy.constraints.required_harnesses && policy.constraints.harnesses
|
|
106
|
+
&& policy.constraints.required_harnesses.some((name) => !policy.constraints.harnesses.includes(name))) {
|
|
107
|
+
throw operational("unsafe-policy", "Invalid setup policy: required harnesses must be allowed by constraints.harnesses.");
|
|
108
|
+
}
|
|
109
|
+
return deepFreeze(policy);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function readSetupPolicy(path, options = {}) {
|
|
113
|
+
const lstat = options.lstat ?? lstatSync;
|
|
114
|
+
const read = options.readFile ?? readFileSync;
|
|
115
|
+
let info;
|
|
116
|
+
try {
|
|
117
|
+
info = lstat(path);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw operational("unsafe-policy", `Setup policy could not be read: ${error instanceof Error ? error.message : String(error)}.`);
|
|
120
|
+
}
|
|
121
|
+
if (!info.isFile() || info.isSymbolicLink()) throw operational("unsafe-policy", "Setup policy must be a regular file, not a symbolic link or device.");
|
|
122
|
+
if (info.size > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
|
|
123
|
+
if (process.platform !== "win32" && (info.mode & 0o111) !== 0) throw operational("unsafe-policy", "Setup policy must not be executable.");
|
|
124
|
+
let descriptor;
|
|
125
|
+
let bytes;
|
|
126
|
+
try {
|
|
127
|
+
if (options.readFile) bytes = read(path);
|
|
128
|
+
else {
|
|
129
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
130
|
+
const opened = fstatSync(descriptor);
|
|
131
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) throw operational("unsafe-policy", "Setup policy changed during validation.");
|
|
132
|
+
if (opened.size > POLICY_LIMIT_BYTES || (process.platform !== "win32" && (opened.mode & 0o111) !== 0)) {
|
|
133
|
+
throw operational("unsafe-policy", "Setup policy type, size, or executable mode is unsafe.");
|
|
134
|
+
}
|
|
135
|
+
bytes = read(descriptor);
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof SetupInputError) throw error;
|
|
139
|
+
throw operational("unsafe-policy", `Setup policy could not be opened safely: ${error instanceof Error ? error.message : String(error)}.`);
|
|
140
|
+
} finally {
|
|
141
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
142
|
+
}
|
|
143
|
+
if (bytes.byteLength > POLICY_LIMIT_BYTES) throw operational("unsafe-policy", "Setup policy exceeds the size limit.");
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
147
|
+
} catch {
|
|
148
|
+
throw operational("unsafe-policy", "Invalid setup policy JSON syntax.");
|
|
149
|
+
}
|
|
150
|
+
return parsePolicy(parsed);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseHarnesses(raw) {
|
|
154
|
+
const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
155
|
+
if (values.length === 0 || values.some((value) => !HARNESSES.includes(value)) || new Set(values).size !== values.length) {
|
|
156
|
+
throw usage(`Invalid harness choice: ${raw || "missing"}.`);
|
|
157
|
+
}
|
|
158
|
+
return HARNESSES.filter((name) => values.includes(name));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function parseSetupArgs(args, options = {}) {
|
|
162
|
+
const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY);
|
|
163
|
+
const parsed = { target: null, git: null, harnesses: null, memory: null, policyPath: null, confirmed: false, json: false };
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
166
|
+
const arg = args[index];
|
|
167
|
+
if (["--git", "--harness", "--memory", "--policy"].includes(arg)) {
|
|
168
|
+
if (seen.has(arg)) throw usage(`Invalid duplicate ${arg} option.`);
|
|
169
|
+
seen.add(arg);
|
|
170
|
+
const value = args[index + 1];
|
|
171
|
+
if (!value || value.startsWith("--")) throw usage(`Missing value for ${arg}.`);
|
|
172
|
+
index += 1;
|
|
173
|
+
if (arg === "--git") {
|
|
174
|
+
if (!GIT_MODES.includes(value)) throw usage(`Invalid Git mode: ${value}.`);
|
|
175
|
+
parsed.git = value;
|
|
176
|
+
} else if (arg === "--harness") parsed.harnesses = parseHarnesses(value);
|
|
177
|
+
else if (arg === "--memory") {
|
|
178
|
+
if (!MEMORY_MODES.includes(value)) throw usage(`Invalid memory mode: ${value}.`);
|
|
179
|
+
parsed.memory = value;
|
|
180
|
+
} else parsed.policyPath = value;
|
|
181
|
+
} else if (arg === "--yes" || arg === "--json") {
|
|
182
|
+
if (seen.has(arg)) throw usage(`Invalid duplicate ${arg} option.`);
|
|
183
|
+
seen.add(arg);
|
|
184
|
+
if (arg === "--yes") parsed.confirmed = true;
|
|
185
|
+
else parsed.json = true;
|
|
186
|
+
} else if (arg.startsWith("-")) throw usage(`Unknown setup option: ${arg}.`);
|
|
187
|
+
else if (parsed.target !== null) throw usage("Invalid extra target/path positional argument.");
|
|
188
|
+
else parsed.target = arg;
|
|
189
|
+
}
|
|
190
|
+
if (!isTTY) {
|
|
191
|
+
if (!parsed.target) throw usage("Missing setup target path.");
|
|
192
|
+
const canUsePolicyDefaults = Boolean(parsed.policyPath);
|
|
193
|
+
if (!canUsePolicyDefaults && !parsed.git) throw usage("Non-TTY setup requires an explicit Git choice.");
|
|
194
|
+
if (!canUsePolicyDefaults && !parsed.harnesses) throw usage("Non-TTY setup requires an explicit harness choice.");
|
|
195
|
+
if (!canUsePolicyDefaults && !parsed.memory) throw usage("Non-TTY setup requires an explicit memory choice.");
|
|
196
|
+
if (!parsed.confirmed) throw usage("Non-TTY setup requires explicit confirmation with --yes.");
|
|
197
|
+
}
|
|
198
|
+
return deepFreeze(parsed);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pathParts(path) {
|
|
202
|
+
const result = [];
|
|
203
|
+
let cursor = resolve(path);
|
|
204
|
+
const root = parse(cursor).root;
|
|
205
|
+
while (cursor !== root) {
|
|
206
|
+
result.push(cursor);
|
|
207
|
+
cursor = dirname(cursor);
|
|
208
|
+
}
|
|
209
|
+
result.push(root);
|
|
210
|
+
return result.reverse();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function classifySetupTarget(target, options = {}) {
|
|
214
|
+
const lstat = options.lstat ?? lstatSync;
|
|
215
|
+
const exists = options.exists ?? existsSync;
|
|
216
|
+
const readdir = options.readdir ?? readdirSync;
|
|
217
|
+
const realpath = options.realpath ?? realpathSync;
|
|
218
|
+
const access = options.access ?? accessSync;
|
|
219
|
+
const run = options.run ?? spawnSync;
|
|
220
|
+
const absolute = resolve(target);
|
|
221
|
+
let nearest = parse(absolute).root;
|
|
222
|
+
for (const candidate of pathParts(absolute)) {
|
|
223
|
+
if (!exists(candidate)) break;
|
|
224
|
+
const info = lstat(candidate);
|
|
225
|
+
if (info.isSymbolicLink()) throw operational("unsafe-target", "Setup target or an ancestor is a symbolic link and is unsafe.");
|
|
226
|
+
if (!info.isDirectory()) throw operational("unsafe-target", "Setup target ancestor is not a regular directory.");
|
|
227
|
+
nearest = candidate;
|
|
228
|
+
}
|
|
229
|
+
const targetExists = exists(absolute);
|
|
230
|
+
const targetState = targetExists ? (readdir(absolute).length === 0 ? "empty" : "non-empty") : "missing";
|
|
231
|
+
try {
|
|
232
|
+
access(nearest, constants.R_OK | constants.W_OK | constants.X_OK);
|
|
233
|
+
} catch {
|
|
234
|
+
throw operational("target-denied", "Setup target cannot be created or inspected because access to its parent is denied.");
|
|
235
|
+
}
|
|
236
|
+
const canonicalNearest = realpath(nearest);
|
|
237
|
+
if (canonicalNearest !== nearest) throw operational("unsafe-target", "Setup target resolves through an unsafe ancestor.");
|
|
238
|
+
|
|
239
|
+
const runOptions = { encoding: "utf8", shell: false };
|
|
240
|
+
const version = run("git", ["--version"], runOptions);
|
|
241
|
+
if (version.error?.code === "ENOENT") {
|
|
242
|
+
return Object.freeze({ target: absolute, targetState, gitExecutable: "missing", repository: "unknown" });
|
|
243
|
+
}
|
|
244
|
+
if (version.error || version.status !== 0) {
|
|
245
|
+
throw operational("git-probe", "Git availability could not be determined safely.");
|
|
246
|
+
}
|
|
247
|
+
const worktree = run("git", ["-C", targetExists ? absolute : nearest, "rev-parse", "--is-inside-work-tree"], runOptions);
|
|
248
|
+
let repository = "none";
|
|
249
|
+
if (worktree.error?.code === "ENOENT") return Object.freeze({ target: absolute, targetState, gitExecutable: "missing", repository: "unknown" });
|
|
250
|
+
if (worktree.status === 0) repository = worktree.stdout.trim() === "true" ? "work-tree" : "bare";
|
|
251
|
+
return Object.freeze({ target: absolute, targetState, gitExecutable: "available", repository });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function requiredChoice(value, name) {
|
|
255
|
+
if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) throw usage(`Missing required ${name} choice.`);
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function resolveSetupChoices({ parsed, preflight, policy = null, interactive = null }) {
|
|
260
|
+
const git = requiredChoice(parsed.git ?? policy?.defaults?.git ?? interactive?.git, "Git");
|
|
261
|
+
const harnesses = requiredChoice(parsed.harnesses ?? policy?.defaults?.harnesses ?? interactive?.harnesses, "harness");
|
|
262
|
+
const memory = requiredChoice(parsed.memory ?? policy?.defaults?.memory ?? interactive?.memory, "memory");
|
|
263
|
+
const confirmed = parsed.confirmed || interactive?.confirmed === true;
|
|
264
|
+
if (!confirmed) throw usage("Missing setup confirmation; use --yes or confirm interactively.");
|
|
265
|
+
if (!GIT_MODES.includes(git)) throw usage(`Invalid Git choice: ${git}.`);
|
|
266
|
+
if (!Array.isArray(harnesses) || harnesses.length === 0 || harnesses.some((name) => !HARNESSES.includes(name))) throw usage("Invalid harness choice.");
|
|
267
|
+
if (!MEMORY_MODES.includes(memory)) throw usage(`Invalid memory choice: ${memory}.`);
|
|
268
|
+
const normalizedHarnesses = HARNESSES.filter((name) => harnesses.includes(name));
|
|
269
|
+
if (git !== "none" && preflight.gitExecutable === "missing") throw operational("missing-git", `Git executable is required for --git ${git}.`);
|
|
270
|
+
if (git === "existing" && preflight.repository !== "work-tree") throw operational("non-repository", "--git existing requires the target to be inside a Git work tree.");
|
|
271
|
+
|
|
272
|
+
const constraints = policy?.constraints ?? {};
|
|
273
|
+
if (constraints.git && !constraints.git.includes(git)) throw operational("policy-constraint", `Git choice ${git} violates policy constraints.`);
|
|
274
|
+
if (constraints.memory && !constraints.memory.includes(memory)) throw operational("policy-constraint", `Memory choice ${memory} violates policy constraints.`);
|
|
275
|
+
if (constraints.harnesses && normalizedHarnesses.some((name) => !constraints.harnesses.includes(name))) {
|
|
276
|
+
throw operational("policy-constraint", "Harness choice violates policy constraints.");
|
|
277
|
+
}
|
|
278
|
+
const missingRequired = (constraints.required_harnesses ?? []).filter((name) => !normalizedHarnesses.includes(name));
|
|
279
|
+
if (missingRequired.length) throw operational("policy-constraint", `Harness constraint requires ${missingRequired.join(", ")}.`);
|
|
280
|
+
return deepFreeze({ git, harnesses: normalizedHarnesses, memory, confirmed: true, limited: git === "none" });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function templateAsset(template, path, mode, harness) {
|
|
284
|
+
const asset = { path, bytes: readFileSync(join(TEMPLATE_ROOT, template)), mode, template, ...(harness ? { harness } : {}) };
|
|
285
|
+
return Object.freeze(asset);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function buildSetupPlan({ target, preflight, choices }) {
|
|
289
|
+
const absolute = resolve(target);
|
|
290
|
+
if (preflight.target && resolve(preflight.target) !== absolute) throw operational("unsafe-target", "Setup target changed after preflight.");
|
|
291
|
+
if (!choices?.confirmed) throw usage("Missing setup confirmation before mutation planning.");
|
|
292
|
+
if (choices.git === "existing" && preflight.repository !== "work-tree") throw operational("non-repository", "Existing Git mode requires a work tree.");
|
|
293
|
+
if (choices.git !== "none" && preflight.gitExecutable === "missing") throw operational("missing-git", "The selected Git mode requires the Git executable.");
|
|
294
|
+
const harnesses = HARNESSES.filter((name) => choices.harnesses.includes(name));
|
|
295
|
+
const assets = [
|
|
296
|
+
...harnesses.map((harness) => templateAsset(`start-${harness}.sh.template`, `.ai/start-${harness}.sh`, 0o755, harness)),
|
|
297
|
+
...COMMON_ASSETS.map(([template, path, mode]) => templateAsset(template, path, mode)),
|
|
298
|
+
];
|
|
299
|
+
const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
|
|
300
|
+
return deepFreeze({
|
|
301
|
+
target: absolute,
|
|
302
|
+
targetState: preflight.targetState,
|
|
303
|
+
version,
|
|
304
|
+
git: choices.git,
|
|
305
|
+
memory: choices.memory,
|
|
306
|
+
harnesses,
|
|
307
|
+
limited: choices.git === "none",
|
|
308
|
+
assets: Object.freeze(assets),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
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
|
+
|
|
20
|
+
export const SETUP_STATE_SCHEMA_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
const HARNESSES = Object.freeze(["claude", "opencode", "pi", "kimi", "qwen"]);
|
|
23
|
+
const GIT_MODES = Object.freeze(["init", "existing", "none"]);
|
|
24
|
+
const MEMORY_MODES = Object.freeze(["local", "none"]);
|
|
25
|
+
const STATE_KEYS = Object.freeze(["schema_version", "cairnkeep_version", "git", "memory", "harnesses", "assets"]);
|
|
26
|
+
const ASSET_KEYS = Object.freeze(["digest", "mode", "template"]);
|
|
27
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
28
|
+
const RELATIVE_PATH_PATTERN = /^\.(?:ai|planning|agentfs)\/(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/;
|
|
29
|
+
const TEMPLATE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
30
|
+
|
|
31
|
+
export function hashSetupAsset(bytes) {
|
|
32
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function exactKeys(value, expected, label) {
|
|
36
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid setup ${label}.`);
|
|
37
|
+
const keys = Object.keys(value).sort();
|
|
38
|
+
if (keys.join(",") !== [...expected].sort().join(",")) throw new Error(`Invalid setup ${label}: unknown or missing fields.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeHarnesses(harnesses) {
|
|
42
|
+
if (!Array.isArray(harnesses) || harnesses.length === 0 || harnesses.some((value) => typeof value !== "string" || !HARNESSES.includes(value))) {
|
|
43
|
+
throw new Error("Invalid setup state harnesses.");
|
|
44
|
+
}
|
|
45
|
+
if (new Set(harnesses).size !== harnesses.length) throw new Error("Invalid setup state duplicate harnesses.");
|
|
46
|
+
return HARNESSES.filter((name) => harnesses.includes(name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeRelativePath(path) {
|
|
50
|
+
return typeof path === "string"
|
|
51
|
+
&& path.length <= 512
|
|
52
|
+
&& !isAbsolute(path)
|
|
53
|
+
&& !path.includes("\\")
|
|
54
|
+
&& RELATIVE_PATH_PATTERN.test(path)
|
|
55
|
+
&& !path.split("/").includes("..");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeState(state) {
|
|
59
|
+
exactKeys(state, STATE_KEYS, "state");
|
|
60
|
+
if (state.schema_version !== SETUP_STATE_SCHEMA_VERSION) throw new Error("Invalid setup state schema version.");
|
|
61
|
+
if (typeof state.cairnkeep_version !== "string" || state.cairnkeep_version.length === 0 || state.cairnkeep_version.length > 128) {
|
|
62
|
+
throw new Error("Invalid setup state version.");
|
|
63
|
+
}
|
|
64
|
+
if (!GIT_MODES.includes(state.git) || !MEMORY_MODES.includes(state.memory)) throw new Error("Invalid setup state mode.");
|
|
65
|
+
const harnesses = normalizeHarnesses(state.harnesses);
|
|
66
|
+
if (!state.assets || typeof state.assets !== "object" || Array.isArray(state.assets)) throw new Error("Invalid setup state assets.");
|
|
67
|
+
const assets = {};
|
|
68
|
+
for (const path of Object.keys(state.assets).sort()) {
|
|
69
|
+
if (!safeRelativePath(path) || path === ".ai/cairnkeep.json") throw new Error("Invalid or unsafe setup state asset path.");
|
|
70
|
+
const record = state.assets[path];
|
|
71
|
+
exactKeys(record, ASSET_KEYS, "state asset record");
|
|
72
|
+
if (!DIGEST_PATTERN.test(record.digest)
|
|
73
|
+
|| !Number.isInteger(record.mode) || record.mode < 0 || record.mode > 0o777
|
|
74
|
+
|| typeof record.template !== "string" || !TEMPLATE_PATTERN.test(record.template)) {
|
|
75
|
+
throw new Error("Invalid setup state asset record.");
|
|
76
|
+
}
|
|
77
|
+
assets[path] = { digest: record.digest, mode: record.mode, template: record.template };
|
|
78
|
+
}
|
|
79
|
+
return { schema_version: SETUP_STATE_SCHEMA_VERSION, cairnkeep_version: state.cairnkeep_version, git: state.git, memory: state.memory, harnesses, assets };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertContained(target, path) {
|
|
83
|
+
const absolute = resolve(target, ...path.split("/"));
|
|
84
|
+
const rel = relative(target, absolute);
|
|
85
|
+
if (!safeRelativePath(path) || rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Unsafe setup asset path: ${path}.`);
|
|
86
|
+
return absolute;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertExistingAncestors(target, destination) {
|
|
90
|
+
const targetInfo = lstatSync(target);
|
|
91
|
+
if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) throw new Error("Setup target must be a regular directory.");
|
|
92
|
+
const segments = relative(target, dirname(destination)).split(/[\\/]/).filter(Boolean);
|
|
93
|
+
let cursor = target;
|
|
94
|
+
for (const segment of segments) {
|
|
95
|
+
cursor = join(cursor, segment);
|
|
96
|
+
if (!existsSync(cursor)) break;
|
|
97
|
+
const info = lstatSync(cursor);
|
|
98
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup asset ancestor: ${relative(target, cursor)}.`);
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(destination)) {
|
|
101
|
+
const destinationInfo = lstatSync(destination);
|
|
102
|
+
if (!destinationInfo.isFile() || destinationInfo.isSymbolicLink()) throw new Error(`Unsafe setup asset destination: ${relative(target, destination)}.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateAsset(asset, target, selectedHarnesses) {
|
|
107
|
+
if (!asset || typeof asset !== "object" || Array.isArray(asset)) throw new Error("Invalid setup asset.");
|
|
108
|
+
if (asset.harness && !selectedHarnesses.includes(asset.harness)) return null;
|
|
109
|
+
if (asset.harness && !HARNESSES.includes(asset.harness)) throw new Error("Invalid setup asset harness.");
|
|
110
|
+
if (!safeRelativePath(asset.path) || asset.path === ".ai/cairnkeep.json") throw new Error(`Unsafe setup asset path: ${asset.path}.`);
|
|
111
|
+
if (!Buffer.isBuffer(asset.bytes) && !(asset.bytes instanceof Uint8Array)) throw new Error(`Invalid setup asset bytes: ${asset.path}.`);
|
|
112
|
+
if (!Number.isInteger(asset.mode) || ![0o600, 0o644, 0o755].includes(asset.mode)) throw new Error(`Invalid setup asset mode: ${asset.path}.`);
|
|
113
|
+
if (typeof asset.template !== "string" || !TEMPLATE_PATTERN.test(asset.template)) throw new Error(`Invalid setup asset template: ${asset.path}.`);
|
|
114
|
+
const destination = assertContained(target, asset.path);
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
path: asset.path,
|
|
117
|
+
destination,
|
|
118
|
+
bytes: Buffer.from(asset.bytes),
|
|
119
|
+
digest: hashSetupAsset(asset.bytes),
|
|
120
|
+
mode: asset.mode,
|
|
121
|
+
template: asset.template,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validateTargetAncestors(target) {
|
|
126
|
+
const segments = resolve(target).split(/[\\/]/).filter(Boolean);
|
|
127
|
+
let cursor = resolve(target).startsWith("/") ? "/" : `${segments.shift()}\\`;
|
|
128
|
+
for (const segment of segments) {
|
|
129
|
+
cursor = join(cursor, segment);
|
|
130
|
+
if (!existsSync(cursor)) return;
|
|
131
|
+
const info = lstatSync(cursor);
|
|
132
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup target ancestor: ${cursor}.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createTargetChain(target) {
|
|
137
|
+
const absolute = resolve(target);
|
|
138
|
+
const segments = absolute.split(/[\\/]/).filter(Boolean);
|
|
139
|
+
let cursor = absolute.startsWith("/") ? "/" : `${segments.shift()}\\`;
|
|
140
|
+
for (const segment of segments) {
|
|
141
|
+
cursor = join(cursor, segment);
|
|
142
|
+
if (existsSync(cursor)) {
|
|
143
|
+
const info = lstatSync(cursor);
|
|
144
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup target ancestor: ${cursor}.`);
|
|
145
|
+
} else mkdirSync(cursor, { mode: cursor === absolute ? 0o755 : 0o700 });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function safeMkdirChain(target, directory) {
|
|
150
|
+
const segments = relative(target, directory).split(/[\\/]/).filter(Boolean);
|
|
151
|
+
let cursor = target;
|
|
152
|
+
for (const segment of segments) {
|
|
153
|
+
cursor = join(cursor, segment);
|
|
154
|
+
if (existsSync(cursor)) {
|
|
155
|
+
const info = lstatSync(cursor);
|
|
156
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe setup directory: ${relative(target, cursor)}.`);
|
|
157
|
+
} else mkdirSync(cursor, { mode: 0o700 });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function defaultAtomicReplace(source, destination) {
|
|
162
|
+
if (process.platform !== "win32") {
|
|
163
|
+
await rename(source, destination);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let lastError = "Windows atomic setup replacement failed.";
|
|
167
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
168
|
+
if (!existsSync(destination)) {
|
|
169
|
+
try {
|
|
170
|
+
await rename(source, destination);
|
|
171
|
+
return;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
174
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25 * (attempt + 1)));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const backup = `${destination}.replace-backup-${randomUUID()}`;
|
|
179
|
+
try {
|
|
180
|
+
const result = spawnSync("powershell.exe", [
|
|
181
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
|
|
182
|
+
"[IO.File]::Replace($env:CK_INTERNAL_ATOMIC_SOURCE,$env:CK_INTERNAL_ATOMIC_DESTINATION,$env:CK_INTERNAL_ATOMIC_BACKUP,$true)",
|
|
183
|
+
], {
|
|
184
|
+
encoding: "utf8",
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
shell: false,
|
|
187
|
+
env: {
|
|
188
|
+
...process.env,
|
|
189
|
+
CK_INTERNAL_ATOMIC_SOURCE: source,
|
|
190
|
+
CK_INTERNAL_ATOMIC_DESTINATION: destination,
|
|
191
|
+
CK_INTERNAL_ATOMIC_BACKUP: backup,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
if (result.status === 0) return;
|
|
195
|
+
lastError = result.stderr.trim() || result.stdout.trim() || lastError;
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(backup, { force: true });
|
|
198
|
+
}
|
|
199
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25 * (attempt + 1)));
|
|
200
|
+
}
|
|
201
|
+
throw new Error(lastError);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hardenPrivatePath(path) {
|
|
205
|
+
if (process.platform !== "win32") {
|
|
206
|
+
chmodSync(path, lstatSync(path).isDirectory() ? 0o700 : 0o600);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const identity = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true, shell: false });
|
|
210
|
+
const sid = identity.stdout.match(/"(S-1-[0-9-]+)"/i)?.[1];
|
|
211
|
+
if (identity.status !== 0 || !sid) throw new Error("Unable to resolve the current Windows security identity.");
|
|
212
|
+
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)"], {
|
|
213
|
+
encoding: "utf8",
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
shell: false,
|
|
216
|
+
});
|
|
217
|
+
if (result.status !== 0) throw new Error("Unable to restrict private setup state.");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function atomicWrite(path, bytes, mode, atomicReplace = defaultAtomicReplace, privateState = false) {
|
|
221
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
222
|
+
let handle;
|
|
223
|
+
try {
|
|
224
|
+
handle = await open(temporary, "wx", mode);
|
|
225
|
+
await handle.writeFile(bytes);
|
|
226
|
+
await handle.sync();
|
|
227
|
+
await handle.close();
|
|
228
|
+
handle = undefined;
|
|
229
|
+
chmodSync(temporary, mode);
|
|
230
|
+
if (privateState) hardenPrivatePath(temporary);
|
|
231
|
+
await atomicReplace(temporary, path);
|
|
232
|
+
chmodSync(path, mode);
|
|
233
|
+
if (privateState) hardenPrivatePath(path);
|
|
234
|
+
} finally {
|
|
235
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
236
|
+
rmSync(temporary, { force: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readPriorState(statePath) {
|
|
241
|
+
if (!existsSync(statePath)) return null;
|
|
242
|
+
const info = lstatSync(statePath);
|
|
243
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("Existing setup state is unsafe.");
|
|
244
|
+
let descriptor;
|
|
245
|
+
try {
|
|
246
|
+
descriptor = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
247
|
+
const opened = fstatSync(descriptor);
|
|
248
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > 1024 * 1024) {
|
|
249
|
+
throw new Error("Existing setup state changed during validation.");
|
|
250
|
+
}
|
|
251
|
+
return normalizeState(JSON.parse(readFileSync(descriptor, "utf8")));
|
|
252
|
+
} finally {
|
|
253
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function writeSetupStateAtomic(path, state, options = {}) {
|
|
258
|
+
const normalized = normalizeState(state);
|
|
259
|
+
const directory = dirname(path);
|
|
260
|
+
const target = dirname(directory);
|
|
261
|
+
if (basename(directory) !== ".ai" || basename(path) !== "cairnkeep.json") throw new Error("Setup state path is unsafe.");
|
|
262
|
+
if (!existsSync(target)) throw new Error("Setup state target does not exist.");
|
|
263
|
+
assertExistingAncestors(target, path);
|
|
264
|
+
safeMkdirChain(target, directory);
|
|
265
|
+
const bytes = Buffer.from(`${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
266
|
+
if (existsSync(path) && readFileSync(path).equals(bytes) && (statSync(path).mode & 0o777) === 0o600) return normalized;
|
|
267
|
+
await atomicWrite(path, bytes, 0o600, options.atomicReplace ?? defaultAtomicReplace, true);
|
|
268
|
+
return normalized;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function reconcileSetupPlan(plan, options = {}) {
|
|
272
|
+
if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("Invalid setup plan.");
|
|
273
|
+
const target = resolve(plan.target);
|
|
274
|
+
validateTargetAncestors(target);
|
|
275
|
+
if (typeof plan.version !== "string" || !GIT_MODES.includes(plan.git) || !MEMORY_MODES.includes(plan.memory)) throw new Error("Invalid setup plan modes.");
|
|
276
|
+
const harnesses = normalizeHarnesses(plan.harnesses);
|
|
277
|
+
if (!Array.isArray(plan.assets)) throw new Error("Invalid setup plan assets.");
|
|
278
|
+
const selected = plan.assets.map((asset) => validateAsset(asset, target, harnesses)).filter(Boolean);
|
|
279
|
+
const paths = selected.map(({ path }) => path);
|
|
280
|
+
if (new Set(paths).size !== paths.length) throw new Error("Setup plan contains duplicate asset paths.");
|
|
281
|
+
createTargetChain(target);
|
|
282
|
+
const targetInfo = lstatSync(target);
|
|
283
|
+
if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) throw new Error("Setup target is unsafe.");
|
|
284
|
+
for (const asset of selected) assertExistingAncestors(target, asset.destination);
|
|
285
|
+
const statePath = join(target, ".ai", "cairnkeep.json");
|
|
286
|
+
assertExistingAncestors(target, statePath);
|
|
287
|
+
const previousState = options.previousState === undefined ? readPriorState(statePath) : options.previousState === null ? null : normalizeState(options.previousState);
|
|
288
|
+
|
|
289
|
+
const decisions = selected.map((asset) => {
|
|
290
|
+
if (!existsSync(asset.destination)) return { ...asset, status: "created" };
|
|
291
|
+
const currentInfo = lstatSync(asset.destination);
|
|
292
|
+
if (!currentInfo.isFile() || currentInfo.isSymbolicLink()) throw new Error(`Unsafe setup asset destination: ${asset.path}.`);
|
|
293
|
+
const currentDigest = hashSetupAsset(readFileSync(asset.destination));
|
|
294
|
+
const currentMode = currentInfo.mode & 0o777;
|
|
295
|
+
if (currentDigest === asset.digest && currentMode === asset.mode) return { ...asset, status: "unchanged" };
|
|
296
|
+
const prior = previousState?.assets?.[asset.path];
|
|
297
|
+
if (prior && prior.digest === currentDigest && prior.mode === currentMode) return { ...asset, status: "updated" };
|
|
298
|
+
return { ...asset, status: "skipped" };
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
for (const decision of decisions) {
|
|
302
|
+
if (decision.status !== "created" && decision.status !== "updated") continue;
|
|
303
|
+
safeMkdirChain(target, dirname(decision.destination));
|
|
304
|
+
await atomicWrite(decision.destination, decision.bytes, decision.mode, options.atomicReplace ?? defaultAtomicReplace, decision.mode === 0o600);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const assets = {};
|
|
308
|
+
for (const decision of decisions) {
|
|
309
|
+
if (decision.status === "skipped") {
|
|
310
|
+
const prior = previousState?.assets?.[decision.path];
|
|
311
|
+
if (prior) assets[decision.path] = prior;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
assets[decision.path] = { digest: decision.digest, mode: decision.mode, template: decision.template };
|
|
315
|
+
}
|
|
316
|
+
const state = normalizeState({
|
|
317
|
+
schema_version: SETUP_STATE_SCHEMA_VERSION,
|
|
318
|
+
cairnkeep_version: plan.version,
|
|
319
|
+
git: plan.git,
|
|
320
|
+
memory: plan.memory,
|
|
321
|
+
harnesses,
|
|
322
|
+
assets,
|
|
323
|
+
});
|
|
324
|
+
await writeSetupStateAtomic(statePath, state, { atomicReplace: options.atomicReplace ?? defaultAtomicReplace });
|
|
325
|
+
const counts = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
|
|
326
|
+
const changes = decisions.map(({ path, status }) => {
|
|
327
|
+
counts[status] += 1;
|
|
328
|
+
return Object.freeze({ path, status });
|
|
329
|
+
});
|
|
330
|
+
return Object.freeze({ counts: Object.freeze(counts), changes: Object.freeze(changes), state: Object.freeze(state) });
|
|
331
|
+
}
|