magus-cli 7.3.0 → 7.3.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/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +4 -1
- package/src/data/npm-package.ts +15 -0
- package/src/services/version-check.ts +20 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.3.
|
|
3
|
+
"version": "7.3.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"magus-cli-darwin-arm64": "7.3.
|
|
68
|
-
"magus-cli-darwin-x64": "7.3.
|
|
69
|
-
"magus-cli-linux-x64": "7.3.
|
|
67
|
+
"magus-cli-darwin-arm64": "7.3.1",
|
|
68
|
+
"magus-cli-darwin-x64": "7.3.1",
|
|
69
|
+
"magus-cli-linux-x64": "7.3.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* package.json (os/cpu restricted, no `bin` — the launcher execs it)
|
|
11
11
|
* bin/magus (the compiled binary)
|
|
12
12
|
*
|
|
13
|
-
* The main `magus` package lists these as optionalDependencies; npm
|
|
14
|
-
* only the one matching the host os/cpu, and bin/magus.js execs it.
|
|
13
|
+
* The main `magus-cli` package lists these as optionalDependencies; npm
|
|
14
|
+
* installs only the one matching the host os/cpu, and bin/magus.js execs it.
|
|
15
|
+
* (`magus-cli` is the npm package; `magus` is the command it installs. The
|
|
16
|
+
* bare `magus` name on npm is an unrelated package.)
|
|
15
17
|
*
|
|
16
18
|
* Hard constraint: magus pins @opentui 0.1.x — 0.4.x breaks --compile.
|
|
17
19
|
*
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The npm package name has one source of truth, and the self-upgrade builders
|
|
3
|
+
* read it.
|
|
4
|
+
*
|
|
5
|
+
* `MAGUS_NPM_PACKAGE` is `package.json` `name` (`magus-cli`). It is the npm
|
|
6
|
+
* package, NOT the command: the command is the `bin` key (`magus`), and the
|
|
7
|
+
* bare npm name belongs to an unrelated package. `selfUpgradeCommand` and
|
|
8
|
+
* `duplicateInstallRemoval` are the pure builders behind `magus upgrade`.
|
|
9
|
+
*
|
|
10
|
+
* Written black-box from the API contract. On the old tree this file fails at
|
|
11
|
+
* import, because none of these exports existed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
type SelfUpgradeManager,
|
|
20
|
+
duplicateInstallRemoval,
|
|
21
|
+
selfUpgradeCommand,
|
|
22
|
+
} from "../cli/upgrade.js";
|
|
23
|
+
import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
|
|
24
|
+
|
|
25
|
+
// Read at test time: the single source of truth the requirements name.
|
|
26
|
+
const PKG = JSON.parse(
|
|
27
|
+
readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
|
|
28
|
+
) as { name: string; version: string; bin: Record<string, string> };
|
|
29
|
+
|
|
30
|
+
const COMMAND = "magus";
|
|
31
|
+
const PACKAGE = "magus-cli";
|
|
32
|
+
const MANAGERS: SelfUpgradeManager[] = ["bun", "npm"];
|
|
33
|
+
|
|
34
|
+
describe("MAGUS_NPM_PACKAGE", () => {
|
|
35
|
+
test("is package.json `name`", () => {
|
|
36
|
+
expect(typeof MAGUS_NPM_PACKAGE).toBe("string");
|
|
37
|
+
expect(MAGUS_NPM_PACKAGE).toBe(PKG.name);
|
|
38
|
+
expect(MAGUS_NPM_PACKAGE).toBe(PACKAGE);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("is not the command", () => {
|
|
42
|
+
const commands = Object.keys(PKG.bin);
|
|
43
|
+
expect(commands).toContain(COMMAND);
|
|
44
|
+
expect(commands).not.toContain(MAGUS_NPM_PACKAGE);
|
|
45
|
+
expect(MAGUS_NPM_PACKAGE).not.toBe(COMMAND);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("selfUpgradeCommand", () => {
|
|
50
|
+
test("bun installs `<package>@latest` globally", () => {
|
|
51
|
+
expect(selfUpgradeCommand("bun")).toEqual({
|
|
52
|
+
cmd: "bun",
|
|
53
|
+
args: ["install", "-g", `${PACKAGE}@latest`],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("npm installs `<package>@latest` globally", () => {
|
|
58
|
+
expect(selfUpgradeCommand("npm")).toEqual({
|
|
59
|
+
cmd: "npm",
|
|
60
|
+
args: ["install", "-g", `${PACKAGE}@latest`],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("is pure: the answer does not depend on PATH", () => {
|
|
65
|
+
const expected = MANAGERS.map((m) => selfUpgradeCommand(m));
|
|
66
|
+
const emptyDir = mkdtempSync(join(tmpdir(), "magus-selfupgrade-path-"));
|
|
67
|
+
const savedPath = process.env.PATH;
|
|
68
|
+
try {
|
|
69
|
+
process.env.PATH = emptyDir;
|
|
70
|
+
const again = MANAGERS.map((m) => selfUpgradeCommand(m));
|
|
71
|
+
expect(again).toEqual(expected);
|
|
72
|
+
} finally {
|
|
73
|
+
process.env.PATH = savedPath;
|
|
74
|
+
rmSync(emptyDir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
expect(MANAGERS.map((m) => selfUpgradeCommand(m))).toEqual(expected);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("reads the package name from the single source", () => {
|
|
80
|
+
for (const m of MANAGERS) {
|
|
81
|
+
const { cmd, args } = selfUpgradeCommand(m);
|
|
82
|
+
expect(cmd).toBe(m);
|
|
83
|
+
expect(args[args.length - 1]).toBe(`${MAGUS_NPM_PACKAGE}@latest`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("duplicateInstallRemoval", () => {
|
|
89
|
+
test("bun names the package", () => {
|
|
90
|
+
expect(duplicateInstallRemoval("bun")).toBe(`bun uninstall -g ${PACKAGE}`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("npm names the package", () => {
|
|
94
|
+
expect(duplicateInstallRemoval("npm")).toBe(`npm uninstall -g ${PACKAGE}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("carries no leading indentation (the caller indents)", () => {
|
|
98
|
+
for (const m of MANAGERS) {
|
|
99
|
+
const hint = duplicateInstallRemoval(m);
|
|
100
|
+
expect(hint).toBe(hint.trimStart());
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("reads the package name from the single source", () => {
|
|
105
|
+
for (const m of MANAGERS) {
|
|
106
|
+
const tokens = duplicateInstallRemoval(m).split(" ");
|
|
107
|
+
expect(tokens[0]).toBe(m);
|
|
108
|
+
expect(tokens[tokens.indexOf("-g") + 1]).toBe(MAGUS_NPM_PACKAGE);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* magus talks to npm by its PACKAGE name, never by its COMMAND name.
|
|
3
|
+
*
|
|
4
|
+
* The npm package is `package.json` `name` (`magus-cli`). The command users
|
|
5
|
+
* type is `magus`. The bare npm name belongs to an unrelated 2015 package
|
|
6
|
+
* whose latest release is 0.0.2, and npm never transfers names, so the split
|
|
7
|
+
* is permanent. Addressing npm by the command name made the update banner go
|
|
8
|
+
* silent and pointed the CLI Tools row at the wrong package.
|
|
9
|
+
*
|
|
10
|
+
* This file uses only exports that existed before the fix, so on the old tree
|
|
11
|
+
* it fails by VALUE (wrong URL, wrong packageName, wrong argv), not merely at
|
|
12
|
+
* import. Written black-box from the API contract.
|
|
13
|
+
*
|
|
14
|
+
* No network: every checkForUpdates call runs against a stubbed fetch, always
|
|
15
|
+
* with force = true, because the result is cached for the session.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { cliTools } from "../data/cli-tools.js";
|
|
22
|
+
import {
|
|
23
|
+
cliToolInstall,
|
|
24
|
+
cliToolUninstall,
|
|
25
|
+
cliToolUpdate,
|
|
26
|
+
} from "../services/cli-tool-commands.js";
|
|
27
|
+
import { cliToolToBin } from "../services/resolver.js";
|
|
28
|
+
import { specForCatalogTool } from "../services/tool-probe.js";
|
|
29
|
+
import {
|
|
30
|
+
checkForUpdates,
|
|
31
|
+
formatUpdateMessage,
|
|
32
|
+
getCurrentVersion,
|
|
33
|
+
} from "../services/version-check.js";
|
|
34
|
+
|
|
35
|
+
// Read at test time: the single source of truth the requirements name.
|
|
36
|
+
const PKG = JSON.parse(
|
|
37
|
+
readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
|
|
38
|
+
) as { name: string; version: string; bin: Record<string, string> };
|
|
39
|
+
|
|
40
|
+
/** The command users type. Also, unluckily, an unrelated npm package. */
|
|
41
|
+
const COMMAND = "magus";
|
|
42
|
+
/** The npm package, as the contract states it. */
|
|
43
|
+
const PACKAGE = "magus-cli";
|
|
44
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
45
|
+
|
|
46
|
+
function magusEntry() {
|
|
47
|
+
const entry = cliTools.find((t) => t.name === COMMAND);
|
|
48
|
+
if (!entry) throw new Error("cliTools has no entry named magus");
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
53
|
+
return new Response(JSON.stringify(body), {
|
|
54
|
+
status,
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function urlOf(input: unknown): string {
|
|
60
|
+
return input instanceof Request ? input.url : String(input);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("checkForUpdates asks npm about the package, not the command", () => {
|
|
64
|
+
const realFetch = globalThis.fetch;
|
|
65
|
+
let urls: string[];
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
urls = [];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
globalThis.fetch = realFetch;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function stubFetch(answer: (url: string) => Response | Promise<Response>) {
|
|
76
|
+
globalThis.fetch = (async (input: unknown) => {
|
|
77
|
+
const url = urlOf(input);
|
|
78
|
+
urls.push(url);
|
|
79
|
+
return answer(url);
|
|
80
|
+
}) as typeof fetch;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("fetches the registry `latest` document of package.json `name`, once", async () => {
|
|
84
|
+
stubFetch(() => jsonResponse({ version: "999.0.0" }));
|
|
85
|
+
|
|
86
|
+
await checkForUpdates(true);
|
|
87
|
+
|
|
88
|
+
expect(urls).toHaveLength(1);
|
|
89
|
+
expect(urls[0]).toBe(`${REGISTRY}/${PKG.name}/latest`);
|
|
90
|
+
expect(urls[0]).toBe(`${REGISTRY}/${PACKAGE}/latest`);
|
|
91
|
+
// The first path segment is the package, never the command.
|
|
92
|
+
const segment = new URL(urls[0]!).pathname.split("/")[1];
|
|
93
|
+
expect(segment).toBe(PKG.name);
|
|
94
|
+
expect(segment).not.toBe(COMMAND);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("with the registry as observed (bare name at 0.0.2, package ahead), an update is reported", async () => {
|
|
98
|
+
// A registry that answers per package, the way npm does: the unrelated
|
|
99
|
+
// package sits at 0.0.2, the real one is ahead of the running version.
|
|
100
|
+
const registry: Record<string, string> = {
|
|
101
|
+
magus: "0.0.2",
|
|
102
|
+
[PKG.name]: "999.0.0",
|
|
103
|
+
};
|
|
104
|
+
stubFetch((url) => {
|
|
105
|
+
const name = new URL(url).pathname.split("/")[1] ?? "";
|
|
106
|
+
const version = registry[name];
|
|
107
|
+
return version
|
|
108
|
+
? jsonResponse({ version })
|
|
109
|
+
: jsonResponse({ error: "Not found" }, 404);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const r = await checkForUpdates(true);
|
|
113
|
+
|
|
114
|
+
expect(r.currentVersion).toBe(PKG.version);
|
|
115
|
+
expect(r.latestVersion).toBe("999.0.0");
|
|
116
|
+
expect(r.updateAvailable).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a major release above the running version is a major update", async () => {
|
|
120
|
+
stubFetch(() => jsonResponse({ version: "999.0.0" }));
|
|
121
|
+
|
|
122
|
+
const r = await checkForUpdates(true);
|
|
123
|
+
|
|
124
|
+
expect(r.updateAvailable).toBe(true);
|
|
125
|
+
expect(r.updateType).toBe("major");
|
|
126
|
+
expect(r.latestVersion).toBe("999.0.0");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the unrelated package's 0.0.2 is not an update", async () => {
|
|
130
|
+
stubFetch(() => jsonResponse({ version: "0.0.2" }));
|
|
131
|
+
|
|
132
|
+
const r = await checkForUpdates(true);
|
|
133
|
+
|
|
134
|
+
expect(r.updateAvailable).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a non-ok registry response reports no update and keeps the running version", async () => {
|
|
138
|
+
stubFetch(() => jsonResponse({ error: "boom" }, 500));
|
|
139
|
+
|
|
140
|
+
const r = await checkForUpdates(true);
|
|
141
|
+
|
|
142
|
+
expect(r.latestVersion).toBe(r.currentVersion);
|
|
143
|
+
expect(r.updateAvailable).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a failed fetch reports no update and keeps the running version", async () => {
|
|
147
|
+
stubFetch(() => {
|
|
148
|
+
throw new Error("network down");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const r = await checkForUpdates(true);
|
|
152
|
+
|
|
153
|
+
expect(r.latestVersion).toBe(r.currentVersion);
|
|
154
|
+
expect(r.updateAvailable).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("the running version is package.json `version`", () => {
|
|
158
|
+
expect(getCurrentVersion()).toBe(PKG.version);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("formatUpdateMessage", () => {
|
|
163
|
+
// Kept as a guard on the hint text; not V1 evidence (amendment L5).
|
|
164
|
+
test("names `magus upgrade` as the way to update", () => {
|
|
165
|
+
expect(
|
|
166
|
+
formatUpdateMessage({
|
|
167
|
+
currentVersion: "1.0.0",
|
|
168
|
+
latestVersion: "1.2.0",
|
|
169
|
+
updateAvailable: true,
|
|
170
|
+
updateType: "minor",
|
|
171
|
+
} as any),
|
|
172
|
+
).toBe("Update available: v1.0.0 → v1.2.0 (minor). Run: magus upgrade");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("is empty when no update is available", () => {
|
|
176
|
+
expect(
|
|
177
|
+
formatUpdateMessage({
|
|
178
|
+
currentVersion: "1.0.0",
|
|
179
|
+
latestVersion: "1.0.0",
|
|
180
|
+
updateAvailable: false,
|
|
181
|
+
} as any),
|
|
182
|
+
).toBe("");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("the CLI Tools entry for magus", () => {
|
|
187
|
+
test("targets the npm package and keeps the command", () => {
|
|
188
|
+
const entry = magusEntry();
|
|
189
|
+
|
|
190
|
+
expect(entry.packageName).toBe(PKG.name);
|
|
191
|
+
expect(entry.packageName).toBe(PACKAGE);
|
|
192
|
+
// R6: everything that names the command is unchanged.
|
|
193
|
+
expect(entry.name).toBe(COMMAND);
|
|
194
|
+
expect(entry.displayName).toBe(COMMAND);
|
|
195
|
+
expect(entry.checkCommand).toBe("magus --version");
|
|
196
|
+
expect(entry.packageManager).toBe("bun");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("install targets the package", () => {
|
|
200
|
+
expect(cliToolInstall(magusEntry())).toEqual({
|
|
201
|
+
cmd: "bun",
|
|
202
|
+
args: ["install", "-g", PACKAGE],
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("update targets `<package>@latest` with bun and with npm", () => {
|
|
207
|
+
const entry = magusEntry();
|
|
208
|
+
expect(cliToolUpdate(entry, "bun")).toEqual({
|
|
209
|
+
cmd: "bun",
|
|
210
|
+
args: ["install", "-g", `${PACKAGE}@latest`],
|
|
211
|
+
});
|
|
212
|
+
expect(cliToolUpdate(entry, "npm")).toEqual({
|
|
213
|
+
cmd: "npm",
|
|
214
|
+
args: ["install", "-g", `${PACKAGE}@latest`],
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("uninstall targets the package with bun and with npm", () => {
|
|
219
|
+
const entry = magusEntry();
|
|
220
|
+
expect(cliToolUninstall(entry, "bun")).toEqual({
|
|
221
|
+
cmd: "bun",
|
|
222
|
+
args: ["remove", "-g", PACKAGE],
|
|
223
|
+
});
|
|
224
|
+
expect(cliToolUninstall(entry, "npm")).toEqual({
|
|
225
|
+
cmd: "npm",
|
|
226
|
+
args: ["uninstall", "-g", PACKAGE],
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("no derived command addresses the bare command name on npm", () => {
|
|
231
|
+
const entry = magusEntry();
|
|
232
|
+
const commands = [
|
|
233
|
+
cliToolInstall(entry),
|
|
234
|
+
cliToolUpdate(entry, "bun"),
|
|
235
|
+
cliToolUpdate(entry, "npm"),
|
|
236
|
+
cliToolUninstall(entry, "bun"),
|
|
237
|
+
cliToolUninstall(entry, "npm"),
|
|
238
|
+
];
|
|
239
|
+
for (const c of commands) {
|
|
240
|
+
expect(c).not.toBeNull();
|
|
241
|
+
const args = c!.args as string[];
|
|
242
|
+
const target = args[args.indexOf("-g") + 1] ?? "";
|
|
243
|
+
// `<package>` or `<package>@<tag>`, never the command name.
|
|
244
|
+
expect(target.split("@")[0]).toBe(PKG.name);
|
|
245
|
+
expect(args).not.toContain(COMMAND);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("pins and probes resolve to the package", () => {
|
|
251
|
+
test("a pinned version installs `<package>@<version>` and checks the command", () => {
|
|
252
|
+
expect(cliToolToBin(COMMAND, PKG.version)).toMatchObject({
|
|
253
|
+
name: COMMAND,
|
|
254
|
+
via: "bun",
|
|
255
|
+
package: PACKAGE,
|
|
256
|
+
check: "magus --version",
|
|
257
|
+
version: PKG.version,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("`latest` carries no version", () => {
|
|
262
|
+
const bin = cliToolToBin(COMMAND, "latest");
|
|
263
|
+
expect(bin).toMatchObject({
|
|
264
|
+
name: COMMAND,
|
|
265
|
+
via: "bun",
|
|
266
|
+
package: PACKAGE,
|
|
267
|
+
check: "magus --version",
|
|
268
|
+
});
|
|
269
|
+
expect(bin.version).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the catalogue spec (the dist-tags probe input) names the package", () => {
|
|
273
|
+
expect(specForCatalogTool(magusEntry(), null, undefined)).toMatchObject({
|
|
274
|
+
name: COMMAND,
|
|
275
|
+
via: "bun",
|
|
276
|
+
package: PACKAGE,
|
|
277
|
+
check: "magus --version",
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magus upgrade`, end to end, with node:child_process replaced.
|
|
3
|
+
*
|
|
4
|
+
* The upgrade must install the npm PACKAGE (`package.json` `name`,
|
|
5
|
+
* `magus-cli`) at `latest`, with whichever package manager owns the active
|
|
6
|
+
* `magus` binary. The bare npm name is an unrelated package: installing it
|
|
7
|
+
* leaves magus where it was. The duplicate-install hint must name the real
|
|
8
|
+
* package too. The probes that look for the COMMAND (`which magus`,
|
|
9
|
+
* `bun pm -g bin`, `npm prefix -g`) stay as they are.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY. This is the one test that could run a real global install, so three
|
|
12
|
+
* independent layers stand between it and one:
|
|
13
|
+
* 1. identity guard: before every call, the imported `spawn` and `execSync`
|
|
14
|
+
* must BE the fakes, or the test throws without calling anything;
|
|
15
|
+
* 2. PATH is an empty temp directory for the duration of the call, so even a
|
|
16
|
+
* real `spawn("bun", ...)` finds nothing (bun 1.4.0's child_process.spawn
|
|
17
|
+
* honours a runtime PATH change; `Bun.which` does not, so it is not used);
|
|
18
|
+
* 3. the module mock is restored in afterAll, the way cli-router.test.ts
|
|
19
|
+
* restores its own, so no later file in the same process inherits it.
|
|
20
|
+
* Every other process launcher on the module is replaced by a fake that
|
|
21
|
+
* records and throws.
|
|
22
|
+
*
|
|
23
|
+
* Written black-box from the API contract.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
afterAll,
|
|
28
|
+
afterEach,
|
|
29
|
+
beforeAll,
|
|
30
|
+
beforeEach,
|
|
31
|
+
describe,
|
|
32
|
+
expect,
|
|
33
|
+
mock,
|
|
34
|
+
test,
|
|
35
|
+
} from "bun:test";
|
|
36
|
+
import * as childProcessNamespace from "node:child_process";
|
|
37
|
+
import { EventEmitter } from "node:events";
|
|
38
|
+
import {
|
|
39
|
+
mkdirSync,
|
|
40
|
+
mkdtempSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
realpathSync,
|
|
43
|
+
rmSync,
|
|
44
|
+
writeFileSync,
|
|
45
|
+
} from "node:fs";
|
|
46
|
+
import { tmpdir } from "node:os";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { stripVTControlCharacters } from "node:util";
|
|
49
|
+
|
|
50
|
+
// Read at test time: the single source of truth the requirements name.
|
|
51
|
+
const PKG = JSON.parse(
|
|
52
|
+
readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
|
|
53
|
+
) as { name: string; version: string };
|
|
54
|
+
|
|
55
|
+
const COMMAND = "magus";
|
|
56
|
+
const PACKAGE = "magus-cli";
|
|
57
|
+
const EXPECTED_ARGS = ["install", "-g", `${PACKAGE}@latest`];
|
|
58
|
+
|
|
59
|
+
// Snapshot the real module BEFORE mocking. mock.module is process-global and
|
|
60
|
+
// updates live namespaces, so the snapshot is what gets put back.
|
|
61
|
+
const realChildProcess: Record<string, unknown> = { ...childProcessNamespace };
|
|
62
|
+
const realDefault = (realChildProcess.default ?? {}) as Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Fakes
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
interface SpawnCall {
|
|
69
|
+
cmd: string;
|
|
70
|
+
args: string[];
|
|
71
|
+
options: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Scenario {
|
|
75
|
+
/** What `which magus` answers: the active binary. */
|
|
76
|
+
which: string;
|
|
77
|
+
/** What `bun pm -g bin` answers. */
|
|
78
|
+
bunBin: string;
|
|
79
|
+
/** What `npm prefix -g` answers. */
|
|
80
|
+
npmPrefix: string;
|
|
81
|
+
/** The exit code the fake child reports. */
|
|
82
|
+
exitCode: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let scenario: Scenario = { which: "", bunBin: "", npmPrefix: "", exitCode: 0 };
|
|
86
|
+
let spawnCalls: SpawnCall[] = [];
|
|
87
|
+
let execCalls: string[] = [];
|
|
88
|
+
|
|
89
|
+
function fakeExecSync(command: string, options?: { encoding?: string }) {
|
|
90
|
+
const text = String(command);
|
|
91
|
+
execCalls.push(text);
|
|
92
|
+
let answer: string | undefined;
|
|
93
|
+
if (text.includes("which magus")) answer = scenario.which;
|
|
94
|
+
else if (text.includes("bun pm -g bin")) answer = scenario.bunBin;
|
|
95
|
+
else if (text.includes("npm prefix -g")) answer = scenario.npmPrefix;
|
|
96
|
+
if (!answer) {
|
|
97
|
+
throw new Error(`fake execSync has no answer for: ${text}`);
|
|
98
|
+
}
|
|
99
|
+
// Mirror Node: a string when an encoding is asked for, a Buffer otherwise.
|
|
100
|
+
const out = `${answer}\n`;
|
|
101
|
+
const encoding = options?.encoding;
|
|
102
|
+
return encoding && encoding !== "buffer" ? out : Buffer.from(out);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fakeSpawn(
|
|
106
|
+
cmd: string,
|
|
107
|
+
args?: readonly string[],
|
|
108
|
+
options?: Record<string, unknown>,
|
|
109
|
+
) {
|
|
110
|
+
spawnCalls.push({
|
|
111
|
+
cmd: String(cmd),
|
|
112
|
+
args: [...(args ?? [])],
|
|
113
|
+
options: options ?? {},
|
|
114
|
+
});
|
|
115
|
+
const child = Object.assign(new EventEmitter(), {
|
|
116
|
+
pid: 424242,
|
|
117
|
+
stdin: null,
|
|
118
|
+
stdout: null,
|
|
119
|
+
stderr: null,
|
|
120
|
+
kill: () => true,
|
|
121
|
+
});
|
|
122
|
+
const code = scenario.exitCode;
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
child.emit("exit", code, null);
|
|
125
|
+
child.emit("close", code, null);
|
|
126
|
+
}, 0);
|
|
127
|
+
return child;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function refuse(name: string) {
|
|
131
|
+
return (...a: unknown[]) => {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`upgrade-spawn test refused child_process.${name}(${String(a[0])}): no real process may run here`,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fakes = {
|
|
139
|
+
spawn: fakeSpawn,
|
|
140
|
+
execSync: fakeExecSync,
|
|
141
|
+
spawnSync: refuse("spawnSync"),
|
|
142
|
+
exec: refuse("exec"),
|
|
143
|
+
execFile: refuse("execFile"),
|
|
144
|
+
execFileSync: refuse("execFileSync"),
|
|
145
|
+
fork: refuse("fork"),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Lifecycle
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
let runUpgradeCommand: () => Promise<number>;
|
|
153
|
+
|
|
154
|
+
beforeAll(async () => {
|
|
155
|
+
mock.module("node:child_process", () => ({
|
|
156
|
+
...realChildProcess,
|
|
157
|
+
...fakes,
|
|
158
|
+
default: { ...realDefault, ...fakes },
|
|
159
|
+
}));
|
|
160
|
+
// Imported only AFTER the mock, so the module binds to the fakes.
|
|
161
|
+
({ runUpgradeCommand } = await import("../cli/upgrade.js"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
mock.module("node:child_process", () => realChildProcess);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** Layer 1: refuse to proceed unless the fakes are what callers will get. */
|
|
169
|
+
async function assertChildProcessIsFake(): Promise<void> {
|
|
170
|
+
const cp = (await import("node:child_process")) as unknown as Record<
|
|
171
|
+
string,
|
|
172
|
+
unknown
|
|
173
|
+
>;
|
|
174
|
+
if (cp.spawn !== fakeSpawn || cp.execSync !== fakeExecSync) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"SAFETY: node:child_process is not mocked. Refusing to call runUpgradeCommand, which would run a real global install.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let root: string;
|
|
182
|
+
let logs: string[];
|
|
183
|
+
let otherOutput: string[];
|
|
184
|
+
const realLog = console.log;
|
|
185
|
+
const realError = console.error;
|
|
186
|
+
const realWarn = console.warn;
|
|
187
|
+
const realFetch = globalThis.fetch;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
// realpath: on macOS the temp dir sits behind a symlink.
|
|
191
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "magus-upgrade-spawn-")));
|
|
192
|
+
const bunBin = join(root, ".bun", "bin");
|
|
193
|
+
const npmPrefix = join(root, "npm");
|
|
194
|
+
mkdirSync(bunBin, { recursive: true });
|
|
195
|
+
mkdirSync(join(npmPrefix, "bin"), { recursive: true });
|
|
196
|
+
mkdirSync(join(root, "empty-path"));
|
|
197
|
+
// Two real installs of the command, one per manager (amendment M3).
|
|
198
|
+
writeFileSync(join(bunBin, COMMAND), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
199
|
+
writeFileSync(join(npmPrefix, "bin", COMMAND), "#!/bin/sh\nexit 0\n", {
|
|
200
|
+
mode: 0o755,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
scenario = { which: "", bunBin, npmPrefix, exitCode: 0 };
|
|
204
|
+
spawnCalls = [];
|
|
205
|
+
execCalls = [];
|
|
206
|
+
logs = [];
|
|
207
|
+
otherOutput = [];
|
|
208
|
+
|
|
209
|
+
console.log = (...a: unknown[]) => {
|
|
210
|
+
logs.push(a.map(String).join(" "));
|
|
211
|
+
};
|
|
212
|
+
console.error = (...a: unknown[]) => {
|
|
213
|
+
otherOutput.push(a.map(String).join(" "));
|
|
214
|
+
};
|
|
215
|
+
console.warn = (...a: unknown[]) => {
|
|
216
|
+
otherOutput.push(a.map(String).join(" "));
|
|
217
|
+
};
|
|
218
|
+
// No network, in case the upgrade consults the registry first.
|
|
219
|
+
globalThis.fetch = (async () =>
|
|
220
|
+
new Response(JSON.stringify({ version: "999.0.0" }), {
|
|
221
|
+
status: 200,
|
|
222
|
+
headers: { "content-type": "application/json" },
|
|
223
|
+
})) as unknown as typeof fetch;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
afterEach(() => {
|
|
227
|
+
console.log = realLog;
|
|
228
|
+
console.error = realError;
|
|
229
|
+
console.warn = realWarn;
|
|
230
|
+
globalThis.fetch = realFetch;
|
|
231
|
+
rmSync(root, { recursive: true, force: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const bunWhich = () => join(scenario.bunBin, COMMAND);
|
|
235
|
+
const npmWhich = () => join(scenario.npmPrefix, "bin", COMMAND);
|
|
236
|
+
|
|
237
|
+
async function runUpgrade(which: string, exitCode = 0): Promise<number> {
|
|
238
|
+
scenario.which = which;
|
|
239
|
+
scenario.exitCode = exitCode;
|
|
240
|
+
await assertChildProcessIsFake();
|
|
241
|
+
// Layer 2: nothing is reachable by name while the upgrade runs (M2).
|
|
242
|
+
const savedPath = process.env.PATH;
|
|
243
|
+
process.env.PATH = join(root, "empty-path");
|
|
244
|
+
try {
|
|
245
|
+
return await runUpgradeCommand();
|
|
246
|
+
} finally {
|
|
247
|
+
if (savedPath === undefined) Reflect.deleteProperty(process.env, "PATH");
|
|
248
|
+
else process.env.PATH = savedPath;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Captured console.log output, one entry per physical line, ANSI removed. */
|
|
253
|
+
function logLines(): string[] {
|
|
254
|
+
return logs.flatMap((l) => stripVTControlCharacters(l).split("\n"));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Tests
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe("runUpgradeCommand installs the npm package with the owning manager", () => {
|
|
262
|
+
test("a bun-owned binary upgrades with `bun install -g <package>@latest`", async () => {
|
|
263
|
+
const code = await runUpgrade(bunWhich());
|
|
264
|
+
|
|
265
|
+
expect(code).toBe(0);
|
|
266
|
+
expect(spawnCalls).toHaveLength(1);
|
|
267
|
+
const call = spawnCalls[0]!;
|
|
268
|
+
expect(call.cmd).toBe("bun");
|
|
269
|
+
expect(call.args).toEqual(EXPECTED_ARGS);
|
|
270
|
+
expect(call.args).toEqual(["install", "-g", `${PKG.name}@latest`]);
|
|
271
|
+
expect(call.options).toMatchObject({ stdio: "inherit", shell: false });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("an npm-owned binary upgrades with `npm install -g <package>@latest`", async () => {
|
|
275
|
+
// Precondition: nothing in this path may look bun-owned.
|
|
276
|
+
expect(npmWhich()).not.toContain(".bun");
|
|
277
|
+
expect(npmWhich()).not.toContain("bun/bin");
|
|
278
|
+
|
|
279
|
+
const code = await runUpgrade(npmWhich());
|
|
280
|
+
|
|
281
|
+
expect(code).toBe(0);
|
|
282
|
+
expect(spawnCalls).toHaveLength(1);
|
|
283
|
+
const call = spawnCalls[0]!;
|
|
284
|
+
expect(call.cmd).toBe("npm");
|
|
285
|
+
expect(call.args).toEqual(EXPECTED_ARGS);
|
|
286
|
+
expect(call.args).toEqual(["install", "-g", `${PKG.name}@latest`]);
|
|
287
|
+
expect(call.options).toMatchObject({ stdio: "inherit", shell: false });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("a `bun/bin` path without `.bun` also counts as bun-owned", async () => {
|
|
291
|
+
const dir = join(root, "opt", "bun", "bin");
|
|
292
|
+
mkdirSync(dir, { recursive: true });
|
|
293
|
+
const which = join(dir, COMMAND);
|
|
294
|
+
writeFileSync(which, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
295
|
+
expect(which).not.toContain(".bun");
|
|
296
|
+
|
|
297
|
+
await runUpgrade(which);
|
|
298
|
+
|
|
299
|
+
expect(spawnCalls).toHaveLength(1);
|
|
300
|
+
expect(spawnCalls[0]!.cmd).toBe("bun");
|
|
301
|
+
expect(spawnCalls[0]!.args).toEqual(EXPECTED_ARGS);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("resolves with the child's exit code", async () => {
|
|
305
|
+
const code = await runUpgrade(npmWhich(), 3);
|
|
306
|
+
|
|
307
|
+
expect(spawnCalls).toHaveLength(1);
|
|
308
|
+
expect(code).toBe(3);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("the duplicate-install hint names the npm package", () => {
|
|
313
|
+
function expectBothHints() {
|
|
314
|
+
const out = logLines().join("\n");
|
|
315
|
+
expect(out).toContain(`bun uninstall -g ${PACKAGE}`);
|
|
316
|
+
expect(out).toContain(`npm uninstall -g ${PACKAGE}`);
|
|
317
|
+
// Every uninstall hint that was printed targets the package.
|
|
318
|
+
const hints = logLines().filter((l) => /\buninstall\b/.test(l));
|
|
319
|
+
expect(hints.length).toBeGreaterThanOrEqual(2);
|
|
320
|
+
for (const line of hints) {
|
|
321
|
+
const tokens = line.trim().split(/\s+/);
|
|
322
|
+
const i = tokens.indexOf("-g");
|
|
323
|
+
expect(i).toBeGreaterThanOrEqual(0);
|
|
324
|
+
expect(tokens[i + 1]).toBe(PKG.name);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
test("both installs present, bun active: both hints name the package", async () => {
|
|
329
|
+
await runUpgrade(bunWhich());
|
|
330
|
+
|
|
331
|
+
expectBothHints();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("both installs present, npm active: both hints name the package", async () => {
|
|
335
|
+
await runUpgrade(npmWhich());
|
|
336
|
+
|
|
337
|
+
expectBothHints();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("the probes still look for the command (R6)", () => {
|
|
342
|
+
test("`which magus`, `bun pm -g bin` and `npm prefix -g` are what gets asked", async () => {
|
|
343
|
+
await runUpgrade(bunWhich());
|
|
344
|
+
|
|
345
|
+
const probes = execCalls.join("\n");
|
|
346
|
+
expect(probes).toContain("bun pm -g bin");
|
|
347
|
+
expect(probes).toContain("npm prefix -g");
|
|
348
|
+
// `which` is asked about the COMMAND, not the package.
|
|
349
|
+
const whichProbe = execCalls
|
|
350
|
+
.map((c) => c.trim().split(/\s+/))
|
|
351
|
+
.find((tokens) => tokens.includes("which"));
|
|
352
|
+
expect(whichProbe).toBeDefined();
|
|
353
|
+
expect(whichProbe![whichProbe!.indexOf("which") + 1]).toBe(COMMAND);
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -6,19 +6,49 @@
|
|
|
6
6
|
* and advancing the tool itself moved here.
|
|
7
7
|
*
|
|
8
8
|
* Detects magus installs across bun + npm global, warns on duplicates
|
|
9
|
-
* (active vs shadowed), then re-installs `
|
|
10
|
-
* package manager owns the active binary. Resolves
|
|
9
|
+
* (active vs shadowed), then re-installs the npm package (`MAGUS_NPM_PACKAGE`)
|
|
10
|
+
* at latest with whichever package manager owns the active binary. Resolves
|
|
11
|
+
* with the child's exit code.
|
|
12
|
+
*
|
|
13
|
+
* The npm package is not the command. The binary on disk and `which magus`
|
|
14
|
+
* stay `magus`; only what is handed to the package manager uses the package
|
|
15
|
+
* name, because bare `magus` on npm is an unrelated package.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { spawn } from "node:child_process";
|
|
14
19
|
import { constants } from "node:os";
|
|
20
|
+
import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
|
|
21
|
+
import type { Command } from "../utils/command-utils.js";
|
|
22
|
+
|
|
23
|
+
/** The package managers `magus upgrade` detects and upgrades through. */
|
|
24
|
+
export type SelfUpgradeManager = "bun" | "npm";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The self-upgrade command for a package manager. Pure: no I/O, no PATH probe.
|
|
28
|
+
*/
|
|
29
|
+
export function selfUpgradeCommand(manager: SelfUpgradeManager): Command {
|
|
30
|
+
return {
|
|
31
|
+
cmd: manager,
|
|
32
|
+
args: ["install", "-g", `${MAGUS_NPM_PACKAGE}@latest`],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The command that removes a duplicate global install, for the hint printed
|
|
38
|
+
* when magus is installed through both package managers. No leading
|
|
39
|
+
* indentation — the caller indents.
|
|
40
|
+
*/
|
|
41
|
+
export function duplicateInstallRemoval(manager: SelfUpgradeManager): string {
|
|
42
|
+
return `${manager} uninstall -g ${MAGUS_NPM_PACKAGE}`;
|
|
43
|
+
}
|
|
15
44
|
|
|
16
45
|
export async function runUpgradeCommand(): Promise<number> {
|
|
17
46
|
const { execSync } = await import("node:child_process");
|
|
18
47
|
const { existsSync } = await import("node:fs");
|
|
19
48
|
|
|
20
49
|
// Detect all installations of magus across package managers
|
|
21
|
-
const installations: Array<{ manager:
|
|
50
|
+
const installations: Array<{ manager: SelfUpgradeManager; path: string }> =
|
|
51
|
+
[];
|
|
22
52
|
|
|
23
53
|
// Check bun global
|
|
24
54
|
try {
|
|
@@ -60,7 +90,7 @@ export async function runUpgradeCommand(): Promise<number> {
|
|
|
60
90
|
}
|
|
61
91
|
console.log("\nTo fix, keep one and remove the other:");
|
|
62
92
|
for (const inst of installations) {
|
|
63
|
-
console.log(` ${inst.manager}
|
|
93
|
+
console.log(` ${duplicateInstallRemoval(inst.manager)}`);
|
|
64
94
|
}
|
|
65
95
|
console.log();
|
|
66
96
|
}
|
|
@@ -76,13 +106,11 @@ export async function runUpgradeCommand(): Promise<number> {
|
|
|
76
106
|
// If which fails, default to npm
|
|
77
107
|
}
|
|
78
108
|
|
|
79
|
-
const
|
|
80
|
-
console.log(`Updating magus using ${
|
|
81
|
-
|
|
82
|
-
const installArgs = ["install", "-g", "magus@latest"];
|
|
109
|
+
const { cmd, args } = selfUpgradeCommand(usesBun ? "bun" : "npm");
|
|
110
|
+
console.log(`Updating magus using ${cmd}...`);
|
|
83
111
|
|
|
84
112
|
return new Promise<number>((resolve) => {
|
|
85
|
-
const proc = spawn(
|
|
113
|
+
const proc = spawn(cmd, args, {
|
|
86
114
|
stdio: "inherit",
|
|
87
115
|
shell: false, // Avoid shell for security (fixes DEP0190 warning)
|
|
88
116
|
});
|
package/src/data/cli-tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BinInstaller } from "../types/index.js";
|
|
2
|
+
import { MAGUS_NPM_PACKAGE } from "./npm-package.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A CLI tool magus can install, check and update.
|
|
@@ -38,7 +39,9 @@ export const cliTools: CliTool[] = [
|
|
|
38
39
|
website: "https://github.com/MadAppGang/magus/tree/main/tools/magus",
|
|
39
40
|
category: "ai-coding",
|
|
40
41
|
packageManager: "bun",
|
|
41
|
-
|
|
42
|
+
// The npm package (`magus-cli`), not the command above: bare `magus` on
|
|
43
|
+
// npm is an unrelated package.
|
|
44
|
+
packageName: MAGUS_NPM_PACKAGE,
|
|
42
45
|
},
|
|
43
46
|
{
|
|
44
47
|
name: "mnemex",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The npm package magus is published as. It is NOT the command.
|
|
5
|
+
*
|
|
6
|
+
* The command users type is `magus` (package.json `bin`). The npm package is
|
|
7
|
+
* `magus-cli`, because the bare `magus` name on npm belongs to an unrelated
|
|
8
|
+
* 2015 package, and npm never transfers names. Anything that talks to npm —
|
|
9
|
+
* the registry check, the self-upgrade, the CLI Tools row — must use this, and
|
|
10
|
+
* `git grep MAGUS_NPM_PACKAGE` lists every such call site.
|
|
11
|
+
*
|
|
12
|
+
* Static import so it resolves inside a `bun build --compile` binary — a
|
|
13
|
+
* dynamic require("../../package.json") returns nothing usable there.
|
|
14
|
+
*/
|
|
15
|
+
export const MAGUS_NPM_PACKAGE: string = (pkg as { name: string }).name;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import semver from "semver";
|
|
2
2
|
import pkg from "../../package.json";
|
|
3
|
+
import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
|
|
3
4
|
|
|
4
5
|
// Cache the result for the session
|
|
5
6
|
let cachedResult: VersionCheckResult | null = null;
|
|
@@ -29,15 +30,20 @@ async function fetchLatestVersion(): Promise<string | null> {
|
|
|
29
30
|
const controller = new AbortController();
|
|
30
31
|
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
// The npm package, not the command: bare `magus` on npm is an unrelated
|
|
34
|
+
// package whose latest is 0.0.2, which hid every release from this check.
|
|
35
|
+
const response = await fetch(
|
|
36
|
+
`https://registry.npmjs.org/${MAGUS_NPM_PACKAGE}/latest`,
|
|
37
|
+
{
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
headers: {
|
|
40
|
+
Accept: "application/json",
|
|
41
|
+
// Always revalidate — a CDN/HTTP-cached response serves a stale
|
|
42
|
+
// "latest" right after a release (the 4.25-vs-4.26 flicker).
|
|
43
|
+
"Cache-Control": "no-cache",
|
|
44
|
+
},
|
|
39
45
|
},
|
|
40
|
-
|
|
46
|
+
);
|
|
41
47
|
|
|
42
48
|
clearTimeout(timeout);
|
|
43
49
|
|
|
@@ -117,11 +123,15 @@ export async function checkForUpdates(
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/**
|
|
120
|
-
* Format update message for display
|
|
126
|
+
* Format update message for display.
|
|
127
|
+
*
|
|
128
|
+
* Points at `magus upgrade`, which picks bun or npm from the active binary. A
|
|
129
|
+
* hard-coded `npm i -g …` would give every bun user a second, npm-owned copy —
|
|
130
|
+
* the duplicate state `magus upgrade` itself warns about.
|
|
121
131
|
*/
|
|
122
132
|
export function formatUpdateMessage(result: VersionCheckResult): string {
|
|
123
133
|
if (!result.updateAvailable) return "";
|
|
124
134
|
|
|
125
135
|
const typeLabel = result.updateType ? ` (${result.updateType})` : "";
|
|
126
|
-
return `Update available: v${result.currentVersion} → v${result.latestVersion}${typeLabel}. Run:
|
|
136
|
+
return `Update available: v${result.currentVersion} → v${result.latestVersion}${typeLabel}. Run: magus upgrade`;
|
|
127
137
|
}
|