magus-cli 7.5.2 → 7.5.4
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/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec 7.5.4 v3 — REQ-8: the TUI and the CLI get the SAME startup state in a subfolder.
|
|
3
|
+
*
|
|
4
|
+
* Written blind: from the spec and the exported signatures and types only. The TUI's
|
|
5
|
+
* startup question is built from `detectProfileStartupNeed`, `describeNeed` and
|
|
6
|
+
* `buildProfileOptions`; the CLI's is printed by `ensureManifest`. In a subfolder of a
|
|
7
|
+
* project that already has profiles, both must say the same thing.
|
|
8
|
+
*
|
|
9
|
+
* Every call runs in a CHILD process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_*
|
|
10
|
+
* already pointing at temp dirs (Bun's `os.homedir()` ignores a HOME changed at
|
|
11
|
+
* runtime), with stdin/stdout forced non-TTY and colours off. The child's PATH carries
|
|
12
|
+
* only `bun` and `git`, so nothing here can reach a real `claude` CLI. The helper script
|
|
13
|
+
* is written to a mkdtemp dir at runtime and removed afterwards.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
mkdir,
|
|
18
|
+
mkdtemp,
|
|
19
|
+
readFile,
|
|
20
|
+
realpath,
|
|
21
|
+
rm,
|
|
22
|
+
symlink,
|
|
23
|
+
writeFile,
|
|
24
|
+
} from "node:fs/promises";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
|
+
import type {
|
|
28
|
+
ProfileStartupNeed,
|
|
29
|
+
ProfileStartupState,
|
|
30
|
+
} from "../services/profile-startup.js";
|
|
31
|
+
|
|
32
|
+
const SRC = join(import.meta.dir, "..");
|
|
33
|
+
const MODULES = {
|
|
34
|
+
ansi: join(SRC, "cli", "ansi.ts"),
|
|
35
|
+
bootstrap: join(SRC, "cli", "bootstrap.ts"),
|
|
36
|
+
startup: join(SRC, "services", "profile-startup.ts"),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CHILD_TIMEOUT_MS = 45_000;
|
|
40
|
+
const CHILD_TEST_TIMEOUT_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* mode `nested`: the three TUI functions on the subfolder, then `ensureManifest` on it
|
|
44
|
+
* with its output captured. mode `root`: `detectProfileStartupNeed` on the root only.
|
|
45
|
+
*/
|
|
46
|
+
const STARTUP_CHILD = `
|
|
47
|
+
import { writeFileSync } from "node:fs";
|
|
48
|
+
import { format } from "node:util";
|
|
49
|
+
|
|
50
|
+
const [mode, target, resultPath] = process.argv.slice(2);
|
|
51
|
+
const ansi = await import(${JSON.stringify(MODULES.ansi)});
|
|
52
|
+
const startup = await import(${JSON.stringify(MODULES.startup)});
|
|
53
|
+
const bootstrap = await import(${JSON.stringify(MODULES.bootstrap)});
|
|
54
|
+
|
|
55
|
+
ansi.setColorEnabled(false);
|
|
56
|
+
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
|
57
|
+
Object.defineProperty(stream, "isTTY", { value: false, configurable: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const out = { need: null, described: null, options: null, ensureOutput: null, ensureResult: null, error: null };
|
|
61
|
+
try {
|
|
62
|
+
const need = await startup.detectProfileStartupNeed(target);
|
|
63
|
+
out.need = { state: need.state, enclosing: need.enclosing ?? null };
|
|
64
|
+
if (mode === "nested") {
|
|
65
|
+
out.described = startup.describeNeed(need);
|
|
66
|
+
out.options = startup.buildProfileOptions(need);
|
|
67
|
+
|
|
68
|
+
const chunks = [];
|
|
69
|
+
const decode = (chunk) =>
|
|
70
|
+
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
71
|
+
const capture = (...args) => {
|
|
72
|
+
chunks.push(format(...args) + "\\n");
|
|
73
|
+
};
|
|
74
|
+
const saved = {
|
|
75
|
+
log: console.log, error: console.error, warn: console.warn, info: console.info,
|
|
76
|
+
out: process.stdout.write, err: process.stderr.write,
|
|
77
|
+
};
|
|
78
|
+
console.log = capture;
|
|
79
|
+
console.error = capture;
|
|
80
|
+
console.warn = capture;
|
|
81
|
+
console.info = capture;
|
|
82
|
+
const captureWrite = (chunk, ...rest) => {
|
|
83
|
+
chunks.push(decode(chunk));
|
|
84
|
+
const done = rest.find((arg) => typeof arg === "function");
|
|
85
|
+
if (done) done();
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
process.stdout.write = captureWrite;
|
|
89
|
+
process.stderr.write = captureWrite;
|
|
90
|
+
try {
|
|
91
|
+
const value = await bootstrap.ensureManifest(target, {});
|
|
92
|
+
out.ensureResult = value === undefined ? null : value;
|
|
93
|
+
} finally {
|
|
94
|
+
console.log = saved.log;
|
|
95
|
+
console.error = saved.error;
|
|
96
|
+
console.warn = saved.warn;
|
|
97
|
+
console.info = saved.info;
|
|
98
|
+
process.stdout.write = saved.out;
|
|
99
|
+
process.stderr.write = saved.err;
|
|
100
|
+
}
|
|
101
|
+
out.ensureOutput = chunks.join("");
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
105
|
+
}
|
|
106
|
+
writeFileSync(resultPath, JSON.stringify(out));
|
|
107
|
+
process.exit(0);
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
interface StartupRun {
|
|
111
|
+
need: {
|
|
112
|
+
state: ProfileStartupState;
|
|
113
|
+
enclosing: ProfileStartupNeed["enclosing"] | null;
|
|
114
|
+
} | null;
|
|
115
|
+
described: { title: string; message: string } | null;
|
|
116
|
+
options: Array<{ label: string; value: string; meta?: string }> | null;
|
|
117
|
+
ensureOutput: string | null;
|
|
118
|
+
ensureResult: unknown;
|
|
119
|
+
error: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const scratch: string[] = [];
|
|
123
|
+
let helperDir = "";
|
|
124
|
+
let binDir = "";
|
|
125
|
+
|
|
126
|
+
async function tempRoot(prefix: string): Promise<string> {
|
|
127
|
+
const dir = await realpath(await mkdtemp(join(tmpdir(), prefix)));
|
|
128
|
+
scratch.push(dir);
|
|
129
|
+
return dir;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function writeJson(path: string, value: unknown): Promise<void> {
|
|
133
|
+
await mkdir(dirname(path), { recursive: true });
|
|
134
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
beforeAll(async () => {
|
|
138
|
+
helperDir = await tempRoot("psn-helpers-");
|
|
139
|
+
binDir = join(helperDir, "bin");
|
|
140
|
+
await mkdir(binDir);
|
|
141
|
+
await symlink(process.execPath, join(binDir, "bun"));
|
|
142
|
+
const git = Bun.which("git");
|
|
143
|
+
if (git) await symlink(git, join(binDir, "git"));
|
|
144
|
+
await writeFile(join(helperDir, "startup-child.ts"), STARTUP_CHILD);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterAll(async () => {
|
|
148
|
+
for (const dir of scratch) await rm(dir, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
interface Tree {
|
|
152
|
+
base: string;
|
|
153
|
+
env: Record<string, string>;
|
|
154
|
+
root: string;
|
|
155
|
+
app: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `<root>/.git/`, `<root>/.claude/profiles.json` defining `team`, and `<root>/tools/app/`
|
|
160
|
+
* with its own `.claude/settings.json` enabling `dev@magus` and no profiles.json.
|
|
161
|
+
* HOME, CLAUDE_CONFIG_DIR (`<HOME>/.claude`) and XDG_* are temp dirs beside it.
|
|
162
|
+
*/
|
|
163
|
+
async function nestedTree(): Promise<Tree> {
|
|
164
|
+
const base = await tempRoot("psn-");
|
|
165
|
+
const home = join(base, "home");
|
|
166
|
+
const xdg = join(base, "xdg");
|
|
167
|
+
await mkdir(join(home, ".claude"), { recursive: true });
|
|
168
|
+
for (const kind of ["config", "cache", "data", "state"]) {
|
|
169
|
+
await mkdir(join(xdg, kind), { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
const root = join(base, "acme-root");
|
|
172
|
+
await mkdir(join(root, ".git"), { recursive: true });
|
|
173
|
+
await writeJson(join(root, ".claude", "profiles.json"), {
|
|
174
|
+
version: 2,
|
|
175
|
+
profiles: { team: { name: "team", plugins: { "dev@magus": true } } },
|
|
176
|
+
});
|
|
177
|
+
const app = join(root, "tools", "app");
|
|
178
|
+
await writeJson(join(app, ".claude", "settings.json"), {
|
|
179
|
+
enabledPlugins: { "dev@magus": true },
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
base,
|
|
183
|
+
root,
|
|
184
|
+
app,
|
|
185
|
+
env: {
|
|
186
|
+
PATH: `${binDir}:/usr/bin:/bin`,
|
|
187
|
+
HOME: home,
|
|
188
|
+
CLAUDE_CONFIG_DIR: join(home, ".claude"),
|
|
189
|
+
XDG_CONFIG_HOME: join(xdg, "config"),
|
|
190
|
+
XDG_CACHE_HOME: join(xdg, "cache"),
|
|
191
|
+
XDG_DATA_HOME: join(xdg, "data"),
|
|
192
|
+
XDG_STATE_HOME: join(xdg, "state"),
|
|
193
|
+
TMPDIR: tmpdir(),
|
|
194
|
+
NO_COLOR: "1",
|
|
195
|
+
TERM: "dumb",
|
|
196
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
197
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function runStartup(
|
|
203
|
+
tree: Tree,
|
|
204
|
+
mode: "nested" | "root",
|
|
205
|
+
): Promise<StartupRun> {
|
|
206
|
+
const target = mode === "nested" ? tree.app : tree.root;
|
|
207
|
+
const resultPath = join(tree.base, `result-${mode}.json`);
|
|
208
|
+
const proc = Bun.spawn({
|
|
209
|
+
cmd: [
|
|
210
|
+
process.execPath,
|
|
211
|
+
"--no-env-file",
|
|
212
|
+
join(helperDir, "startup-child.ts"),
|
|
213
|
+
mode,
|
|
214
|
+
target,
|
|
215
|
+
resultPath,
|
|
216
|
+
],
|
|
217
|
+
cwd: target,
|
|
218
|
+
env: tree.env,
|
|
219
|
+
stdin: "ignore",
|
|
220
|
+
stdout: "pipe",
|
|
221
|
+
stderr: "pipe",
|
|
222
|
+
});
|
|
223
|
+
const timer = setTimeout(() => proc.kill(), CHILD_TIMEOUT_MS);
|
|
224
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
225
|
+
new Response(proc.stdout).text(),
|
|
226
|
+
new Response(proc.stderr).text(),
|
|
227
|
+
proc.exited,
|
|
228
|
+
]);
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
let raw: string;
|
|
231
|
+
try {
|
|
232
|
+
raw = await readFile(resultPath, "utf8");
|
|
233
|
+
} catch {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`startup child wrote no result (exit ${code}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return JSON.parse(raw) as StartupRun;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function collapse(text: string): string {
|
|
242
|
+
return text.replace(/\s+/g, " ").trim();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
describe("REQ-8 the TUI and the CLI get the same startup state in a subfolder", () => {
|
|
246
|
+
test(
|
|
247
|
+
'REQ-8a detectProfileStartupNeed("<root>/tools/app") is in state "nested"',
|
|
248
|
+
async () => {
|
|
249
|
+
const tree = await nestedTree();
|
|
250
|
+
|
|
251
|
+
const run = await runStartup(tree, "nested");
|
|
252
|
+
|
|
253
|
+
expect(run.error).toBeNull();
|
|
254
|
+
expect(run.need?.state).toBe("nested");
|
|
255
|
+
},
|
|
256
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
test(
|
|
260
|
+
"REQ-8b buildProfileOptions offers exactly one option, Quit magus",
|
|
261
|
+
async () => {
|
|
262
|
+
const tree = await nestedTree();
|
|
263
|
+
|
|
264
|
+
const run = await runStartup(tree, "nested");
|
|
265
|
+
|
|
266
|
+
expect(run.error).toBeNull();
|
|
267
|
+
expect(run.options?.map((option) => option.label)).toEqual([
|
|
268
|
+
"Quit magus",
|
|
269
|
+
]);
|
|
270
|
+
},
|
|
271
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
test(
|
|
275
|
+
"REQ-8c describeNeed titles it Inside another Claude Code project and names ../.. (<root basename>)",
|
|
276
|
+
async () => {
|
|
277
|
+
const tree = await nestedTree();
|
|
278
|
+
|
|
279
|
+
const run = await runStartup(tree, "nested");
|
|
280
|
+
|
|
281
|
+
expect(run.error).toBeNull();
|
|
282
|
+
expect(run.described?.title).toBe("Inside another Claude Code project");
|
|
283
|
+
const messageLines = (run.described?.message ?? "")
|
|
284
|
+
.split("\n")
|
|
285
|
+
.map((line) => line.trim());
|
|
286
|
+
expect(messageLines).toContain(`../.. (${basename(tree.root)})`);
|
|
287
|
+
},
|
|
288
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
test(
|
|
292
|
+
"REQ-8d ensureManifest prints every non-empty line of describeNeed's message, in order",
|
|
293
|
+
async () => {
|
|
294
|
+
const tree = await nestedTree();
|
|
295
|
+
|
|
296
|
+
const run = await runStartup(tree, "nested");
|
|
297
|
+
|
|
298
|
+
expect(run.error).toBeNull();
|
|
299
|
+
const wanted = (run.described?.message ?? "")
|
|
300
|
+
.split("\n")
|
|
301
|
+
.map(collapse)
|
|
302
|
+
.filter((line) => line !== "");
|
|
303
|
+
expect(wanted.length).toBeGreaterThan(0);
|
|
304
|
+
const printed = collapse(run.ensureOutput ?? "");
|
|
305
|
+
let from = 0;
|
|
306
|
+
const found = wanted.map((line) => {
|
|
307
|
+
const at = printed.indexOf(line, from);
|
|
308
|
+
if (at !== -1) from = at + line.length;
|
|
309
|
+
return { line, printedInOrder: at !== -1 };
|
|
310
|
+
});
|
|
311
|
+
expect(found.filter((entry) => !entry.printedInOrder)).toEqual([]);
|
|
312
|
+
},
|
|
313
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
test(
|
|
317
|
+
'REQ-8e at <root> itself, which has profiles.json, the state is not "nested"',
|
|
318
|
+
async () => {
|
|
319
|
+
const tree = await nestedTree();
|
|
320
|
+
|
|
321
|
+
const run = await runStartup(tree, "root");
|
|
322
|
+
|
|
323
|
+
expect(run.error).toBeNull();
|
|
324
|
+
expect(run.need).not.toBeNull();
|
|
325
|
+
expect(run.need?.state).not.toBe("nested");
|
|
326
|
+
},
|
|
327
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
328
|
+
);
|
|
329
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec 7.5.4 v3 — REQ-9: an old profile format is ONE question for the CLI and the TUI.
|
|
3
|
+
*
|
|
4
|
+
* Written blind: from the spec and the exported signatures, types and constant only.
|
|
5
|
+
* `prepareProfileStartup` is the TUI's startup entry and `profileGate` the CLI's (REQ-5);
|
|
6
|
+
* the TUI's question is built from `oldFormatNeed`, `describeNeed` and
|
|
7
|
+
* `buildProfileOptions`, and its title must be the line the CLI gate prints.
|
|
8
|
+
*
|
|
9
|
+
* Every call runs in a CHILD process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_*
|
|
10
|
+
* already pointing at temp dirs (Bun's `os.homedir()` ignores a HOME changed at runtime),
|
|
11
|
+
* with stdin/stdout forced non-TTY and colours off. Each test builds its own REAL git
|
|
12
|
+
* repository. The child's PATH carries only `bun` and `git`, so nothing here can reach a
|
|
13
|
+
* real `claude` CLI. The helper script is written to a mkdtemp dir at runtime.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import { existsSync, lstatSync, readlinkSync } from "node:fs";
|
|
17
|
+
import {
|
|
18
|
+
mkdir,
|
|
19
|
+
mkdtemp,
|
|
20
|
+
readFile,
|
|
21
|
+
readdir,
|
|
22
|
+
realpath,
|
|
23
|
+
rm,
|
|
24
|
+
symlink,
|
|
25
|
+
writeFile,
|
|
26
|
+
} from "node:fs/promises";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { dirname, join, relative } from "node:path";
|
|
29
|
+
import type { ProfileStartupState } from "../services/profile-startup.js";
|
|
30
|
+
|
|
31
|
+
const SRC = join(import.meta.dir, "..");
|
|
32
|
+
const MODULES = {
|
|
33
|
+
ansi: join(SRC, "cli", "ansi.ts"),
|
|
34
|
+
startup: join(SRC, "services", "profile-startup.ts"),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const CHILD_TIMEOUT_MS = 45_000;
|
|
38
|
+
const CHILD_TEST_TIMEOUT_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* mode `old-need`: oldFormatNeed, then describeNeed / buildProfileOptions on its answer.
|
|
42
|
+
* mode `prepare`: prepareProfileStartup with no options. mode `convert`: with
|
|
43
|
+
* `{ convert: true }`.
|
|
44
|
+
*/
|
|
45
|
+
const OLD_FORMAT_CHILD = `
|
|
46
|
+
import { writeFileSync } from "node:fs";
|
|
47
|
+
|
|
48
|
+
const [mode, project, resultPath] = process.argv.slice(2);
|
|
49
|
+
const ansi = await import(${JSON.stringify(MODULES.ansi)});
|
|
50
|
+
const startup = await import(${JSON.stringify(MODULES.startup)});
|
|
51
|
+
|
|
52
|
+
ansi.setColorEnabled(false);
|
|
53
|
+
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
|
54
|
+
Object.defineProperty(stream, "isTTY", { value: false, configurable: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const out = {
|
|
58
|
+
need: null,
|
|
59
|
+
needWasNull: null,
|
|
60
|
+
described: null,
|
|
61
|
+
options: null,
|
|
62
|
+
updateFormatValue: startup.UPDATE_FORMAT_VALUE,
|
|
63
|
+
notices: null,
|
|
64
|
+
error: null,
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
if (mode === "old-need") {
|
|
68
|
+
const need = await startup.oldFormatNeed(project);
|
|
69
|
+
out.needWasNull = need === null;
|
|
70
|
+
if (need) {
|
|
71
|
+
out.need = { state: need.state };
|
|
72
|
+
out.described = startup.describeNeed(need);
|
|
73
|
+
out.options = startup.buildProfileOptions(need);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
const prepared = await startup.prepareProfileStartup(
|
|
77
|
+
project,
|
|
78
|
+
mode === "convert" ? { convert: true } : undefined,
|
|
79
|
+
);
|
|
80
|
+
out.need = { state: prepared.need.state };
|
|
81
|
+
out.notices = prepared.notices;
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
85
|
+
}
|
|
86
|
+
writeFileSync(resultPath, JSON.stringify(out));
|
|
87
|
+
process.exit(0);
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
interface OldFormatRun {
|
|
91
|
+
need: { state: ProfileStartupState } | null;
|
|
92
|
+
needWasNull: boolean | null;
|
|
93
|
+
described: { title: string; message: string } | null;
|
|
94
|
+
options: Array<{ label: string; value: string; meta?: string }> | null;
|
|
95
|
+
updateFormatValue: string;
|
|
96
|
+
notices: string[] | null;
|
|
97
|
+
error: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const scratch: string[] = [];
|
|
101
|
+
let helperDir = "";
|
|
102
|
+
let binDir = "";
|
|
103
|
+
|
|
104
|
+
async function tempRoot(prefix: string): Promise<string> {
|
|
105
|
+
const dir = await realpath(await mkdtemp(join(tmpdir(), prefix)));
|
|
106
|
+
scratch.push(dir);
|
|
107
|
+
return dir;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeJson(path: string, value: unknown): Promise<void> {
|
|
111
|
+
await mkdir(dirname(path), { recursive: true });
|
|
112
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
helperDir = await tempRoot("psof-helpers-");
|
|
117
|
+
binDir = join(helperDir, "bin");
|
|
118
|
+
await mkdir(binDir);
|
|
119
|
+
await symlink(process.execPath, join(binDir, "bun"));
|
|
120
|
+
const git = Bun.which("git");
|
|
121
|
+
if (git) await symlink(git, join(binDir, "git"));
|
|
122
|
+
await writeFile(join(helperDir, "old-format-child.ts"), OLD_FORMAT_CHILD);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
afterAll(async () => {
|
|
126
|
+
for (const dir of scratch) await rm(dir, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
interface Project {
|
|
130
|
+
base: string;
|
|
131
|
+
project: string;
|
|
132
|
+
env: Record<string, string>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Which tree a test gets. All three have `.claude/profiles.json` defining `team`.
|
|
137
|
+
* - `leftover`: the REQ-5 minimal tree — `.claude/_profiles/team/settings.json` and no
|
|
138
|
+
* symlink naming a profile (REQ-9a–9d, REQ-9f).
|
|
139
|
+
* - `symlinked`: a real magus 7.4.1 layout — the same, plus `.claude/settings.json` as a
|
|
140
|
+
* symlink to the RELATIVE path `_profiles/team/settings.json` (REQ-9e).
|
|
141
|
+
* - `current`: no `.claude/_profiles/` at all.
|
|
142
|
+
*/
|
|
143
|
+
type Layout = "leftover" | "symlinked" | "current";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A real git repository holding the chosen layout, everything committed. HOME,
|
|
147
|
+
* CLAUDE_CONFIG_DIR (`<HOME>/.claude`) and XDG_* are temp dirs beside it.
|
|
148
|
+
*/
|
|
149
|
+
async function repoProject(layout: Layout): Promise<Project> {
|
|
150
|
+
const base = await tempRoot("psof-");
|
|
151
|
+
const home = join(base, "home");
|
|
152
|
+
const xdg = join(base, "xdg");
|
|
153
|
+
await mkdir(join(home, ".claude"), { recursive: true });
|
|
154
|
+
for (const kind of ["config", "cache", "data", "state"]) {
|
|
155
|
+
await mkdir(join(xdg, kind), { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const env = {
|
|
158
|
+
PATH: `${binDir}:/usr/bin:/bin`,
|
|
159
|
+
HOME: home,
|
|
160
|
+
CLAUDE_CONFIG_DIR: join(home, ".claude"),
|
|
161
|
+
XDG_CONFIG_HOME: join(xdg, "config"),
|
|
162
|
+
XDG_CACHE_HOME: join(xdg, "cache"),
|
|
163
|
+
XDG_DATA_HOME: join(xdg, "data"),
|
|
164
|
+
XDG_STATE_HOME: join(xdg, "state"),
|
|
165
|
+
TMPDIR: tmpdir(),
|
|
166
|
+
NO_COLOR: "1",
|
|
167
|
+
TERM: "dumb",
|
|
168
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
169
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
170
|
+
};
|
|
171
|
+
const project = join(base, "proj");
|
|
172
|
+
await mkdir(project, { recursive: true });
|
|
173
|
+
const git = (...args: string[]): void => {
|
|
174
|
+
const r = Bun.spawnSync({
|
|
175
|
+
cmd: [
|
|
176
|
+
"git",
|
|
177
|
+
"-c",
|
|
178
|
+
"user.email=t@t",
|
|
179
|
+
"-c",
|
|
180
|
+
"user.name=t",
|
|
181
|
+
"-c",
|
|
182
|
+
"commit.gpgsign=false",
|
|
183
|
+
...args,
|
|
184
|
+
],
|
|
185
|
+
cwd: project,
|
|
186
|
+
env,
|
|
187
|
+
});
|
|
188
|
+
if (r.exitCode !== 0) {
|
|
189
|
+
throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
git("init", "-q");
|
|
193
|
+
if (layout !== "current") {
|
|
194
|
+
await writeJson(
|
|
195
|
+
join(project, ".claude", "_profiles", "team", "settings.json"),
|
|
196
|
+
{ enabledPlugins: { "dev@magus": true } },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (layout === "symlinked") {
|
|
200
|
+
await symlink(
|
|
201
|
+
"_profiles/team/settings.json",
|
|
202
|
+
join(project, ".claude", "settings.json"),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
await writeJson(join(project, ".claude", "profiles.json"), {
|
|
206
|
+
version: 2,
|
|
207
|
+
profiles: { team: { name: "team", plugins: { "dev@magus": true } } },
|
|
208
|
+
});
|
|
209
|
+
git("add", "-A");
|
|
210
|
+
git("commit", "-q", "-m", "init");
|
|
211
|
+
return { base, project, env };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runChild(
|
|
215
|
+
proj: Project,
|
|
216
|
+
mode: "old-need" | "prepare" | "convert",
|
|
217
|
+
): Promise<OldFormatRun> {
|
|
218
|
+
const resultPath = join(proj.base, `result-${mode}.json`);
|
|
219
|
+
const proc = Bun.spawn({
|
|
220
|
+
cmd: [
|
|
221
|
+
process.execPath,
|
|
222
|
+
"--no-env-file",
|
|
223
|
+
join(helperDir, "old-format-child.ts"),
|
|
224
|
+
mode,
|
|
225
|
+
proj.project,
|
|
226
|
+
resultPath,
|
|
227
|
+
],
|
|
228
|
+
cwd: proj.project,
|
|
229
|
+
env: proj.env,
|
|
230
|
+
stdin: "ignore",
|
|
231
|
+
stdout: "pipe",
|
|
232
|
+
stderr: "pipe",
|
|
233
|
+
});
|
|
234
|
+
const timer = setTimeout(() => proc.kill(), CHILD_TIMEOUT_MS);
|
|
235
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
236
|
+
new Response(proc.stdout).text(),
|
|
237
|
+
new Response(proc.stderr).text(),
|
|
238
|
+
proc.exited,
|
|
239
|
+
]);
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
let raw: string;
|
|
242
|
+
try {
|
|
243
|
+
raw = await readFile(resultPath, "utf8");
|
|
244
|
+
} catch {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`old-format child wrote no result (exit ${code}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return JSON.parse(raw) as OldFormatRun;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every directory and file of the working tree, with contents. `.git/` is left out:
|
|
254
|
+
* git rewrites its own index on a plain `git status`, which is not the project changing.
|
|
255
|
+
*/
|
|
256
|
+
async function snapshotWorkingTree(
|
|
257
|
+
root: string,
|
|
258
|
+
): Promise<Record<string, string>> {
|
|
259
|
+
const out: Record<string, string> = {};
|
|
260
|
+
async function walk(dir: string): Promise<void> {
|
|
261
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
262
|
+
const full = join(dir, entry.name);
|
|
263
|
+
const rel = relative(root, full);
|
|
264
|
+
if (rel === ".git") continue;
|
|
265
|
+
if (entry.isDirectory()) {
|
|
266
|
+
out[`${rel}/`] = "<dir>";
|
|
267
|
+
await walk(full);
|
|
268
|
+
} else {
|
|
269
|
+
out[rel] = (await readFile(full)).toString("base64");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await walk(root);
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
describe("REQ-9 an old profile format is one question for the CLI and the TUI", () => {
|
|
278
|
+
test(
|
|
279
|
+
'REQ-9a oldFormatNeed on an old-format project is in state "old-format"',
|
|
280
|
+
async () => {
|
|
281
|
+
const proj = await repoProject("leftover");
|
|
282
|
+
|
|
283
|
+
const run = await runChild(proj, "old-need");
|
|
284
|
+
|
|
285
|
+
expect(run.error).toBeNull();
|
|
286
|
+
expect(run.need?.state).toBe("old-format");
|
|
287
|
+
},
|
|
288
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
test(
|
|
292
|
+
"REQ-9a oldFormatNeed on a project without .claude/_profiles/ is null",
|
|
293
|
+
async () => {
|
|
294
|
+
const proj = await repoProject("current");
|
|
295
|
+
|
|
296
|
+
const run = await runChild(proj, "old-need");
|
|
297
|
+
|
|
298
|
+
expect(run.error).toBeNull();
|
|
299
|
+
expect(run.needWasNull).toBe(true);
|
|
300
|
+
},
|
|
301
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
test(
|
|
305
|
+
"REQ-9b the question offers exactly Update it now and Quit magus, and Update carries UPDATE_FORMAT_VALUE",
|
|
306
|
+
async () => {
|
|
307
|
+
const proj = await repoProject("leftover");
|
|
308
|
+
|
|
309
|
+
const run = await runChild(proj, "old-need");
|
|
310
|
+
|
|
311
|
+
expect(run.error).toBeNull();
|
|
312
|
+
expect(run.options?.map((option) => option.label)).toEqual([
|
|
313
|
+
"Update it now",
|
|
314
|
+
"Quit magus",
|
|
315
|
+
]);
|
|
316
|
+
expect(run.options?.[0]?.value).toBe(run.updateFormatValue);
|
|
317
|
+
},
|
|
318
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
test(
|
|
322
|
+
"REQ-9c describeNeed titles it with the same line the CLI gate prints",
|
|
323
|
+
async () => {
|
|
324
|
+
const proj = await repoProject("leftover");
|
|
325
|
+
|
|
326
|
+
const run = await runChild(proj, "old-need");
|
|
327
|
+
|
|
328
|
+
expect(run.error).toBeNull();
|
|
329
|
+
expect(run.described?.title).toBe(
|
|
330
|
+
"This project uses an old magus profile format",
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
test(
|
|
337
|
+
'REQ-9d prepareProfileStartup without convert answers "old-format" and changes nothing',
|
|
338
|
+
async () => {
|
|
339
|
+
const proj = await repoProject("leftover");
|
|
340
|
+
const before = await snapshotWorkingTree(proj.project);
|
|
341
|
+
|
|
342
|
+
const run = await runChild(proj, "prepare");
|
|
343
|
+
|
|
344
|
+
expect(run.error).toBeNull();
|
|
345
|
+
expect(run.need?.state).toBe("old-format");
|
|
346
|
+
expect(await snapshotWorkingTree(proj.project)).toEqual(before);
|
|
347
|
+
},
|
|
348
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
test(
|
|
352
|
+
"REQ-9e on a real symlinked old layout, convert removes .claude/_profiles/ and leaves settings.json a regular file",
|
|
353
|
+
async () => {
|
|
354
|
+
const proj = await repoProject("symlinked");
|
|
355
|
+
const oldDir = join(proj.project, ".claude", "_profiles");
|
|
356
|
+
const settings = join(proj.project, ".claude", "settings.json");
|
|
357
|
+
expect(existsSync(oldDir)).toBe(true);
|
|
358
|
+
expect(lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
359
|
+
expect(readlinkSync(settings)).toBe("_profiles/team/settings.json");
|
|
360
|
+
|
|
361
|
+
const run = await runChild(proj, "convert");
|
|
362
|
+
|
|
363
|
+
expect(run.error).toBeNull();
|
|
364
|
+
expect(existsSync(oldDir)).toBe(false);
|
|
365
|
+
expect(existsSync(settings)).toBe(true);
|
|
366
|
+
const after = lstatSync(settings);
|
|
367
|
+
expect({
|
|
368
|
+
symlink: after.isSymbolicLink(),
|
|
369
|
+
regularFile: after.isFile(),
|
|
370
|
+
}).toEqual({ symlink: false, regularFile: true });
|
|
371
|
+
expect(run.need).not.toBeNull();
|
|
372
|
+
expect(run.need?.state).not.toBe("old-format");
|
|
373
|
+
},
|
|
374
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
test(
|
|
378
|
+
'REQ-9f on the leftover tree (no symlink naming a profile), convert asks which profile: "inactive", never "old-format"',
|
|
379
|
+
async () => {
|
|
380
|
+
const proj = await repoProject("leftover");
|
|
381
|
+
|
|
382
|
+
const run = await runChild(proj, "convert");
|
|
383
|
+
|
|
384
|
+
expect(run.error).toBeNull();
|
|
385
|
+
expect(run.need?.state).toBe("inactive");
|
|
386
|
+
expect(run.need?.state).not.toBe("old-format");
|
|
387
|
+
},
|
|
388
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
389
|
+
);
|
|
390
|
+
});
|