@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,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
EXPECTED_RED_EXIT=86
|
|
6
|
+
RED_MARKER="PHASE26_RED:SETUP_OVERLAY_MISSING"
|
|
7
|
+
|
|
8
|
+
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
9
|
+
|
|
10
|
+
# The existing managed-distribution schema and package checks remain part of
|
|
11
|
+
# this contract and must pass before the new setup-policy seam is evaluated.
|
|
12
|
+
bash "$ROOT/scripts/test-overlay-schema.sh" >/dev/null || fail "existing overlay schema contract failed"
|
|
13
|
+
|
|
14
|
+
tmp=$(mktemp -d)
|
|
15
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
16
|
+
mkdir -p "$tmp/project"
|
|
17
|
+
printf '%s\n' '{"schema_version":1,"defaults":{"git":"none","harnesses":["claude","pi"],"memory":"local"},"constraints":{"git":["none","existing"],"harnesses":["claude","pi"],"required_harnesses":["pi"],"memory":["local"]}}' >"$tmp/policy.json"
|
|
18
|
+
node -e 'const p=require(process.argv[1]);if(p.schema_version!==1||Object.keys(p).sort().join(",")!=="constraints,defaults,schema_version")process.exit(1)' "$tmp/policy.json" || fail "policy fixture is invalid"
|
|
19
|
+
[[ ! -e "$tmp/project/.ai" && ! -e "$tmp/project/.planning" && ! -e "$tmp/project/.agentfs" ]] || fail "policy fixture created managed paths"
|
|
20
|
+
|
|
21
|
+
if [[ ! -f "$ROOT/scripts/setup-core.mjs" || ! -f "$ROOT/schemas/cairnkeep-setup-policy.schema.json" ]]; then
|
|
22
|
+
if [[ "${CAIRN_PHASE26_RED:-0}" == 1 ]]; then
|
|
23
|
+
echo "$RED_MARKER"
|
|
24
|
+
exit "$EXPECTED_RED_EXIT"
|
|
25
|
+
fi
|
|
26
|
+
echo "SKIP: setup overlay policy production surface is not complete"
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
node --input-type=module - "$ROOT" "$tmp" <<'NODE'
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
|
|
36
|
+
const [root, sandbox] = process.argv.slice(2);
|
|
37
|
+
const core = await import(pathToFileURL(path.join(root, "scripts/setup-core.mjs")).href);
|
|
38
|
+
const policyPath = path.join(sandbox, "policy.json");
|
|
39
|
+
const target = path.join(sandbox, "project");
|
|
40
|
+
const policy = core.readSetupPolicy(policyPath);
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(Object.keys(policy).sort(), ["constraints", "defaults", "schema_version"]);
|
|
43
|
+
assert.equal(policy.schema_version, 1);
|
|
44
|
+
assert.deepEqual(policy.defaults, { git: "none", harnesses: ["claude", "pi"], memory: "local" });
|
|
45
|
+
|
|
46
|
+
const fromDefaults = core.resolveSetupChoices({
|
|
47
|
+
parsed: { target, git: null, harnesses: null, memory: null, confirmed: true },
|
|
48
|
+
preflight: { targetState: "empty", gitExecutable: "available", repository: "none" },
|
|
49
|
+
policy,
|
|
50
|
+
interactive: null,
|
|
51
|
+
});
|
|
52
|
+
assert.equal(fromDefaults.git, "none");
|
|
53
|
+
assert.deepEqual(fromDefaults.harnesses, ["claude", "pi"]);
|
|
54
|
+
assert.equal(fromDefaults.memory, "local");
|
|
55
|
+
|
|
56
|
+
const cliWins = core.resolveSetupChoices({
|
|
57
|
+
parsed: { target, git: "existing", harnesses: ["pi"], memory: "local", confirmed: true },
|
|
58
|
+
preflight: { targetState: "non-empty", gitExecutable: "available", repository: "work-tree" },
|
|
59
|
+
policy,
|
|
60
|
+
interactive: null,
|
|
61
|
+
});
|
|
62
|
+
assert.equal(cliWins.git, "existing");
|
|
63
|
+
assert.deepEqual(cliWins.harnesses, ["pi"]);
|
|
64
|
+
assert.equal(cliWins.memory, "local");
|
|
65
|
+
|
|
66
|
+
assert.throws(
|
|
67
|
+
() => core.resolveSetupChoices({
|
|
68
|
+
parsed: { target, git: "existing", harnesses: ["claude"], memory: "local", confirmed: true },
|
|
69
|
+
preflight: { targetState: "non-empty", gitExecutable: "available", repository: "work-tree" },
|
|
70
|
+
policy,
|
|
71
|
+
interactive: null,
|
|
72
|
+
}),
|
|
73
|
+
/constraint|required|pi/i,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const invalidPolicies = {
|
|
77
|
+
unknown: { schema_version: 1, defaults: {}, constraints: {}, extra: true },
|
|
78
|
+
version: { schema_version: 2, defaults: {}, constraints: {} },
|
|
79
|
+
url: { schema_version: 1, defaults: {}, constraints: {}, endpoint: "https://example.invalid" },
|
|
80
|
+
credential: { schema_version: 1, defaults: {}, constraints: {}, token: "fixture-secret" },
|
|
81
|
+
executable: { schema_version: 1, defaults: { launcher: "node helper.mjs" }, constraints: {} },
|
|
82
|
+
};
|
|
83
|
+
for (const [name, value] of Object.entries(invalidPolicies)) {
|
|
84
|
+
const candidate = path.join(sandbox, `${name}.json`);
|
|
85
|
+
fs.writeFileSync(candidate, `${JSON.stringify(value)}\n`);
|
|
86
|
+
assert.throws(() => core.readSetupPolicy(candidate), /invalid|unknown|policy|schema|allowed/i, name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const oversized = path.join(sandbox, "oversized.json");
|
|
90
|
+
fs.writeFileSync(oversized, " ".repeat(1024 * 1024 + 1));
|
|
91
|
+
assert.throws(() => core.readSetupPolicy(oversized), /size|large|policy/i);
|
|
92
|
+
|
|
93
|
+
const linked = path.join(sandbox, "linked.json");
|
|
94
|
+
fs.symlinkSync(policyPath, linked);
|
|
95
|
+
assert.throws(() => core.readSetupPolicy(linked), /symbolic|symlink|unsafe|policy/i);
|
|
96
|
+
|
|
97
|
+
const executable = path.join(sandbox, "executable.json");
|
|
98
|
+
fs.writeFileSync(executable, '{"schema_version":1,"defaults":{},"constraints":{}}\n', { mode: 0o755 });
|
|
99
|
+
fs.chmodSync(executable, 0o755);
|
|
100
|
+
assert.throws(() => core.readSetupPolicy(executable), /executable|mode|unsafe|policy/i);
|
|
101
|
+
|
|
102
|
+
for (const managed of [".ai", ".planning", ".agentfs"]) {
|
|
103
|
+
assert.equal(fs.existsSync(path.join(target, managed)), false, `policy validation created ${managed}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const schema = JSON.parse(fs.readFileSync(path.join(root, "schemas/cairnkeep-setup-policy.schema.json"), "utf8"));
|
|
107
|
+
assert.equal(schema.$schema, "https://json-schema.org/draft/2020-12/schema");
|
|
108
|
+
assert.equal(schema.additionalProperties, false);
|
|
109
|
+
assert.deepEqual(schema.required, ["schema_version", "defaults", "constraints"]);
|
|
110
|
+
assert.deepEqual(Object.keys(schema.properties).sort(), ["constraints", "defaults", "schema_version"]);
|
|
111
|
+
NODE
|
|
112
|
+
|
|
113
|
+
echo "PASS: provider-neutral setup overlay policy and precedence contract"
|
|
@@ -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();
|