magus-cli 7.7.0 → 7.9.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.
Files changed (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The TUI's dependency flow — the one the Plugins screen runs after a plugin
3
+ * installs and the CLI Tools screen runs for a tool a plugin owns: probe →
4
+ * planDeps → one confirm listing every step → applyDepsPlan → a failure popup
5
+ * built from the same ApplyOutcome[] the CLI renders. PATH, HOME and the cache
6
+ * dir are temp dirs for the duration.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ chmodSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ mkdtempSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import type { ResolvedBin } from "../types/index.js";
21
+ import {
22
+ type DepsModal,
23
+ depsFailureReport,
24
+ installBinsInTui,
25
+ } from "../ui/adapters/depsAdapter.js";
26
+ import type { FailureReport } from "../ui/state/types.js";
27
+
28
+ let root: string;
29
+ let tools: string;
30
+ const saved = {
31
+ PATH: process.env.PATH,
32
+ HOME: process.env.HOME,
33
+ XDG: process.env.XDG_CACHE_HOME,
34
+ };
35
+ beforeEach(() => {
36
+ root = mkdtempSync(join(tmpdir(), "deps-adapter-"));
37
+ tools = join(root, "tools");
38
+ mkdirSync(tools);
39
+ mkdirSync(join(root, "home"));
40
+ process.env.PATH = `${tools}:/usr/bin:/bin`;
41
+ process.env.HOME = join(root, "home");
42
+ process.env.XDG_CACHE_HOME = join(root, "cache");
43
+ });
44
+ afterEach(() => {
45
+ process.env.PATH = saved.PATH;
46
+ process.env.HOME = saved.HOME;
47
+ if (saved.XDG === undefined)
48
+ Reflect.deleteProperty(process.env, "XDG_CACHE_HOME");
49
+ else process.env.XDG_CACHE_HOME = saved.XDG;
50
+ rmSync(root, { recursive: true, force: true });
51
+ });
52
+
53
+ function fakeBrew(fails = false): void {
54
+ const brew = join(tools, "brew");
55
+ writeFileSync(
56
+ brew,
57
+ fails
58
+ ? "#!/bin/sh\necho 'Error: No available formula' >&2\nexit 1\n"
59
+ : `#!/bin/sh\nprintf '#!/bin/sh\\nexit 0\\n' > "${tools}/$2"\nchmod 755 "${tools}/$2"\n`,
60
+ );
61
+ chmodSync(brew, 0o755);
62
+ }
63
+
64
+ const owned: ResolvedBin = {
65
+ name: "faketool",
66
+ check: ["faketool", "--version"],
67
+ install: [{ via: "brew", formula: "faketool" }],
68
+ chosen: { via: "brew", formula: "faketool" },
69
+ sources: ["plugin:fake@magus"],
70
+ };
71
+
72
+ function modal(answer: boolean) {
73
+ const seen = {
74
+ confirms: [] as string[],
75
+ loading: [] as string[],
76
+ reports: [] as FailureReport[],
77
+ };
78
+ const m: DepsModal = {
79
+ confirm: async (_title, message) => {
80
+ seen.confirms.push(message);
81
+ return answer;
82
+ },
83
+ loading: (msg) => seen.loading.push(msg),
84
+ hideModal: () => {},
85
+ failures: async (r) => {
86
+ seen.reports.push(r);
87
+ },
88
+ };
89
+ return { m, seen };
90
+ }
91
+
92
+ describe("installBinsInTui", () => {
93
+ test("asks once with every step, installs through the executor, and stays quiet on success", async () => {
94
+ fakeBrew();
95
+ const { m, seen } = modal(true);
96
+ await installBinsInTui("fake@magus", [owned], m);
97
+ expect(seen.confirms).toHaveLength(1);
98
+ expect(seen.confirms[0]).toContain(
99
+ "faketool: brew install faketool (fake@magus)",
100
+ );
101
+ expect(seen.loading).toContain("Installing faketool...");
102
+ expect(existsSync(join(tools, "faketool"))).toBe(true);
103
+ expect(seen.reports).toEqual([]);
104
+ });
105
+
106
+ test("no means nothing runs", async () => {
107
+ fakeBrew();
108
+ const { m } = modal(false);
109
+ await installBinsInTui("fake@magus", [owned], m);
110
+ expect(existsSync(join(tools, "faketool"))).toBe(false);
111
+ });
112
+
113
+ test("a failed install is a popup with the installer's words and the command", async () => {
114
+ fakeBrew(true);
115
+ const { m, seen } = modal(true);
116
+ await installBinsInTui("fake@magus", [owned], m);
117
+ expect(seen.reports).toHaveLength(1);
118
+ const [row] = seen.reports[0]?.rows ?? [];
119
+ expect(row?.subject).toBe("faketool");
120
+ expect(row?.reason).toContain("No available formula");
121
+ const fix = row?.fix ?? seen.reports[0]?.fix;
122
+ expect(fix).toBe("brew install faketool");
123
+ });
124
+
125
+ test("present already: no question at all", async () => {
126
+ fakeBrew();
127
+ writeFileSync(join(tools, "faketool"), "#!/bin/sh\nexit 0\n");
128
+ chmodSync(join(tools, "faketool"), 0o755);
129
+ const { m, seen } = modal(true);
130
+ await installBinsInTui("fake@magus", [owned], m);
131
+ expect(seen.confirms).toEqual([]);
132
+ expect(seen.reports).toEqual([]);
133
+ });
134
+ });
135
+
136
+ describe("depsFailureReport", () => {
137
+ test("needs-password, unfixable and a PATH line each say what to do", () => {
138
+ const report = depsFailureReport(
139
+ "terminal@magus",
140
+ [
141
+ {
142
+ name: "tmux, zsh",
143
+ status: "needs-password",
144
+ requiredBy: [],
145
+ exitCode: null,
146
+ outputTail: "",
147
+ durationMs: 0,
148
+ remedy: "run: sudo -v then: magus doctor --fix",
149
+ },
150
+ ],
151
+ {
152
+ unfixable: [{ name: "x", why: "needs Go" }],
153
+ pathAdvice: [
154
+ {
155
+ prefix: "~/.bun/bin",
156
+ rcFile: "~/.zshrc",
157
+ rcLine: 'export PATH="$HOME/.bun/bin:$PATH"',
158
+ },
159
+ ],
160
+ },
161
+ );
162
+ expect(report?.headline).toBe("3 dependencies of terminal@magus need you");
163
+ expect(report?.rows.map((r) => r.subject)).toEqual([
164
+ "tmux, zsh",
165
+ "x",
166
+ "~/.bun/bin",
167
+ ]);
168
+ expect(report?.rows[0]?.fix).toBe(
169
+ "run: sudo -v then: magus doctor --fix",
170
+ );
171
+ });
172
+
173
+ test("all installed and nothing else to say: no popup", () => {
174
+ expect(
175
+ depsFailureReport(
176
+ "p",
177
+ [
178
+ {
179
+ name: "a",
180
+ status: "installed",
181
+ requiredBy: [],
182
+ exitCode: 0,
183
+ outputTail: "",
184
+ durationMs: 1,
185
+ },
186
+ ],
187
+ { unfixable: [], pathAdvice: [] },
188
+ ),
189
+ ).toBeNull();
190
+ });
191
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The SessionStart deps-check hook, run as Claude Code runs it: a child process
3
+ * with the hook input on stdin, `CLAUDE_PLUGIN_ROOT` set, and the label as argv.
4
+ * PATH, HOME and TMPDIR are temp dirs, so the result does not depend on what
5
+ * this machine has installed.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import {
22
+ type Requirement,
23
+ dedupeKey,
24
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
25
+
26
+ const HOOK = join(
27
+ import.meta.dir,
28
+ "..",
29
+ "shared",
30
+ "plugin",
31
+ "hooks",
32
+ "deps-check.ts",
33
+ );
34
+
35
+ const JQ: Requirement = {
36
+ name: "jq",
37
+ check: ["jq", "--version"],
38
+ install: [{ via: "apt", packages: ["jq"] }],
39
+ };
40
+
41
+ let root: string;
42
+ let bin: string;
43
+ let tmp: string;
44
+ beforeEach(() => {
45
+ root = mkdtempSync(join(tmpdir(), "deps-check-"));
46
+ bin = join(root, "bin");
47
+ tmp = join(root, "tmp");
48
+ mkdirSync(bin);
49
+ mkdirSync(tmp);
50
+ mkdirSync(join(root, "home"));
51
+ tool("apt-get");
52
+ });
53
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
54
+
55
+ function tool(name: string): void {
56
+ writeFileSync(join(bin, name), "#!/bin/sh\nexit 0\n");
57
+ chmodSync(join(bin, name), 0o755);
58
+ }
59
+
60
+ function plugin(label: string, requires: unknown): string {
61
+ const dir = join(root, "plugins", label);
62
+ mkdirSync(join(dir, ".claude-plugin"), { recursive: true });
63
+ writeFileSync(
64
+ join(dir, ".claude-plugin", "plugin.json"),
65
+ JSON.stringify({ name: label, requires }),
66
+ );
67
+ return dir;
68
+ }
69
+
70
+ function runHook(
71
+ label: string,
72
+ pluginRoot: string,
73
+ sessionId: string | null = "s-1",
74
+ extraInput: Record<string, unknown> = {},
75
+ ): { stdout: string; exitCode: number } {
76
+ const r = Bun.spawnSync(
77
+ [
78
+ process.execPath,
79
+ "--env-file=/dev/null",
80
+ "--config=/dev/null",
81
+ HOOK,
82
+ label,
83
+ ],
84
+ {
85
+ stdin: Buffer.from(
86
+ JSON.stringify({
87
+ ...(sessionId ? { session_id: sessionId } : {}),
88
+ ...extraInput,
89
+ }),
90
+ ),
91
+ env: {
92
+ PATH: bin,
93
+ HOME: join(root, "home"),
94
+ TMPDIR: tmp,
95
+ SHELL: "/bin/zsh",
96
+ CLAUDE_PLUGIN_ROOT: pluginRoot,
97
+ },
98
+ },
99
+ );
100
+ return { stdout: r.stdout.toString(), exitCode: r.exitCode };
101
+ }
102
+
103
+ const ledger = (sessionId = "s-1") => join(tmp, `magus-deps-${sessionId}`);
104
+
105
+ describe("deps-check hook", () => {
106
+ test("healthy: prints nothing, and the ledger proves it ran", () => {
107
+ tool("jq");
108
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
109
+ const r = runHook("dev", dir);
110
+ expect(r).toEqual({ stdout: "", exitCode: 0 });
111
+ expect(readFileSync(join(ledger(), "seen", "dev"), "utf8")).toBe("ts");
112
+ });
113
+
114
+ test("missing: one systemMessage with the marker, the dep and its fix", () => {
115
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
116
+ const r = runHook("dev", dir);
117
+ expect(r.exitCode).toBe(0);
118
+ const out = JSON.parse(r.stdout) as {
119
+ systemMessage: string;
120
+ hookSpecificOutput: { hookEventName: string; additionalContext: string };
121
+ };
122
+ expect(out.systemMessage.split("\n")[0]).toBe(
123
+ "magus-deps · dev: missing jq.",
124
+ );
125
+ expect(out.systemMessage).toContain(
126
+ "Fix all: bun add -g magus-cli, then magus doctor --fix",
127
+ );
128
+ expect(out.systemMessage).toContain(
129
+ "jq: sudo apt-get install -y --no-install-recommends jq",
130
+ );
131
+ expect(out.hookSpecificOutput.hookEventName).toBe("SessionStart");
132
+ expect(readdirSync(join(ledger(), "claimed"))).toEqual([dedupeKey(JQ)]);
133
+ });
134
+
135
+ test("magus on PATH: the fix names magus alone", () => {
136
+ tool("magus");
137
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
138
+ const out = JSON.parse(runHook("dev", dir).stdout) as {
139
+ systemMessage: string;
140
+ };
141
+ expect(out.systemMessage).toContain("Fix all: magus doctor --fix");
142
+ expect(out.systemMessage).not.toContain("magus-cli");
143
+ });
144
+
145
+ test("the fix runs doctor against the directory the session runs in", () => {
146
+ tool("magus");
147
+ const message = (input: Record<string, unknown>, session: string) =>
148
+ (
149
+ JSON.parse(
150
+ runHook(
151
+ "dev",
152
+ plugin("dev", { schema: 1, bin: [JQ] }),
153
+ session,
154
+ input,
155
+ ).stdout,
156
+ ) as { systemMessage: string }
157
+ ).systemMessage;
158
+ expect(message({ cwd: "/work/repo" }, "s-a")).toContain(
159
+ "Fix all: magus doctor --fix --project /work/repo\n",
160
+ );
161
+ // Quoted for the shell it is pasted into.
162
+ expect(message({ cwd: "/work/my repo" }, "s-b")).toContain(
163
+ "Fix all: magus doctor --fix --project '/work/my repo'\n",
164
+ );
165
+ // Not a directory a terminal can name: the fix is the bare command.
166
+ for (const [cwd, session] of [
167
+ ["relative/dir", "s-c"],
168
+ ["/work/a\nb", "s-d"],
169
+ ] as const) {
170
+ expect(message({ cwd }, session)).toContain(
171
+ "Fix all: magus doctor --fix\n",
172
+ );
173
+ }
174
+ });
175
+
176
+ test("one identical declaration in two plugins is reported once per session", () => {
177
+ const first = runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
178
+ const second = runHook(
179
+ "code-search",
180
+ plugin("code-search", { schema: 1, bin: [JQ] }),
181
+ );
182
+ expect(first.stdout).toContain("magus-deps · dev: missing jq");
183
+ expect(second.stdout).toBe("");
184
+ expect(readdirSync(join(ledger(), "seen")).sort()).toEqual([
185
+ "code-search",
186
+ "dev",
187
+ ]);
188
+ });
189
+
190
+ test("no readable session id: no ledger, and the banner still prints", () => {
191
+ const dir = plugin("dev", { schema: 1, bin: [JQ] });
192
+ const r = runHook("dev", dir, null);
193
+ expect(r.stdout).toContain("magus-deps · dev: missing jq");
194
+ expect(existsSync(tmp) && readdirSync(tmp)).toEqual([]);
195
+ });
196
+
197
+ test("an invalid declaration is reported, not swallowed", () => {
198
+ const dir = plugin("dev", { schema: 1, bin: [{ name: "jq" }] });
199
+ const out = JSON.parse(runHook("dev", dir).stdout) as {
200
+ systemMessage: string;
201
+ };
202
+ expect(out.systemMessage).toStartWith(
203
+ "magus-deps · dev: requires is invalid (",
204
+ );
205
+ expect(out.systemMessage).toEndWith("— please report");
206
+ });
207
+
208
+ test("a ledger older than a day is pruned; a fresh one is kept", () => {
209
+ const old = join(tmp, "magus-deps-old");
210
+ const fresh = join(tmp, "magus-deps-fresh");
211
+ mkdirSync(old);
212
+ mkdirSync(fresh);
213
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
214
+ require("node:fs").utimesSync(old, twoDaysAgo, twoDaysAgo);
215
+ tool("jq");
216
+ runHook("dev", plugin("dev", { schema: 1, bin: [JQ] }));
217
+ expect(existsSync(old)).toBe(false);
218
+ expect(existsSync(fresh)).toBe(true);
219
+ });
220
+ });