@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,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const EXPECTED_RED_EXIT = 86;
|
|
19
|
+
const RED_MARKER = "PHASE26_RED:SETUP_CORE_MISSING";
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const setupCorePath = join(here, "setup-core.mjs");
|
|
22
|
+
const setupPath = join(here, "setup.mjs");
|
|
23
|
+
const MANAGED_PATHS = [".ai", ".planning", ".agentfs"];
|
|
24
|
+
|
|
25
|
+
function assertNoManagedPaths(target, label) {
|
|
26
|
+
for (const relative of MANAGED_PATHS) {
|
|
27
|
+
assert.equal(existsSync(join(target, relative)), false, `${label} created ${relative}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertUsageError(fn, field) {
|
|
32
|
+
assert.throws(fn, (error) => {
|
|
33
|
+
assert.equal(error?.kind, "usage");
|
|
34
|
+
assert.equal(error?.status, 2);
|
|
35
|
+
assert.match(String(error?.message), new RegExp(field, "i"));
|
|
36
|
+
assert.match(String(error?.message), /usage|requires|invalid|missing/i);
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateFixtures() {
|
|
42
|
+
const sandbox = mkdtempSync(join(tmpdir(), "cairn-setup-preflight-fixture-"));
|
|
43
|
+
try {
|
|
44
|
+
const missing = join(sandbox, "missing");
|
|
45
|
+
const empty = join(sandbox, "empty");
|
|
46
|
+
const nonempty = join(sandbox, "nonempty");
|
|
47
|
+
const linked = join(sandbox, "linked");
|
|
48
|
+
mkdirSync(empty);
|
|
49
|
+
mkdirSync(nonempty);
|
|
50
|
+
writeFileSync(join(nonempty, "operator.txt"), "preserve\n");
|
|
51
|
+
symlinkSync(nonempty, linked, "dir");
|
|
52
|
+
assert.equal(existsSync(missing), false);
|
|
53
|
+
assert.deepEqual(lstatSync(empty).isDirectory(), true);
|
|
54
|
+
assert.deepEqual(lstatSync(nonempty).isDirectory(), true);
|
|
55
|
+
assert.deepEqual(lstatSync(linked).isSymbolicLink(), true);
|
|
56
|
+
assertNoManagedPaths(sandbox, "fixture self-validation");
|
|
57
|
+
|
|
58
|
+
const invocations = [];
|
|
59
|
+
const run = (executable, args, options) => {
|
|
60
|
+
invocations.push({ executable, args: [...args], options: { ...options } });
|
|
61
|
+
return { status: 0, stdout: "git version fixture\n", stderr: "" };
|
|
62
|
+
};
|
|
63
|
+
run("git", ["--version"], { encoding: "utf8", shell: false });
|
|
64
|
+
assert.deepEqual(invocations, [{
|
|
65
|
+
executable: "git",
|
|
66
|
+
args: ["--version"],
|
|
67
|
+
options: { encoding: "utf8", shell: false },
|
|
68
|
+
}]);
|
|
69
|
+
return { sandbox, missing, empty, nonempty, linked };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadSetupCore() {
|
|
77
|
+
return import(pathToFileURL(setupCorePath).href);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function loadSetup() {
|
|
81
|
+
return import(pathToFileURL(setupPath).href);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertExports(core) {
|
|
85
|
+
for (const name of [
|
|
86
|
+
"parseSetupArgs",
|
|
87
|
+
"readSetupPolicy",
|
|
88
|
+
"classifySetupTarget",
|
|
89
|
+
"resolveSetupChoices",
|
|
90
|
+
"buildSetupPlan",
|
|
91
|
+
]) assert.equal(typeof core[name], "function", `setup-core must export ${name}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function completeArgs(target) {
|
|
95
|
+
return [target, "--git", "init", "--harness", "claude,pi", "--memory", "local", "--yes"];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function testChoiceContract(core, fixture) {
|
|
99
|
+
const { parseSetupArgs, resolveSetupChoices } = core;
|
|
100
|
+
const complete = parseSetupArgs(completeArgs(fixture.missing), { isTTY: false });
|
|
101
|
+
assert.equal(complete.target, fixture.missing);
|
|
102
|
+
assert.equal(complete.git, "init");
|
|
103
|
+
assert.deepEqual(complete.harnesses, ["claude", "pi"]);
|
|
104
|
+
assert.equal(complete.memory, "local");
|
|
105
|
+
assert.equal(complete.confirmed, true);
|
|
106
|
+
|
|
107
|
+
const syntaxCases = [
|
|
108
|
+
{ args: [fixture.empty, "--unknown"], field: "unknown" },
|
|
109
|
+
{ args: [fixture.empty, "--git"], field: "git" },
|
|
110
|
+
{ args: [fixture.empty, "--git", "sometimes"], field: "git" },
|
|
111
|
+
{ args: [fixture.empty, "--harness", "claude,unknown"], field: "harness" },
|
|
112
|
+
{ args: [fixture.empty, "--memory", "remote"], field: "memory" },
|
|
113
|
+
{ args: [fixture.empty, fixture.nonempty], field: "target|path|positional" },
|
|
114
|
+
];
|
|
115
|
+
for (const { args, field } of syntaxCases) {
|
|
116
|
+
assertUsageError(() => parseSetupArgs(args, { isTTY: false }), field);
|
|
117
|
+
assertNoManagedPaths(fixture.sandbox, `invalid syntax ${args.join(" ")}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const incompleteCases = [
|
|
121
|
+
{ args: [], field: "target" },
|
|
122
|
+
{ args: [fixture.empty, "--harness", "claude", "--memory", "local", "--yes"], field: "git" },
|
|
123
|
+
{ args: [fixture.empty, "--git", "init", "--memory", "local", "--yes"], field: "harness" },
|
|
124
|
+
{ args: [fixture.empty, "--git", "init", "--harness", "claude", "--yes"], field: "memory" },
|
|
125
|
+
{ args: [fixture.empty, "--git", "init", "--harness", "claude", "--memory", "local"], field: "confirm|yes" },
|
|
126
|
+
{ args: [fixture.empty, "--yes"], field: "git|harness|memory" },
|
|
127
|
+
];
|
|
128
|
+
for (const { args, field } of incompleteCases) {
|
|
129
|
+
assertUsageError(() => parseSetupArgs(args, { isTTY: false }), field);
|
|
130
|
+
assertNoManagedPaths(fixture.sandbox, `incomplete non-TTY choices ${args.join(" ")}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const resolved = resolveSetupChoices({
|
|
134
|
+
parsed: complete,
|
|
135
|
+
preflight: { targetState: "missing", gitExecutable: "available", repository: "none" },
|
|
136
|
+
policy: null,
|
|
137
|
+
interactive: null,
|
|
138
|
+
});
|
|
139
|
+
assert.deepEqual(resolved.harnesses, ["claude", "pi"]);
|
|
140
|
+
assert.equal(resolved.git, "init");
|
|
141
|
+
assert.equal(resolved.memory, "local");
|
|
142
|
+
assert.equal(resolved.confirmed, true);
|
|
143
|
+
assert.equal(Object.isFrozen(resolved), true);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scriptedGit(kind, calls) {
|
|
147
|
+
return (executable, args, options) => {
|
|
148
|
+
calls.push({ executable, args: [...args], options: { ...options } });
|
|
149
|
+
assert.equal(executable, "git");
|
|
150
|
+
assert.equal(options?.shell, false);
|
|
151
|
+
if (kind === "missing") return { status: null, stdout: "", stderr: "", error: { code: "ENOENT" } };
|
|
152
|
+
if (args.includes("--version")) return { status: 0, stdout: "git version fixture\n", stderr: "" };
|
|
153
|
+
if (kind === "bare") return { status: 0, stdout: "false\n", stderr: "" };
|
|
154
|
+
if (kind === "work-tree") return { status: 0, stdout: "true\n", stderr: "" };
|
|
155
|
+
return { status: 128, stdout: "", stderr: "not a repository" };
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function testPreflightContract(core, fixture) {
|
|
160
|
+
const { classifySetupTarget, buildSetupPlan } = core;
|
|
161
|
+
const cases = [
|
|
162
|
+
[fixture.missing, "missing", "none"],
|
|
163
|
+
[fixture.empty, "empty", "none"],
|
|
164
|
+
[fixture.nonempty, "non-empty", "none"],
|
|
165
|
+
[fixture.empty, "empty", "work-tree"],
|
|
166
|
+
[fixture.nonempty, "non-empty", "bare"],
|
|
167
|
+
[fixture.nonempty, "non-empty", "missing"],
|
|
168
|
+
];
|
|
169
|
+
for (const [target, targetState, gitKind] of cases) {
|
|
170
|
+
const calls = [];
|
|
171
|
+
const result = classifySetupTarget(target, { run: scriptedGit(gitKind, calls) });
|
|
172
|
+
assert.equal(result.targetState, targetState);
|
|
173
|
+
assert.equal(result.gitExecutable, gitKind === "missing" ? "missing" : "available");
|
|
174
|
+
assert.equal(result.repository, gitKind === "work-tree" ? "work-tree" : gitKind === "bare" ? "bare" : gitKind === "missing" ? "unknown" : "none");
|
|
175
|
+
assert.equal(Object.isFrozen(result), true);
|
|
176
|
+
assert.ok(calls.every(({ options }) => options.shell === false));
|
|
177
|
+
assertNoManagedPaths(fixture.sandbox, `preflight ${targetState}/${gitKind}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
assert.throws(
|
|
181
|
+
() => classifySetupTarget(fixture.linked, { run: scriptedGit("none", []) }),
|
|
182
|
+
(error) => error?.kind === "unsafe-target" && error?.status !== 2,
|
|
183
|
+
);
|
|
184
|
+
assertNoManagedPaths(fixture.sandbox, "unsafe symlink target");
|
|
185
|
+
|
|
186
|
+
const plan = buildSetupPlan({
|
|
187
|
+
target: fixture.missing,
|
|
188
|
+
preflight: { targetState: "missing", gitExecutable: "available", repository: "none" },
|
|
189
|
+
choices: Object.freeze({ git: "init", harnesses: ["claude", "pi"], memory: "local", confirmed: true }),
|
|
190
|
+
});
|
|
191
|
+
assert.equal(plan.target, resolve(fixture.missing));
|
|
192
|
+
assert.deepEqual(plan.harnesses, ["claude", "pi"]);
|
|
193
|
+
assert.equal(plan.git, "init");
|
|
194
|
+
assert.equal(plan.memory, "local");
|
|
195
|
+
assert.ok(Array.isArray(plan.assets));
|
|
196
|
+
assert.equal(Object.isFrozen(plan), true);
|
|
197
|
+
assertNoManagedPaths(fixture.sandbox, "mutation planning");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function captureStream() {
|
|
201
|
+
let output = "";
|
|
202
|
+
return {
|
|
203
|
+
stream: { write(value) { output += String(value); return true; } },
|
|
204
|
+
read() { return output; },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function runInteractive(setup, args, responses) {
|
|
209
|
+
const prompts = [];
|
|
210
|
+
const remaining = [...responses];
|
|
211
|
+
const output = captureStream();
|
|
212
|
+
const error = captureStream();
|
|
213
|
+
const status = await setup.runSetup(args, {
|
|
214
|
+
isTTY: true,
|
|
215
|
+
input: { isTTY: false },
|
|
216
|
+
output: output.stream,
|
|
217
|
+
error: error.stream,
|
|
218
|
+
question: async (prompt) => {
|
|
219
|
+
prompts.push(prompt);
|
|
220
|
+
assert.ok(remaining.length > 0, `unexpected interactive prompt: ${prompt}`);
|
|
221
|
+
return remaining.shift();
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
assert.deepEqual(remaining, [], "not all scripted interactive answers were consumed");
|
|
225
|
+
return { status, prompts, output: output.read(), error: error.read() };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function testInteractiveGitRecommendation(setup, fixture) {
|
|
229
|
+
const accepted = await runInteractive(setup, [], [fixture.missing, "init", "pi", "local", "yes"]);
|
|
230
|
+
assert.equal(accepted.status, 0);
|
|
231
|
+
assert.equal(accepted.error, "");
|
|
232
|
+
assert.match(accepted.prompts[0], /target path/i);
|
|
233
|
+
assert.match(accepted.prompts[1], /init recommended/i);
|
|
234
|
+
assert.equal(existsSync(join(fixture.missing, ".git")), true);
|
|
235
|
+
assert.equal(existsSync(join(fixture.missing, ".ai", "cairnkeep.json")), true);
|
|
236
|
+
|
|
237
|
+
const refused = await runInteractive(setup, [fixture.empty], ["init", "pi", "local", "no"]);
|
|
238
|
+
assert.equal(refused.status, 0);
|
|
239
|
+
assert.equal(refused.error, "");
|
|
240
|
+
assert.ok(refused.prompts.some((prompt) => /init recommended/i.test(prompt)));
|
|
241
|
+
assert.match(refused.output, /cancelled; no files were changed/i);
|
|
242
|
+
assert.equal(existsSync(join(fixture.empty, ".git")), false);
|
|
243
|
+
assertNoManagedPaths(fixture.empty, "refused interactive setup");
|
|
244
|
+
|
|
245
|
+
const existing = await runInteractive(setup, [fixture.nonempty], ["none", "pi", "local", "yes"]);
|
|
246
|
+
assert.equal(existing.status, 0);
|
|
247
|
+
assert.equal(existing.error, "");
|
|
248
|
+
const gitPrompt = existing.prompts.find((prompt) => /Git mode/i.test(prompt));
|
|
249
|
+
assert.match(gitPrompt, /init requires explicit choice/i);
|
|
250
|
+
assert.doesNotMatch(gitPrompt, /init recommended/i);
|
|
251
|
+
assert.equal(existsSync(join(fixture.nonempty, ".git")), false);
|
|
252
|
+
assert.equal(readFileSync(join(fixture.nonempty, "operator.txt"), "utf8"), "preserve\n");
|
|
253
|
+
assert.equal(existsSync(join(fixture.nonempty, ".ai", "cairnkeep.json")), true);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function main() {
|
|
257
|
+
const fixture = validateFixtures();
|
|
258
|
+
try {
|
|
259
|
+
let core;
|
|
260
|
+
try {
|
|
261
|
+
core = await loadSetupCore();
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND" && String(error.message).includes("setup-core.mjs")) {
|
|
264
|
+
console.log(RED_MARKER);
|
|
265
|
+
process.exitCode = EXPECTED_RED_EXIT;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
assertExports(core);
|
|
271
|
+
testChoiceContract(core, fixture);
|
|
272
|
+
testPreflightContract(core, fixture);
|
|
273
|
+
const setup = await loadSetup();
|
|
274
|
+
await testInteractiveGitRecommendation(setup, fixture);
|
|
275
|
+
console.log("PASS: setup target, Git, syntax, choice, and no-write preflight contract");
|
|
276
|
+
} finally {
|
|
277
|
+
rmSync(fixture.sandbox, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await main();
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
const EXPECTED_RED_EXIT = 86;
|
|
23
|
+
const RED_MARKER = "PHASE26_RED:SETUP_RECONCILE_MISSING";
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const reconcilerPath = join(here, "setup-reconcile.mjs");
|
|
26
|
+
|
|
27
|
+
function digest(value) {
|
|
28
|
+
return createHash("sha256").update(value).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asset(path, value, mode, template, harness = null) {
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
path,
|
|
34
|
+
bytes: Buffer.from(value, "utf8"),
|
|
35
|
+
mode,
|
|
36
|
+
template,
|
|
37
|
+
...(harness ? { harness } : {}),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function plan(target, assets, overrides = {}) {
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
target: resolve(target),
|
|
44
|
+
version: "fixture-1",
|
|
45
|
+
git: "existing",
|
|
46
|
+
memory: "local",
|
|
47
|
+
harnesses: ["claude"],
|
|
48
|
+
assets: Object.freeze(assets),
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownedState(rows, overrides = {}) {
|
|
54
|
+
return {
|
|
55
|
+
schema_version: 1,
|
|
56
|
+
cairnkeep_version: "fixture-1",
|
|
57
|
+
git: "existing",
|
|
58
|
+
memory: "local",
|
|
59
|
+
harnesses: ["claude"],
|
|
60
|
+
assets: Object.fromEntries(rows.map(({ path, bytes, mode, template }) => [path, {
|
|
61
|
+
digest: digest(bytes),
|
|
62
|
+
mode,
|
|
63
|
+
template,
|
|
64
|
+
}])),
|
|
65
|
+
...overrides,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertCounts(result, counts) {
|
|
70
|
+
assert.deepEqual(result.counts, counts);
|
|
71
|
+
assert.deepEqual(
|
|
72
|
+
result.changes.map(({ status }) => status).sort(),
|
|
73
|
+
Object.entries(counts).flatMap(([status, count]) => Array(count).fill(status)).sort(),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertStrictState(state, target) {
|
|
78
|
+
assert.deepEqual(Object.keys(state).sort(), [
|
|
79
|
+
"assets",
|
|
80
|
+
"cairnkeep_version",
|
|
81
|
+
"git",
|
|
82
|
+
"harnesses",
|
|
83
|
+
"memory",
|
|
84
|
+
"schema_version",
|
|
85
|
+
]);
|
|
86
|
+
assert.equal(state.schema_version, 1);
|
|
87
|
+
assert.deepEqual(state.harnesses, ["claude"]);
|
|
88
|
+
assert.ok(["existing", "init", "none"].includes(state.git));
|
|
89
|
+
assert.ok(["local", "none"].includes(state.memory));
|
|
90
|
+
for (const [path, record] of Object.entries(state.assets)) {
|
|
91
|
+
assert.equal(path.startsWith(".") && !path.startsWith("..") && !path.includes("\\"), true, `unsafe state path ${path}`);
|
|
92
|
+
assert.deepEqual(Object.keys(record).sort(), ["digest", "mode", "template"]);
|
|
93
|
+
assert.match(record.digest, /^[a-f0-9]{64}$/);
|
|
94
|
+
assert.equal(Number.isInteger(record.mode), true);
|
|
95
|
+
assert.equal(typeof record.template, "string");
|
|
96
|
+
}
|
|
97
|
+
const serialized = JSON.stringify(state);
|
|
98
|
+
assert.equal(serialized.includes(resolve(target)), false, "state leaked absolute target");
|
|
99
|
+
assert.doesNotMatch(serialized, /(?:token|secret|credential|endpoint|username|environment|https?:)/i);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateFixtures() {
|
|
103
|
+
const sandbox = mkdtempSync(join(tmpdir(), "cairn-setup-reconcile-fixture-"));
|
|
104
|
+
try {
|
|
105
|
+
const target = join(sandbox, "project");
|
|
106
|
+
const outside = join(sandbox, "outside");
|
|
107
|
+
mkdirSync(target);
|
|
108
|
+
mkdirSync(outside);
|
|
109
|
+
const first = asset(".ai/start-claude.sh", "#!/bin/sh\necho one\n", 0o755, "start-claude.sh.template", "claude");
|
|
110
|
+
const config = asset(".planning/config.json", "{\"fixture\":true}\n", 0o644, "planning-config.json.template");
|
|
111
|
+
assert.equal(first.path.startsWith(".ai/"), true);
|
|
112
|
+
assert.equal(config.path.startsWith(".planning/"), true);
|
|
113
|
+
assert.equal(digest(first.bytes), createHash("sha256").update(first.bytes).digest("hex"));
|
|
114
|
+
assert.deepEqual(ownedState([first]).assets[first.path], {
|
|
115
|
+
digest: digest(first.bytes),
|
|
116
|
+
mode: 0o755,
|
|
117
|
+
template: "start-claude.sh.template",
|
|
118
|
+
});
|
|
119
|
+
return { sandbox, target, outside, first, config };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadReconciler() {
|
|
127
|
+
return import(pathToFileURL(reconcilerPath).href);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertExports(reconciler) {
|
|
131
|
+
assert.equal(reconciler.SETUP_STATE_SCHEMA_VERSION, 1);
|
|
132
|
+
for (const name of ["hashSetupAsset", "reconcileSetupPlan", "writeSetupStateAtomic"]) {
|
|
133
|
+
assert.equal(typeof reconciler[name], "function", `setup-reconcile must export ${name}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function testCreateAndIdempotence(reconciler, fixture) {
|
|
138
|
+
const selectedPlan = plan(fixture.target, [fixture.first, fixture.config]);
|
|
139
|
+
const unselected = join(fixture.target, ".ai", "start-pi.sh");
|
|
140
|
+
mkdirSync(dirname(unselected), { recursive: true });
|
|
141
|
+
symlinkSync(join(fixture.outside, "missing-pi"), unselected);
|
|
142
|
+
|
|
143
|
+
const created = await reconciler.reconcileSetupPlan(selectedPlan);
|
|
144
|
+
assertCounts(created, { created: 2, updated: 0, unchanged: 0, skipped: 0 });
|
|
145
|
+
assert.deepEqual(readFileSync(join(fixture.target, fixture.first.path)), fixture.first.bytes);
|
|
146
|
+
assert.deepEqual(readFileSync(join(fixture.target, fixture.config.path)), fixture.config.bytes);
|
|
147
|
+
assert.equal((statSync(join(fixture.target, fixture.first.path)).mode & 0o777), 0o755);
|
|
148
|
+
assert.equal((statSync(join(fixture.target, fixture.config.path)).mode & 0o777), 0o644);
|
|
149
|
+
assert.equal(lstatSync(unselected).isSymbolicLink(), true, "unselected harness asset was inspected or removed");
|
|
150
|
+
assertStrictState(created.state, fixture.target);
|
|
151
|
+
const statePath = join(fixture.target, ".ai", "cairnkeep.json");
|
|
152
|
+
assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), created.state);
|
|
153
|
+
|
|
154
|
+
const mtimes = new Map(selectedPlan.assets.map(({ path }) => [path, statSync(join(fixture.target, path)).mtimeMs]));
|
|
155
|
+
const stateMtime = statSync(statePath).mtimeMs;
|
|
156
|
+
const unchanged = await reconciler.reconcileSetupPlan(selectedPlan);
|
|
157
|
+
assertCounts(unchanged, { created: 0, updated: 0, unchanged: 2, skipped: 0 });
|
|
158
|
+
for (const [path, mtime] of mtimes) assert.equal(statSync(join(fixture.target, path)).mtimeMs, mtime, `identical rerun touched ${path}`);
|
|
159
|
+
assert.equal(statSync(statePath).mtimeMs, stateMtime, "identical rerun rewrote setup state");
|
|
160
|
+
assert.equal(lstatSync(unselected).isSymbolicLink(), true);
|
|
161
|
+
return { selectedPlan, created };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function testOwnershipBranches(reconciler, fixture, baseline) {
|
|
165
|
+
const ownedPath = join(fixture.target, fixture.first.path);
|
|
166
|
+
const previous = baseline.created.state;
|
|
167
|
+
|
|
168
|
+
const next = asset(fixture.first.path, "#!/bin/sh\necho two\n", 0o755, fixture.first.template, "claude");
|
|
169
|
+
const updated = await reconciler.reconcileSetupPlan(plan(fixture.target, [next]), { previousState: previous });
|
|
170
|
+
assertCounts(updated, { created: 0, updated: 1, unchanged: 0, skipped: 0 });
|
|
171
|
+
assert.deepEqual(readFileSync(ownedPath), next.bytes);
|
|
172
|
+
|
|
173
|
+
writeFileSync(ownedPath, "operator-owned\n");
|
|
174
|
+
chmodSync(ownedPath, 0o700);
|
|
175
|
+
const userBytes = readFileSync(ownedPath);
|
|
176
|
+
const userMtime = statSync(ownedPath).mtimeMs;
|
|
177
|
+
const diverged = await reconciler.reconcileSetupPlan(plan(fixture.target, [fixture.first]), { previousState: updated.state });
|
|
178
|
+
assertCounts(diverged, { created: 0, updated: 0, unchanged: 0, skipped: 1 });
|
|
179
|
+
assert.deepEqual(readFileSync(ownedPath), userBytes);
|
|
180
|
+
assert.equal(statSync(ownedPath).mtimeMs, userMtime);
|
|
181
|
+
|
|
182
|
+
const unownedPath = join(fixture.target, ".planning", "operator.md");
|
|
183
|
+
writeFileSync(unownedPath, "operator\n");
|
|
184
|
+
const unowned = asset(".planning/operator.md", "desired\n", 0o644, "operator.template");
|
|
185
|
+
const skipped = await reconciler.reconcileSetupPlan(plan(fixture.target, [unowned]), { previousState: null });
|
|
186
|
+
assertCounts(skipped, { created: 0, updated: 0, unchanged: 0, skipped: 1 });
|
|
187
|
+
assert.equal(readFileSync(unownedPath, "utf8"), "operator\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function testUnsafePaths(reconciler, fixture) {
|
|
191
|
+
for (const kind of ["symlink-ancestor", "file-ancestor", "symlink-destination"]) {
|
|
192
|
+
const target = join(fixture.sandbox, kind);
|
|
193
|
+
mkdirSync(target);
|
|
194
|
+
const outsideSentinel = join(fixture.outside, `${kind}.txt`);
|
|
195
|
+
writeFileSync(outsideSentinel, "outside\n");
|
|
196
|
+
if (kind === "symlink-ancestor") symlinkSync(fixture.outside, join(target, ".ai"), "dir");
|
|
197
|
+
else if (kind === "file-ancestor") writeFileSync(join(target, ".ai"), "not-a-directory\n");
|
|
198
|
+
else {
|
|
199
|
+
mkdirSync(join(target, ".ai"));
|
|
200
|
+
symlinkSync(outsideSentinel, join(target, ".ai", "start-claude.sh"));
|
|
201
|
+
}
|
|
202
|
+
await assert.rejects(
|
|
203
|
+
reconciler.reconcileSetupPlan(plan(target, [fixture.first])),
|
|
204
|
+
/unsafe|symbolic|regular|contain|ancestor/i,
|
|
205
|
+
);
|
|
206
|
+
assert.equal(readFileSync(outsideSentinel, "utf8"), "outside\n");
|
|
207
|
+
assert.equal(existsSync(join(target, ".planning")), false, `${kind} left partial state`);
|
|
208
|
+
assert.equal(existsSync(join(target, ".agentfs")), false, `${kind} left partial state`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function testStateInterruptionAndPrivacy(reconciler, fixture) {
|
|
213
|
+
const statePath = join(fixture.target, ".ai", "cairnkeep.json");
|
|
214
|
+
const before = readFileSync(statePath);
|
|
215
|
+
const hostile = ownedState([fixture.first], { endpoint: "https://invalid.example", target: fixture.target });
|
|
216
|
+
await assert.rejects(reconciler.writeSetupStateAtomic(statePath, hostile), /invalid|unknown|unsafe|state/i);
|
|
217
|
+
assert.deepEqual(readFileSync(statePath), before);
|
|
218
|
+
|
|
219
|
+
const replacement = ownedState([fixture.first], { cairnkeep_version: "fixture-2" });
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
reconciler.writeSetupStateAtomic(statePath, replacement, {
|
|
222
|
+
atomicReplace: async () => { throw new Error("fixture interruption before replacement"); },
|
|
223
|
+
}),
|
|
224
|
+
/fixture interruption/,
|
|
225
|
+
);
|
|
226
|
+
assert.deepEqual(readFileSync(statePath), before, "interrupted replacement changed prior state");
|
|
227
|
+
const temporary = readdirSync(dirname(statePath)).filter((name) => name.includes("cairnkeep.json") && name.endsWith(".tmp"));
|
|
228
|
+
assert.deepEqual(temporary, [], "interrupted replacement leaked a temporary state file");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function main() {
|
|
232
|
+
const fixture = validateFixtures();
|
|
233
|
+
try {
|
|
234
|
+
let reconciler;
|
|
235
|
+
try {
|
|
236
|
+
reconciler = await loadReconciler();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND" && String(error.message).includes("setup-reconcile.mjs")) {
|
|
239
|
+
console.log(RED_MARKER);
|
|
240
|
+
process.exitCode = EXPECTED_RED_EXIT;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
assertExports(reconciler);
|
|
246
|
+
assert.equal(reconciler.hashSetupAsset(fixture.first.bytes), digest(fixture.first.bytes));
|
|
247
|
+
const baseline = await testCreateAndIdempotence(reconciler, fixture);
|
|
248
|
+
await testOwnershipBranches(reconciler, fixture, baseline);
|
|
249
|
+
await testUnsafePaths(reconciler, fixture);
|
|
250
|
+
await testStateInterruptionAndPrivacy(reconciler, fixture);
|
|
251
|
+
console.log("PASS: setup ownership, idempotence, containment, privacy, and atomic state contract");
|
|
252
|
+
} finally {
|
|
253
|
+
rmSync(fixture.sandbox, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await main();
|
|
@@ -327,3 +327,53 @@ check "explicit pack purge removes context packs" "$([[ -e "$PACK_STORE" ]] && e
|
|
|
327
327
|
echo
|
|
328
328
|
if [[ "$fails" -gt 0 ]]; then echo "test-uninstall: $fails check(s) failed."; exit 1; fi
|
|
329
329
|
echo "test-uninstall: OK"
|
|
330
|
+
|
|
331
|
+
phase26_uninstall_complete=true
|
|
332
|
+
[[ -f "$ROOT_DIR/pi/extensions/cairnkeep-memory.ts" ]] || phase26_uninstall_complete=false
|
|
333
|
+
grep -qF 'extensions/cairnkeep-memory.ts' "$ROOT_DIR/scripts/sync-pi-assets.sh" || phase26_uninstall_complete=false
|
|
334
|
+
grep -qF 'extensions/cairnkeep-memory.ts' "$ROOT_DIR/scripts/uninstall.sh" || phase26_uninstall_complete=false
|
|
335
|
+
|
|
336
|
+
if [[ "$phase26_uninstall_complete" != true ]]; then
|
|
337
|
+
if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
|
|
338
|
+
echo "PHASE26_RED:PI_UNINSTALL_MISSING"
|
|
339
|
+
exit 86
|
|
340
|
+
fi
|
|
341
|
+
echo "SKIP: Pi memory-extension uninstall ownership is not complete"
|
|
342
|
+
exit 0
|
|
343
|
+
fi
|
|
344
|
+
|
|
345
|
+
# Exercise the new Pi owner separately so the long-standing uninstall oracle
|
|
346
|
+
# above remains byte-for-byte focused on its established asset set.
|
|
347
|
+
PHASE26_HOME="$SB/phase26-home"
|
|
348
|
+
PHASE26_PI="$SB/phase26-pi"
|
|
349
|
+
PHASE26_LIVE="$SB/phase26-live"
|
|
350
|
+
PHASE26_STORE="$PHASE26_HOME/.cairnkeep"
|
|
351
|
+
PHASE26_PACKS="$PHASE26_STORE/packs"
|
|
352
|
+
mkdir -p "$PHASE26_HOME" "$PHASE26_PI/extensions" "$PHASE26_STORE/memory" "$PHASE26_PACKS/objects/fixture"
|
|
353
|
+
printf 'neighbor bytes\n' >"$PHASE26_PI/extensions/operator-extension.ts"
|
|
354
|
+
printf 'memory bytes\n' >"$PHASE26_STORE/memory/record"
|
|
355
|
+
printf 'pack bytes\n' >"$PHASE26_PACKS/objects/fixture/record"
|
|
356
|
+
|
|
357
|
+
HOME="$PHASE26_HOME" PI_CODING_AGENT_DIR="$PHASE26_PI" \
|
|
358
|
+
"$ROOT_DIR/scripts/sync-pi-assets.sh" --apply --live-root "$PHASE26_PI" >/dev/null 2>&1
|
|
359
|
+
check "Pi memory extension installed" "$([[ -f "$PHASE26_PI/extensions/cairnkeep-memory.ts" ]] && echo yes || echo no)" "yes"
|
|
360
|
+
printf '\n// operator-modified fixture\n' >>"$PHASE26_PI/extensions/cairnkeep-memory.ts"
|
|
361
|
+
cp "$PHASE26_PI/extensions/cairnkeep-memory.ts" "$SB/pi-memory-modified.before.ts"
|
|
362
|
+
|
|
363
|
+
HOME="$PHASE26_HOME" XDG_CONFIG_HOME="$PHASE26_HOME/.config" \
|
|
364
|
+
CAIRN_AGENTFS_BASE_DIR="$PHASE26_STORE" CAIRN_PACK_BASE_DIR="$PHASE26_PACKS" \
|
|
365
|
+
"$ROOT_DIR/scripts/uninstall.sh" --yes --live-root "$PHASE26_LIVE" --pi-live-root "$PHASE26_PI" >/dev/null 2>&1
|
|
366
|
+
check "modified Pi memory extension removed after backup" "$([[ -e "$PHASE26_PI/extensions/cairnkeep-memory.ts" ]] && echo no || echo yes)" "yes"
|
|
367
|
+
check "neighboring Pi extension preserved" "$(cat "$PHASE26_PI/extensions/operator-extension.ts")" "neighbor bytes"
|
|
368
|
+
check "default Pi uninstall preserves memory" "$(cat "$PHASE26_STORE/memory/record")" "memory bytes"
|
|
369
|
+
check "default Pi uninstall preserves context packs" "$(cat "$PHASE26_PACKS/objects/fixture/record")" "pack bytes"
|
|
370
|
+
|
|
371
|
+
PHASE26_BACKUP=$(ls -dt "$PHASE26_HOME/.cairnkeep-uninstall-"* 2>/dev/null | head -1)
|
|
372
|
+
check "Pi memory extension backup exists" "$([[ -f "$PHASE26_BACKUP/files/${PHASE26_PI#/}/extensions/cairnkeep-memory.ts" ]] && echo yes || echo no)" "yes"
|
|
373
|
+
check "Pi memory extension manifest entry exists" "$(grep -cF "$PHASE26_PI/extensions/cairnkeep-memory.ts" "$PHASE26_BACKUP/manifest.tsv" 2>/dev/null || true)" "1"
|
|
374
|
+
HOME="$PHASE26_HOME" bash "$PHASE26_BACKUP/revert.sh" >/dev/null 2>&1
|
|
375
|
+
check "modified Pi memory extension restored exactly" "$(cmp -s "$SB/pi-memory-modified.before.ts" "$PHASE26_PI/extensions/cairnkeep-memory.ts" && echo yes || echo no)" "yes"
|
|
376
|
+
check "neighbor remains exact after revert" "$(cat "$PHASE26_PI/extensions/operator-extension.ts")" "neighbor bytes"
|
|
377
|
+
|
|
378
|
+
if [[ "$fails" -gt 0 ]]; then echo "test-uninstall: $fails Phase 26 check(s) failed."; exit 1; fi
|
|
379
|
+
echo "PASS: Pi memory-extension backup-first uninstall and revert contract"
|