magus-cli 7.8.0 → 7.9.1
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/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +897 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +1154 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +143 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type ApplyOptions,
|
|
8
|
+
type DepsPlan,
|
|
9
|
+
applyDepsPlan,
|
|
10
|
+
} from "../../../services/deps-plan.js";
|
|
11
|
+
import type { RunResult } from "../../../utils/run.js";
|
|
12
|
+
import { makeWorld, removeWorld } from "../plugin-deps/helpers.js";
|
|
13
|
+
|
|
14
|
+
const plan: DepsPlan = {
|
|
15
|
+
pathAdvice: [],
|
|
16
|
+
steps: [
|
|
17
|
+
{
|
|
18
|
+
installs: [
|
|
19
|
+
{
|
|
20
|
+
pluginRoot: "",
|
|
21
|
+
requirement: {
|
|
22
|
+
check: ["widget", "--version"],
|
|
23
|
+
install: [],
|
|
24
|
+
name: "widget",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
needs: [],
|
|
29
|
+
requiredBy: ["testdata@test"],
|
|
30
|
+
step: {
|
|
31
|
+
argv: ["installer", "widget"],
|
|
32
|
+
escalation: "none",
|
|
33
|
+
kind: "argv",
|
|
34
|
+
name: "widget",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
unfixable: [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function successfulRun(): Promise<RunResult> {
|
|
42
|
+
return Promise.resolve({
|
|
43
|
+
durationMs: 0,
|
|
44
|
+
exitCode: 0,
|
|
45
|
+
outputTail: "",
|
|
46
|
+
signal: null,
|
|
47
|
+
timedOut: false,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("a lock that disappears during inspection is retried", async () => {
|
|
52
|
+
const world = await makeWorld();
|
|
53
|
+
try {
|
|
54
|
+
const lockPath = join(world.cache, "doctor-fix.lock");
|
|
55
|
+
await writeFile(lockPath, "4242");
|
|
56
|
+
let removed = false;
|
|
57
|
+
const options: ApplyOptions = {
|
|
58
|
+
confirm: async () => true,
|
|
59
|
+
env: {
|
|
60
|
+
arch: "amd64",
|
|
61
|
+
home: world.home,
|
|
62
|
+
os: "linux",
|
|
63
|
+
path: world.bin,
|
|
64
|
+
userShell: join(world.bin, "zsh"),
|
|
65
|
+
},
|
|
66
|
+
io: {
|
|
67
|
+
alive: () => {
|
|
68
|
+
if (!removed) {
|
|
69
|
+
removed = true;
|
|
70
|
+
rmSync(lockPath);
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
},
|
|
74
|
+
run: successfulRun,
|
|
75
|
+
uid: () => 1000,
|
|
76
|
+
verify: async ({ requirement }) => ({
|
|
77
|
+
name: requirement.name,
|
|
78
|
+
status: "present",
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
lockPath,
|
|
82
|
+
yes: true,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const outcomes = await applyDepsPlan(plan, options);
|
|
86
|
+
expect(outcomes).toMatchObject([{ name: "widget", status: "installed" }]);
|
|
87
|
+
} finally {
|
|
88
|
+
await removeWorld(world);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, utimes, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ApplyOptions,
|
|
9
|
+
type DepsPlan,
|
|
10
|
+
applyDepsPlan,
|
|
11
|
+
} from "../../../services/deps-plan.js";
|
|
12
|
+
import { makeWorld, removeWorld } from "../plugin-deps/helpers.js";
|
|
13
|
+
|
|
14
|
+
test("an apply removes abandoned release directories from the user bin directory", async () => {
|
|
15
|
+
const world = await makeWorld();
|
|
16
|
+
try {
|
|
17
|
+
const destination = join(world.home, ".local", "bin");
|
|
18
|
+
const abandoned = join(destination, ".magus-tmp-abandoned");
|
|
19
|
+
await mkdir(abandoned, { recursive: true });
|
|
20
|
+
await writeFile(join(abandoned, "partial-download"), "interrupted");
|
|
21
|
+
const old = new Date("2020-01-01T00:00:00.000Z");
|
|
22
|
+
await utimes(join(abandoned, "partial-download"), old, old);
|
|
23
|
+
await utimes(abandoned, old, old);
|
|
24
|
+
|
|
25
|
+
const body = new TextEncoder().encode("widget binary\n");
|
|
26
|
+
const digest = createHash("sha256").update(body).digest("hex");
|
|
27
|
+
const plan: DepsPlan = {
|
|
28
|
+
pathAdvice: [],
|
|
29
|
+
steps: [
|
|
30
|
+
{
|
|
31
|
+
installs: [
|
|
32
|
+
{
|
|
33
|
+
pluginRoot: "",
|
|
34
|
+
requirement: {
|
|
35
|
+
check: ["widget", "--version"],
|
|
36
|
+
install: [],
|
|
37
|
+
name: "widget",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
needs: [],
|
|
42
|
+
requiredBy: ["testdata@test"],
|
|
43
|
+
step: {
|
|
44
|
+
binary: "widget",
|
|
45
|
+
dest: "~/.local/bin",
|
|
46
|
+
format: "binary",
|
|
47
|
+
kind: "release",
|
|
48
|
+
name: "widget",
|
|
49
|
+
sha256: digest,
|
|
50
|
+
url: "https://example.test/widget",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
unfixable: [],
|
|
55
|
+
};
|
|
56
|
+
const options: ApplyOptions = {
|
|
57
|
+
confirm: async () => true,
|
|
58
|
+
env: {
|
|
59
|
+
arch: "amd64",
|
|
60
|
+
home: world.home,
|
|
61
|
+
os: "linux",
|
|
62
|
+
path: world.bin,
|
|
63
|
+
userShell: join(world.bin, "zsh"),
|
|
64
|
+
},
|
|
65
|
+
io: {
|
|
66
|
+
alive: () => false,
|
|
67
|
+
download: async () => body,
|
|
68
|
+
uid: () => 1000,
|
|
69
|
+
verify: async ({ requirement }) => ({
|
|
70
|
+
name: requirement.name,
|
|
71
|
+
status: "present",
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
lockPath: join(world.cache, "doctor-fix.lock"),
|
|
75
|
+
yes: true,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await applyDepsPlan(plan, options);
|
|
79
|
+
expect(existsSync(abandoned)).toBe(false);
|
|
80
|
+
} finally {
|
|
81
|
+
await removeWorld(world);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ApplyIO,
|
|
7
|
+
type ApplyOptions,
|
|
8
|
+
type DepsPlan,
|
|
9
|
+
applyDepsPlan,
|
|
10
|
+
} from "../../../services/deps-plan.js";
|
|
11
|
+
import {
|
|
12
|
+
makeWorld,
|
|
13
|
+
removeWorld,
|
|
14
|
+
writeExecutable,
|
|
15
|
+
} from "../plugin-deps/helpers.js";
|
|
16
|
+
|
|
17
|
+
function options(
|
|
18
|
+
world: Awaited<ReturnType<typeof makeWorld>>,
|
|
19
|
+
lockName: string,
|
|
20
|
+
verify: ApplyIO["verify"],
|
|
21
|
+
): ApplyOptions {
|
|
22
|
+
return {
|
|
23
|
+
confirm: async () => true,
|
|
24
|
+
env: {
|
|
25
|
+
arch: "amd64",
|
|
26
|
+
home: world.home,
|
|
27
|
+
os: "linux",
|
|
28
|
+
path: world.bin,
|
|
29
|
+
userShell: join(world.bin, "zsh"),
|
|
30
|
+
},
|
|
31
|
+
io: {
|
|
32
|
+
alive: () => false,
|
|
33
|
+
uid: () => 1000,
|
|
34
|
+
verify,
|
|
35
|
+
},
|
|
36
|
+
lockPath: join(world.cache, lockName),
|
|
37
|
+
yes: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("tool answers use stdout without treating stderr warnings as paths", async () => {
|
|
42
|
+
const world = await makeWorld();
|
|
43
|
+
try {
|
|
44
|
+
await writeExecutable(
|
|
45
|
+
join(world.bin, "uv"),
|
|
46
|
+
[
|
|
47
|
+
'if [ "$1" = "python" ] && [ "$2" = "find" ]; then',
|
|
48
|
+
' printf "%s\\n" "${0%/*}/python-env"',
|
|
49
|
+
" i=0",
|
|
50
|
+
' while [ "$i" -lt 100000 ]; do i=$((i + 1)); done',
|
|
51
|
+
' printf "uv warning: cache metadata is stale\\n" >&2',
|
|
52
|
+
"fi",
|
|
53
|
+
"exit 0",
|
|
54
|
+
].join("\n"),
|
|
55
|
+
);
|
|
56
|
+
await writeExecutable(join(world.bin, "python-env"), "exit 0");
|
|
57
|
+
const uvPlan: DepsPlan = {
|
|
58
|
+
pathAdvice: [],
|
|
59
|
+
steps: [
|
|
60
|
+
{
|
|
61
|
+
installs: [
|
|
62
|
+
{
|
|
63
|
+
pluginRoot: world.root,
|
|
64
|
+
requirement: {
|
|
65
|
+
check: ["chromium", "--version"],
|
|
66
|
+
install: [],
|
|
67
|
+
name: "chromium",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
needs: [],
|
|
72
|
+
requiredBy: ["browser-use@test"],
|
|
73
|
+
step: {
|
|
74
|
+
args: ["install", "chromium"],
|
|
75
|
+
escalation: "none",
|
|
76
|
+
kind: "uv-module",
|
|
77
|
+
module: "playwright",
|
|
78
|
+
name: "chromium",
|
|
79
|
+
script: join(world.root, "scripts", "browser.py"),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
unfixable: [],
|
|
84
|
+
};
|
|
85
|
+
const [uvOutcome] = await applyDepsPlan(
|
|
86
|
+
uvPlan,
|
|
87
|
+
options(world, "uv.lock", async ({ requirement }) => ({
|
|
88
|
+
name: requirement.name,
|
|
89
|
+
status: "present",
|
|
90
|
+
})),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const globalBin = join(world.bin, "bun-global");
|
|
94
|
+
await mkdir(globalBin, { recursive: true });
|
|
95
|
+
await writeExecutable(join(globalBin, "widget"), "exit 0");
|
|
96
|
+
await writeExecutable(
|
|
97
|
+
join(world.bin, "bun"),
|
|
98
|
+
[
|
|
99
|
+
'if [ "$1" = "pm" ] && [ "$2" = "bin" ]; then',
|
|
100
|
+
' printf "%s\\n" "${0%/*}/bun-global"',
|
|
101
|
+
" i=0",
|
|
102
|
+
' while [ "$i" -lt 100000 ]; do i=$((i + 1)); done',
|
|
103
|
+
' printf "bun warning: registry metadata is stale\\n" >&2',
|
|
104
|
+
"fi",
|
|
105
|
+
"exit 0",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
);
|
|
108
|
+
const bunPlan: DepsPlan = {
|
|
109
|
+
pathAdvice: [],
|
|
110
|
+
steps: [
|
|
111
|
+
{
|
|
112
|
+
installs: [
|
|
113
|
+
{
|
|
114
|
+
pluginRoot: "",
|
|
115
|
+
requirement: {
|
|
116
|
+
check: ["widget", "--version"],
|
|
117
|
+
install: [],
|
|
118
|
+
name: "widget",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
needs: [],
|
|
123
|
+
requiredBy: ["testdata@test"],
|
|
124
|
+
step: {
|
|
125
|
+
argv: ["bun", "add", "-g", "widget"],
|
|
126
|
+
escalation: "none",
|
|
127
|
+
kind: "argv",
|
|
128
|
+
name: "widget",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
unfixable: [],
|
|
133
|
+
};
|
|
134
|
+
const [bunOutcome] = await applyDepsPlan(
|
|
135
|
+
bunPlan,
|
|
136
|
+
options(world, "bun.lock", async ({ requirement }) => ({
|
|
137
|
+
name: requirement.name,
|
|
138
|
+
status: "missing",
|
|
139
|
+
})),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect({
|
|
143
|
+
bunGlobalBin: bunOutcome?.dir,
|
|
144
|
+
uvInterpreter: uvOutcome?.status,
|
|
145
|
+
}).toEqual({
|
|
146
|
+
bunGlobalBin: globalBin,
|
|
147
|
+
uvInterpreter: "installed",
|
|
148
|
+
});
|
|
149
|
+
} finally {
|
|
150
|
+
await removeWorld(world);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { REAL_APPLY_IO } from "../../../services/deps-plan.js";
|
|
4
|
+
|
|
5
|
+
const HTTPS_URL = "https://example.test/install.sh";
|
|
6
|
+
const HTTP_URL = "http://mirror.example.test/install.sh";
|
|
7
|
+
|
|
8
|
+
function responseAt(
|
|
9
|
+
url: string,
|
|
10
|
+
body: BodyInit | null,
|
|
11
|
+
init: ResponseInit,
|
|
12
|
+
redirected = false,
|
|
13
|
+
): Response {
|
|
14
|
+
const response = new Response(body, init);
|
|
15
|
+
Object.defineProperty(response, "url", { value: url });
|
|
16
|
+
if (redirected) {
|
|
17
|
+
Object.defineProperty(response, "redirected", { value: true });
|
|
18
|
+
}
|
|
19
|
+
return response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("an installer script redirected to plain HTTP is rejected", async () => {
|
|
23
|
+
const realFetch = globalThis.fetch;
|
|
24
|
+
const requestedUrls: string[] = [];
|
|
25
|
+
const stubFetch: typeof fetch = async (input, init) => {
|
|
26
|
+
const url =
|
|
27
|
+
typeof input === "string"
|
|
28
|
+
? input
|
|
29
|
+
: input instanceof URL
|
|
30
|
+
? input.href
|
|
31
|
+
: input.url;
|
|
32
|
+
const redirect =
|
|
33
|
+
init?.redirect ?? (input instanceof Request ? input.redirect : "follow");
|
|
34
|
+
requestedUrls.push(url);
|
|
35
|
+
|
|
36
|
+
if (url === HTTPS_URL) {
|
|
37
|
+
if (redirect === "manual") {
|
|
38
|
+
return responseAt(HTTPS_URL, null, {
|
|
39
|
+
headers: { Location: HTTP_URL },
|
|
40
|
+
status: 302,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return stubFetch(HTTP_URL, { ...init, redirect: "follow" }).then(
|
|
44
|
+
(response) => {
|
|
45
|
+
Object.defineProperty(response, "redirected", { value: true });
|
|
46
|
+
return response;
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (url === HTTP_URL) {
|
|
51
|
+
return responseAt(HTTP_URL, "#!/bin/sh\n", { status: 200 });
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
globalThis.fetch = stubFetch;
|
|
57
|
+
let rejection: unknown;
|
|
58
|
+
try {
|
|
59
|
+
await REAL_APPLY_IO.download(HTTPS_URL);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
rejection = error;
|
|
62
|
+
} finally {
|
|
63
|
+
globalThis.fetch = realFetch;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect({
|
|
67
|
+
errorNamesHttp:
|
|
68
|
+
rejection instanceof Error && rejection.message.includes(HTTP_URL),
|
|
69
|
+
requestedUrls,
|
|
70
|
+
}).toEqual({
|
|
71
|
+
errorNamesHttp: true,
|
|
72
|
+
requestedUrls: [HTTPS_URL],
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type World,
|
|
7
|
+
hookCommand,
|
|
8
|
+
linkExecutable,
|
|
9
|
+
makeHookPlugin,
|
|
10
|
+
makeShellPath,
|
|
11
|
+
makeWorld,
|
|
12
|
+
pinnedEnv,
|
|
13
|
+
removeWorld,
|
|
14
|
+
runChild,
|
|
15
|
+
sessionInput,
|
|
16
|
+
} from "../plugin-deps/helpers";
|
|
17
|
+
|
|
18
|
+
async function runHookWithPinnedUmask(
|
|
19
|
+
world: World,
|
|
20
|
+
shellPath: string,
|
|
21
|
+
pluginRoot: string,
|
|
22
|
+
label: string,
|
|
23
|
+
pathDirs: string[],
|
|
24
|
+
sessionId: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await runChild(
|
|
27
|
+
join(shellPath, "sh"),
|
|
28
|
+
["-c", `umask 022; ${hookCommand(label)}`],
|
|
29
|
+
{
|
|
30
|
+
cwd: world.project,
|
|
31
|
+
env: pinnedEnv(world, pathDirs, {
|
|
32
|
+
BUN_INSTALL: join(world.home, ".bun"),
|
|
33
|
+
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
34
|
+
SHELL: join(shellPath, "sh"),
|
|
35
|
+
}),
|
|
36
|
+
input: sessionInput(sessionId),
|
|
37
|
+
timeoutMs: 9_500,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("the TypeScript hook and shell fallback create ledger directories private to the user", async () => {
|
|
43
|
+
const world = await makeWorld();
|
|
44
|
+
try {
|
|
45
|
+
const plugin = await makeHookPlugin(world, "private-ledger", {
|
|
46
|
+
schema: 1,
|
|
47
|
+
bin: [],
|
|
48
|
+
});
|
|
49
|
+
const shellPath = await makeShellPath(world);
|
|
50
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
51
|
+
await runHookWithPinnedUmask(
|
|
52
|
+
world,
|
|
53
|
+
shellPath,
|
|
54
|
+
plugin,
|
|
55
|
+
"typescript",
|
|
56
|
+
[world.bin, shellPath],
|
|
57
|
+
"typescript-ledger",
|
|
58
|
+
);
|
|
59
|
+
await runHookWithPinnedUmask(
|
|
60
|
+
world,
|
|
61
|
+
shellPath,
|
|
62
|
+
plugin,
|
|
63
|
+
"fallback",
|
|
64
|
+
[shellPath],
|
|
65
|
+
"fallback-ledger",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const modes = await Promise.all(
|
|
69
|
+
["typescript-ledger", "fallback-ledger"].map(
|
|
70
|
+
async (sessionId) =>
|
|
71
|
+
(await stat(join(world.tmp, `magus-deps-${sessionId}`))).mode & 0o777,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
expect(modes).toEqual([0o700, 0o700]);
|
|
75
|
+
} finally {
|
|
76
|
+
await removeWorld(world);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
chmod,
|
|
5
|
+
mkdir,
|
|
6
|
+
readdir,
|
|
7
|
+
utimes,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
hookCommand,
|
|
14
|
+
linkExecutable,
|
|
15
|
+
makeHookPlugin,
|
|
16
|
+
makeShellPath,
|
|
17
|
+
makeWorld,
|
|
18
|
+
pinnedEnv,
|
|
19
|
+
removeWorld,
|
|
20
|
+
runChild,
|
|
21
|
+
sessionInput,
|
|
22
|
+
} from "../plugin-deps/helpers";
|
|
23
|
+
|
|
24
|
+
test("each stale ledger is pruned even when another stale ledger cannot be removed", async () => {
|
|
25
|
+
const world = await makeWorld();
|
|
26
|
+
let blocked = "";
|
|
27
|
+
try {
|
|
28
|
+
const plugin = await makeHookPlugin(world, "ledger-pruning", {
|
|
29
|
+
schema: 1,
|
|
30
|
+
bin: [],
|
|
31
|
+
});
|
|
32
|
+
const shellPath = await makeShellPath(world);
|
|
33
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
34
|
+
|
|
35
|
+
for (const name of ["magus-deps-candidate-a", "magus-deps-candidate-b"]) {
|
|
36
|
+
await mkdir(join(world.tmp, name));
|
|
37
|
+
}
|
|
38
|
+
const candidates = (await readdir(world.tmp)).filter((name) =>
|
|
39
|
+
name.startsWith("magus-deps-candidate-"),
|
|
40
|
+
);
|
|
41
|
+
blocked = join(world.tmp, candidates[0] ?? "magus-deps-candidate-a");
|
|
42
|
+
const removable = join(
|
|
43
|
+
world.tmp,
|
|
44
|
+
candidates[1] ?? "magus-deps-candidate-b",
|
|
45
|
+
);
|
|
46
|
+
await mkdir(join(blocked, "contents"));
|
|
47
|
+
await writeFile(join(blocked, "contents", "ledger"), "stale\n");
|
|
48
|
+
const stale = new Date(0);
|
|
49
|
+
await utimes(blocked, stale, stale);
|
|
50
|
+
await utimes(removable, stale, stale);
|
|
51
|
+
await chmod(blocked, 0o000);
|
|
52
|
+
|
|
53
|
+
await runChild(
|
|
54
|
+
join(shellPath, "sh"),
|
|
55
|
+
["-c", `umask 022; ${hookCommand("pruner")}`],
|
|
56
|
+
{
|
|
57
|
+
cwd: world.project,
|
|
58
|
+
env: pinnedEnv(world, [world.bin, shellPath], {
|
|
59
|
+
BUN_INSTALL: join(world.home, ".bun"),
|
|
60
|
+
CLAUDE_PLUGIN_ROOT: plugin,
|
|
61
|
+
SHELL: join(shellPath, "sh"),
|
|
62
|
+
}),
|
|
63
|
+
input: sessionInput("current-ledger"),
|
|
64
|
+
timeoutMs: 9_500,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
let removableExists = true;
|
|
69
|
+
try {
|
|
70
|
+
await access(removable);
|
|
71
|
+
} catch {
|
|
72
|
+
removableExists = false;
|
|
73
|
+
}
|
|
74
|
+
expect(removableExists).toBe(false);
|
|
75
|
+
} finally {
|
|
76
|
+
if (blocked) await chmod(blocked, 0o700).catch(() => undefined);
|
|
77
|
+
await removeWorld(world);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
configureClaude,
|
|
7
|
+
makePlugin,
|
|
8
|
+
makeWorld,
|
|
9
|
+
removeWorld,
|
|
10
|
+
runDoctor,
|
|
11
|
+
writeExecutable,
|
|
12
|
+
} from "../plugin-deps/helpers";
|
|
13
|
+
|
|
14
|
+
test("doctor does not advise adding a PATH line already present in the shell rc file", async () => {
|
|
15
|
+
const world = await makeWorld();
|
|
16
|
+
try {
|
|
17
|
+
const rcLine = 'export PATH="$HOME/.bun/bin:$PATH"';
|
|
18
|
+
await writeFile(join(world.home, ".zshrc"), `${rcLine}\n`);
|
|
19
|
+
const plugin = await makePlugin(world, "path-advice", {
|
|
20
|
+
schema: 1,
|
|
21
|
+
bin: [
|
|
22
|
+
{
|
|
23
|
+
name: "bun",
|
|
24
|
+
check: ["bun", "--version"],
|
|
25
|
+
install: [{ tool: "bun", via: "bootstrap" }],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "widget",
|
|
29
|
+
check: ["widget", "--version"],
|
|
30
|
+
install: [{ package: "widget", via: "bun" }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
await configureClaude(world, [{ key: "path-advice@test", root: plugin }]);
|
|
35
|
+
await writeExecutable(join(world.bin, "bun"), "exit 0");
|
|
36
|
+
await writeExecutable(join(world.home, ".bun", "bin", "widget"), "exit 0");
|
|
37
|
+
|
|
38
|
+
const result = await runDoctor(world, ["--fix", "--yes"], {
|
|
39
|
+
extraEnv: { BUN_INSTALL: join(world.home, ".bun") },
|
|
40
|
+
});
|
|
41
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
42
|
+
expect(output).not.toContain(`${rcLine} to ~/.zshrc`);
|
|
43
|
+
} finally {
|
|
44
|
+
await removeWorld(world);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { REPO_ROOT } from "../plugin-deps/helpers";
|
|
6
|
+
|
|
7
|
+
test("the stats plugin declares only tools its runtime uses", async () => {
|
|
8
|
+
const manifest = JSON.parse(
|
|
9
|
+
await readFile(
|
|
10
|
+
join(REPO_ROOT, "plugins", "stats", ".claude-plugin", "plugin.json"),
|
|
11
|
+
"utf8",
|
|
12
|
+
),
|
|
13
|
+
) as { requires: { bin: { name: string }[] } };
|
|
14
|
+
|
|
15
|
+
expect(manifest.requires.bin.map(({ name }) => name).sort()).toEqual(["bun"]);
|
|
16
|
+
});
|