From 4c999edd52638f4527897b61fbb57c73ac63924c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jy=E8=80=B6?= <1849120682@qq.com> Date: Sun, 9 Aug 2026 11:26:28 +0800 Subject: [PATCH 1/9] feat: support self-update via pi-web update --- README.md | 8 ++ README.zh-CN.md | 8 ++ bin/pi-web-update.js | 159 +++++++++++++++++++++++++++++++++++++ bin/pi-web-update.test.mjs | 56 +++++++++++++ bin/pi-web.js | 7 ++ package.json | 2 +- 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 bin/pi-web-update.js create mode 100644 bin/pi-web-update.test.mjs diff --git a/README.md b/README.md index 464d0cd15..4f404d547 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ npm install -g @agegr/pi-web pi-web ``` +**Update to the latest version:** + +```bash +pi-web update +``` + +`pi-web update` checks the npm registry for a newer release and reinstalls pi-web globally with the package manager that was used to install it (npm, pnpm, yarn, or bun). If pi-web was run through `npx`, the update installs the latest version globally. Restart pi-web after updating. + Then open [http://127.0.0.1:30141](http://127.0.0.1:30141). The CLI will try to open the browser automatically after the server is ready. Pi Web listens on `127.0.0.1` by default. **Options:** diff --git a/README.zh-CN.md b/README.zh-CN.md index 9c3a2f262..7efdcfafa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,6 +23,14 @@ npm install -g @agegr/pi-web pi-web ``` +**更新到最新版本:** + +```bash +pi-web update +``` + +`pi-web update` 会检查 npm registry 中是否有新版本,并使用安装 pi-web 时所用的包管理器(npm、pnpm、yarn 或 bun)重新全局安装。如果通过 `npx` 运行 pi-web,该命令会将最新版本全局安装到本机。更新完成后请重启 pi-web。 + 启动后打开 [http://127.0.0.1:30141](http://127.0.0.1:30141)。命令行版本会在服务就绪后尝试自动打开浏览器。Pi Web 默认仅监听 `127.0.0.1`。 **可选参数:** diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js new file mode 100644 index 000000000..c082c407a --- /dev/null +++ b/bin/pi-web-update.js @@ -0,0 +1,159 @@ +"use strict"; + +// Implements `pi-web update`: check the npm registry for a newer release of +// @agegr/pi-web, then reinstall the package globally with the same package +// manager that was used to install it (npm, pnpm, yarn, or bun). + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { spawnSync } = require("child_process"); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { name: PACKAGE_NAME, version: CURRENT_VERSION } = require("../package.json"); +const VERSION_CHECK_TIMEOUT_MS = 15_000; + +const STABLE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; + +function parseStableVersion(version) { + const match = STABLE_VERSION_PATTERN.exec(String(version).trim()); + if (!match) return null; + const parts = match.slice(1).map(Number); + if (parts.some((part) => !Number.isSafeInteger(part))) return null; + return parts; +} + +function isNewerVersion(candidate, current) { + const candidateParts = parseStableVersion(candidate); + const currentParts = parseStableVersion(current); + if (!candidateParts || !currentParts) return false; + + for (let index = 0; index < candidateParts.length; index += 1) { + if (candidateParts[index] !== currentParts[index]) { + return candidateParts[index] > currentParts[index]; + } + } + return false; +} + +// Detect the package manager that installed this copy of pi-web from the +// installation path. Global installs always live under +// `/node_modules/@agegr/pi-web/bin`, and each package manager uses +// a recognizable directory layout above that. +function detectInstallMethod(dir = __dirname) { + const normalized = dir.toLowerCase().replace(/\\/g, "/"); + if (normalized.includes("/pnpm/") || normalized.includes("/.pnpm/")) return "pnpm"; + if (normalized.includes("/yarn/") || normalized.includes("/.yarn/")) return "yarn"; + if (normalized.includes("/install/global/node_modules/")) return "bun"; + if (normalized.includes("/npm/") || normalized.includes("/node_modules/")) return "npm"; + return "unknown"; +} + +function getUpdateCommand(method, version) { + const spec = `${PACKAGE_NAME}@${version}`; + switch (method) { + case "pnpm": + return { command: "pnpm", args: ["install", "-g", spec] }; + case "yarn": + return { command: "yarn", args: ["global", "add", spec] }; + case "bun": + return { command: "bun", args: ["add", "-g", spec] }; + default: + return { command: "npm", args: ["install", "-g", spec] }; + } +} + +function runCommand(command, args) { + // npm/pnpm/yarn ship as .cmd shims on Windows and cannot be spawned + // directly, so resolve them through the shell there. + const result = spawnSync(command, args, { + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with code ${result.status ?? "unknown"}`); + } +} + +// Query the registry through npm so the check and the install use the same +// registry, proxy, and timeout configuration as the package manager itself. +function getLatestVersion() { + const result = spawnSync( + "npm", + ["view", PACKAGE_NAME, "version", "--json", `--fetch-timeout=${VERSION_CHECK_TIMEOUT_MS}`], + { encoding: "utf8", shell: process.platform === "win32" }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = (result.stderr || "").trim() || `npm view exited with code ${result.status}`; + throw new Error(detail); + } + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + throw new Error("npm view returned unexpected output"); + } + if (typeof parsed === "string") return parsed; + if (Array.isArray(parsed)) { + const versions = parsed.filter((value) => typeof value === "string"); + if (versions.length > 0) return versions[versions.length - 1]; + } + return ""; +} + +function getManualInstallHint(version = "latest") { + return `npm install -g ${PACKAGE_NAME}@${version}`; +} + +function runUpdateInternal() { + const method = detectInstallMethod(); + if (method === "unknown") { + console.error("error: could not determine how pi-web was installed."); + console.error(`Update it manually with: ${getManualInstallHint()}`); + return 1; + } + + console.log(`Checking for updates to ${PACKAGE_NAME}...`); + let latestVersion; + try { + latestVersion = getLatestVersion(); + } catch (error) { + console.error(`error: could not check for updates: ${error.message}`); + console.error(`Update it manually with: ${getManualInstallHint()}`); + return 1; + } + if (!latestVersion || !isNewerVersion(latestVersion, CURRENT_VERSION)) { + console.log(`${PACKAGE_NAME} is already up to date (v${CURRENT_VERSION})`); + return 0; + } + + const updateCommand = getUpdateCommand(method, latestVersion); + const commandDisplay = [updateCommand.command, ...updateCommand.args].join(" "); + console.log(`Updating ${PACKAGE_NAME} from v${CURRENT_VERSION} to v${latestVersion} with ${commandDisplay}...`); + try { + runCommand(updateCommand.command, updateCommand.args); + } catch (error) { + console.error(`error: update failed: ${error.message}`); + console.error(`If this keeps failing, run the command yourself: ${getManualInstallHint(latestVersion)}`); + return 1; + } + console.log(`${PACKAGE_NAME} updated to v${latestVersion}. Restart pi-web to use the new version.`); + return 0; +} + +function runUpdate() { + try { + return runUpdateInternal(); + } catch (error) { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + console.error(`Update it manually with: ${getManualInstallHint()}`); + return 1; + } +} + +module.exports = { + detectInstallMethod, + getUpdateCommand, + isNewerVersion, + runUpdate, +}; diff --git a/bin/pi-web-update.test.mjs b/bin/pi-web-update.test.mjs new file mode 100644 index 000000000..28510802a --- /dev/null +++ b/bin/pi-web-update.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const { detectInstallMethod, getUpdateCommand, isNewerVersion } = require("./pi-web-update.js"); + +test("detects newer stable versions", () => { + assert.equal(isNewerVersion("0.8.8", "0.8.7"), true); + assert.equal(isNewerVersion("0.9.0", "0.8.7"), true); + assert.equal(isNewerVersion("1.0.0", "0.9.9"), true); +}); + +test("does not report equal, older, or unsupported versions as updates", () => { + assert.equal(isNewerVersion("0.8.7", "0.8.7"), false); + assert.equal(isNewerVersion("0.8.6", "0.8.7"), false); + assert.equal(isNewerVersion("0.8.8-beta.1", "0.8.7"), false); + assert.equal(isNewerVersion("invalid", "0.8.7"), false); +}); + +test("detects the package manager from the installation path", () => { + assert.equal(detectInstallMethod("C:/Users/x/AppData/Roaming/npm/node_modules/@agegr/pi-web/bin"), "npm"); + assert.equal(detectInstallMethod("/usr/local/lib/node_modules/@agegr/pi-web/bin"), "npm"); + assert.equal(detectInstallMethod("C:/Users/x/AppData/Local/npm-cache/_npx/abc123/node_modules/@agegr/pi-web/bin"), "npm"); + assert.equal(detectInstallMethod("/home/x/.npm/_npx/abc123/node_modules/@agegr/pi-web/bin"), "npm"); + assert.equal(detectInstallMethod("C:/Users/x/AppData/Local/pnpm/global/5/node_modules/.pnpm/@agegr+pi-web@0.8.7/node_modules/@agegr/pi-web/bin"), "pnpm"); + assert.equal(detectInstallMethod("/home/x/.local/share/pnpm/global/5/node_modules/@agegr/pi-web/bin"), "pnpm"); + assert.equal(detectInstallMethod("C:/Users/x/AppData/Local/Yarn/Config/global/node_modules/@agegr/pi-web/bin"), "yarn"); + assert.equal(detectInstallMethod("/home/x/.yarn/global/node_modules/@agegr/pi-web/bin"), "yarn"); + assert.equal(detectInstallMethod("C:/Users/x/.bun/install/global/node_modules/@agegr/pi-web/bin"), "bun"); + assert.equal(detectInstallMethod("/home/x/.bun/install/global/node_modules/@agegr/pi-web/bin"), "bun"); +}); + +test("does not guess a package manager for non-global checkouts", () => { + assert.equal(detectInstallMethod("C:/Users/x/projects/pi-web/bin"), "unknown"); + assert.equal(detectInstallMethod("/home/x/pi-web/bin"), "unknown"); +}); + +test("builds the update command for each package manager", () => { + assert.deepEqual(getUpdateCommand("npm", "0.8.8"), { + command: "npm", + args: ["install", "-g", "@agegr/pi-web@0.8.8"], + }); + assert.deepEqual(getUpdateCommand("pnpm", "0.8.8"), { + command: "pnpm", + args: ["install", "-g", "@agegr/pi-web@0.8.8"], + }); + assert.deepEqual(getUpdateCommand("yarn", "0.8.8"), { + command: "yarn", + args: ["global", "add", "@agegr/pi-web@0.8.8"], + }); + assert.deepEqual(getUpdateCommand("bun", "0.8.8"), { + command: "bun", + args: ["add", "-g", "@agegr/pi-web@0.8.8"], + }); +}); diff --git a/bin/pi-web.js b/bin/pi-web.js index ae7e571c0..10f84d796 100755 --- a/bin/pi-web.js +++ b/bin/pi-web.js @@ -9,6 +9,13 @@ if (!isNodeVersionSupported(process.versions.node)) { process.exit(1); } +const args = process.argv.slice(2); +if (args.includes("update")) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runUpdate } = require("./pi-web-update"); + process.exit(runUpdate()); +} + // eslint-disable-next-line @typescript-eslint/no-require-imports const { spawn } = require("child_process"); // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/package.json b/package.json index c325228e2..1b43b4592 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "start": "next start -H 127.0.0.1 -p 30141", "start:lan": "next start -H 0.0.0.0 -p 30141", "lint": "eslint .", - "test": "node --experimental-strip-types --test \"app/**/*.test.mjs\" \"components/**/*.test.mjs\" \"hooks/**/*.test.mjs\" \"lib/**/*.test.mjs\" \"public/**/*.test.mjs\"", + "test": "node --experimental-strip-types --test \"app/**/*.test.mjs\" \"components/**/*.test.mjs\" \"hooks/**/*.test.mjs\" \"lib/**/*.test.mjs\" \"public/**/*.test.mjs\" \"bin/**/*.test.mjs\"", "release": "npm version patch --no-git-tag-version && npm run build && npm publish --access public" }, "dependencies": { From 0bfa8ff2406c6be13929b93c4d0c58241ca50d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jy=E8=80=B6?= <1849120682@qq.com> Date: Sun, 9 Aug 2026 20:33:32 +0800 Subject: [PATCH 2/9] fix: address review feedback on pi-web update - use pnpm add -g for pnpm global installs - dispatch update only when it is the first positional argument - check the latest version with the detected package manager instead of npm --- bin/pi-web-options.js | 3 +- bin/pi-web-options.test.mjs | 26 +++++++++++ bin/pi-web-update.js | 86 +++++++++++++++++++++++++++---------- bin/pi-web-update.test.mjs | 40 ++++++++++++++++- bin/pi-web.js | 14 +++--- 5 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 bin/pi-web-options.test.mjs diff --git a/bin/pi-web-options.js b/bin/pi-web-options.js index b27eb9773..f7ac6e71b 100644 --- a/bin/pi-web-options.js +++ b/bin/pi-web-options.js @@ -10,7 +10,7 @@ function isEnabled(value) { } function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { - const { values: cliArgs } = parseArgs({ + const { values: cliArgs, positionals } = parseArgs({ args, options: { port: { type: "string", short: "p" }, @@ -24,6 +24,7 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { port: cliArgs.port ?? env.PORT ?? "30141", hostname: cliArgs.hostname ?? env.PI_WEB_HOSTNAME ?? "127.0.0.1", openBrowser: !cliArgs["no-open"] && !isEnabled(env.PI_WEB_NO_OPEN), + positionals, }; } diff --git a/bin/pi-web-options.test.mjs b/bin/pi-web-options.test.mjs new file mode 100644 index 000000000..ec6a1e02e --- /dev/null +++ b/bin/pi-web-options.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const { parseLaunchOptions } = require("./pi-web-options.js"); + +test("reports the first positional argument", () => { + assert.deepEqual(parseLaunchOptions(["update"]).positionals, ["update"]); + assert.deepEqual(parseLaunchOptions(["update", "--port", "8080"]).positionals, ["update"]); + assert.deepEqual(parseLaunchOptions(["--port", "8080", "update"]).positionals, ["update"]); + assert.deepEqual(parseLaunchOptions(["--port", "8080"]).positionals, []); + assert.deepEqual(parseLaunchOptions([]).positionals, []); +}); + +test("does not mistake option values for positional arguments", () => { + assert.deepEqual(parseLaunchOptions(["--hostname", "update"]).positionals, []); + assert.deepEqual(parseLaunchOptions(["--port", "update"]).positionals, []); +}); + +test("keeps launch options stable", () => { + const options = parseLaunchOptions(["-p", "8080", "-H", "0.0.0.0", "--no-open"]); + assert.equal(options.port, "8080"); + assert.equal(options.hostname, "0.0.0.0"); + assert.equal(options.openBrowser, false); +}); diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js index c082c407a..51fc18dff 100644 --- a/bin/pi-web-update.js +++ b/bin/pi-web-update.js @@ -6,6 +6,8 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const { spawnSync } = require("child_process"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const path = require("path"); // eslint-disable-next-line @typescript-eslint/no-require-imports const { name: PACKAGE_NAME, version: CURRENT_VERSION } = require("../package.json"); @@ -51,7 +53,7 @@ function getUpdateCommand(method, version) { const spec = `${PACKAGE_NAME}@${version}`; switch (method) { case "pnpm": - return { command: "pnpm", args: ["install", "-g", spec] }; + return { command: "pnpm", args: ["add", "-g", spec] }; case "yarn": return { command: "yarn", args: ["global", "add", spec] }; case "bun": @@ -61,6 +63,32 @@ function getUpdateCommand(method, version) { } } +// Each package manager has its own registry-aware way to print the latest +// version of a package. Using the detected package manager (instead of always +// calling npm) keeps the check consistent with the registry, proxy, and +// timeout configuration the update itself will use. +function getVersionCheckCommand(method) { + switch (method) { + case "pnpm": + return { command: "pnpm", args: ["view", PACKAGE_NAME, "version", "--json"] }; + case "yarn": + return { command: "yarn", args: ["info", PACKAGE_NAME, "version"] }; + case "bun": + // `bun pm view` resolves the workspace from the current directory, so + // run it from the package directory, which always ships a package.json. + return { + command: "bun", + args: ["pm", "view", PACKAGE_NAME, "version"], + cwd: path.join(__dirname, ".."), + }; + default: + return { + command: "npm", + args: ["view", PACKAGE_NAME, "version", "--json", `--fetch-timeout=${VERSION_CHECK_TIMEOUT_MS}`], + }; + } +} + function runCommand(command, args) { // npm/pnpm/yarn ship as .cmd shims on Windows and cannot be spawned // directly, so resolve them through the shell there. @@ -74,31 +102,41 @@ function runCommand(command, args) { } } -// Query the registry through npm so the check and the install use the same -// registry, proxy, and timeout configuration as the package manager itself. -function getLatestVersion() { - const result = spawnSync( - "npm", - ["view", PACKAGE_NAME, "version", "--json", `--fetch-timeout=${VERSION_CHECK_TIMEOUT_MS}`], - { encoding: "utf8", shell: process.platform === "win32" }, - ); +// npm and pnpm print JSON (a quoted string, or an array on newer npm); yarn +// and bun print the bare version on the last line. +function parseVersionOutput(method, stdout) { + if (method === "npm" || method === "pnpm") { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return ""; + } + if (typeof parsed === "string") return parsed; + if (Array.isArray(parsed)) { + const versions = parsed.filter((value) => typeof value === "string"); + if (versions.length > 0) return versions[versions.length - 1]; + } + return ""; + } + const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean); + return lines.length > 0 ? lines[lines.length - 1] : ""; +} + +function getLatestVersion(method) { + const check = getVersionCheckCommand(method); + const result = spawnSync(check.command, check.args, { + encoding: "utf8", + shell: process.platform === "win32", + timeout: VERSION_CHECK_TIMEOUT_MS, + cwd: check.cwd, + }); if (result.error) throw result.error; if (result.status !== 0) { - const detail = (result.stderr || "").trim() || `npm view exited with code ${result.status}`; + const detail = (result.stderr || "").trim() || `${check.command} exited with code ${result.status}`; throw new Error(detail); } - let parsed; - try { - parsed = JSON.parse(result.stdout); - } catch { - throw new Error("npm view returned unexpected output"); - } - if (typeof parsed === "string") return parsed; - if (Array.isArray(parsed)) { - const versions = parsed.filter((value) => typeof value === "string"); - if (versions.length > 0) return versions[versions.length - 1]; - } - return ""; + return parseVersionOutput(method, result.stdout); } function getManualInstallHint(version = "latest") { @@ -116,7 +154,7 @@ function runUpdateInternal() { console.log(`Checking for updates to ${PACKAGE_NAME}...`); let latestVersion; try { - latestVersion = getLatestVersion(); + latestVersion = getLatestVersion(method); } catch (error) { console.error(`error: could not check for updates: ${error.message}`); console.error(`Update it manually with: ${getManualInstallHint()}`); @@ -154,6 +192,8 @@ function runUpdate() { module.exports = { detectInstallMethod, getUpdateCommand, + getVersionCheckCommand, isNewerVersion, + parseVersionOutput, runUpdate, }; diff --git a/bin/pi-web-update.test.mjs b/bin/pi-web-update.test.mjs index 28510802a..c5abc28f3 100644 --- a/bin/pi-web-update.test.mjs +++ b/bin/pi-web-update.test.mjs @@ -1,9 +1,14 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; const require = createRequire(import.meta.url); -const { detectInstallMethod, getUpdateCommand, isNewerVersion } = require("./pi-web-update.js"); +const { detectInstallMethod, getUpdateCommand, getVersionCheckCommand, isNewerVersion, parseVersionOutput } = require("./pi-web-update.js"); + +// mirrors path.join(__dirname, "..") inside the module +const PACKAGE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); test("detects newer stable versions", () => { assert.equal(isNewerVersion("0.8.8", "0.8.7"), true); @@ -43,7 +48,7 @@ test("builds the update command for each package manager", () => { }); assert.deepEqual(getUpdateCommand("pnpm", "0.8.8"), { command: "pnpm", - args: ["install", "-g", "@agegr/pi-web@0.8.8"], + args: ["add", "-g", "@agegr/pi-web@0.8.8"], }); assert.deepEqual(getUpdateCommand("yarn", "0.8.8"), { command: "yarn", @@ -54,3 +59,34 @@ test("builds the update command for each package manager", () => { args: ["add", "-g", "@agegr/pi-web@0.8.8"], }); }); + +test("builds the version check command for each package manager", () => { + assert.deepEqual(getVersionCheckCommand("npm"), { + command: "npm", + args: ["view", "@agegr/pi-web", "version", "--json", "--fetch-timeout=15000"], + }); + assert.deepEqual(getVersionCheckCommand("pnpm"), { + command: "pnpm", + args: ["view", "@agegr/pi-web", "version", "--json"], + }); + assert.deepEqual(getVersionCheckCommand("yarn"), { + command: "yarn", + args: ["info", "@agegr/pi-web", "version"], + }); + const bun = getVersionCheckCommand("bun"); + assert.deepEqual(bun, { + command: "bun", + args: ["pm", "view", "@agegr/pi-web", "version"], + cwd: PACKAGE_DIR, + }); +}); + +test("parses version output from each package manager", () => { + assert.equal(parseVersionOutput("npm", '"0.8.8"'), "0.8.8"); + assert.equal(parseVersionOutput("npm", '["0.8.8"]'), "0.8.8"); + assert.equal(parseVersionOutput("pnpm", '"0.8.8"'), "0.8.8"); + assert.equal(parseVersionOutput("yarn", "0.8.8\n"), "0.8.8"); + assert.equal(parseVersionOutput("bun", "0.8.8\n"), "0.8.8"); + assert.equal(parseVersionOutput("npm", "not json"), ""); + assert.equal(parseVersionOutput("bun", ""), ""); +}); diff --git a/bin/pi-web.js b/bin/pi-web.js index 10f84d796..e3ef8cb1d 100755 --- a/bin/pi-web.js +++ b/bin/pi-web.js @@ -9,13 +9,6 @@ if (!isNodeVersionSupported(process.versions.node)) { process.exit(1); } -const args = process.argv.slice(2); -if (args.includes("update")) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { runUpdate } = require("./pi-web-update"); - process.exit(runUpdate()); -} - // eslint-disable-next-line @typescript-eslint/no-require-imports const { spawn } = require("child_process"); // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -43,7 +36,12 @@ try { } } -const { port, hostname, openBrowser } = parseLaunchOptions(); +const { port, hostname, openBrowser, positionals } = parseLaunchOptions(); +if (positionals[0] === "update") { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runUpdate } = require("./pi-web-update"); + process.exit(runUpdate()); +} const loopbackHostnames = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); const passwordEnabled = Boolean(process.env.PI_WEB_PASSWORD); From b3ebce41737f7994d7473d76d18f11618b70ae9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jy=E8=80=B6?= <1849120682@qq.com> Date: Sun, 9 Aug 2026 20:39:07 +0800 Subject: [PATCH 3/9] refactor: share launch option definitions for command positionals --- bin/pi-web-options.js | 23 +++++++++++++++-------- bin/pi-web-options.test.mjs | 16 ++++++++-------- bin/pi-web.js | 4 ++-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/bin/pi-web-options.js b/bin/pi-web-options.js index f7ac6e71b..df82c561c 100644 --- a/bin/pi-web-options.js +++ b/bin/pi-web-options.js @@ -9,14 +9,16 @@ function isEnabled(value) { return typeof value === "string" && TRUE_VALUES.has(value.trim().toLowerCase()); } +const LAUNCH_OPTIONS = { + port: { type: "string", short: "p" }, + hostname: { type: "string", short: "H" }, + "no-open": { type: "boolean" }, +}; + function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { - const { values: cliArgs, positionals } = parseArgs({ + const { values: cliArgs } = parseArgs({ args, - options: { - port: { type: "string", short: "p" }, - hostname: { type: "string", short: "H" }, - "no-open": { type: "boolean" }, - }, + options: LAUNCH_OPTIONS, strict: false, }); @@ -24,8 +26,13 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { port: cliArgs.port ?? env.PORT ?? "30141", hostname: cliArgs.hostname ?? env.PI_WEB_HOSTNAME ?? "127.0.0.1", openBrowser: !cliArgs["no-open"] && !isEnabled(env.PI_WEB_NO_OPEN), - positionals, }; } -module.exports = { parseLaunchOptions }; +// Only used to detect the `update` subcommand. Kept separate from +// parseLaunchOptions so the launch options object stays stable. +function getCommandPositionals(args = process.argv.slice(2)) { + return parseArgs({ args, options: LAUNCH_OPTIONS, strict: false }).positionals; +} + +module.exports = { getCommandPositionals, parseLaunchOptions }; diff --git a/bin/pi-web-options.test.mjs b/bin/pi-web-options.test.mjs index ec6a1e02e..a8e3d4f79 100644 --- a/bin/pi-web-options.test.mjs +++ b/bin/pi-web-options.test.mjs @@ -3,19 +3,19 @@ import { createRequire } from "node:module"; import test from "node:test"; const require = createRequire(import.meta.url); -const { parseLaunchOptions } = require("./pi-web-options.js"); +const { getCommandPositionals, parseLaunchOptions } = require("./pi-web-options.js"); test("reports the first positional argument", () => { - assert.deepEqual(parseLaunchOptions(["update"]).positionals, ["update"]); - assert.deepEqual(parseLaunchOptions(["update", "--port", "8080"]).positionals, ["update"]); - assert.deepEqual(parseLaunchOptions(["--port", "8080", "update"]).positionals, ["update"]); - assert.deepEqual(parseLaunchOptions(["--port", "8080"]).positionals, []); - assert.deepEqual(parseLaunchOptions([]).positionals, []); + assert.deepEqual(getCommandPositionals(["update"]), ["update"]); + assert.deepEqual(getCommandPositionals(["update", "--port", "8080"]), ["update"]); + assert.deepEqual(getCommandPositionals(["--port", "8080", "update"]), ["update"]); + assert.deepEqual(getCommandPositionals(["--port", "8080"]), []); + assert.deepEqual(getCommandPositionals([]), []); }); test("does not mistake option values for positional arguments", () => { - assert.deepEqual(parseLaunchOptions(["--hostname", "update"]).positionals, []); - assert.deepEqual(parseLaunchOptions(["--port", "update"]).positionals, []); + assert.deepEqual(getCommandPositionals(["--hostname", "update"]), []); + assert.deepEqual(getCommandPositionals(["--port", "update"]), []); }); test("keeps launch options stable", () => { diff --git a/bin/pi-web.js b/bin/pi-web.js index e3ef8cb1d..9c160b200 100755 --- a/bin/pi-web.js +++ b/bin/pi-web.js @@ -36,8 +36,8 @@ try { } } -const { port, hostname, openBrowser, positionals } = parseLaunchOptions(); -if (positionals[0] === "update") { +const { port, hostname, openBrowser } = parseLaunchOptions(); +if (getCommandPositionals(process.argv.slice(2))[0] === "update") { // eslint-disable-next-line @typescript-eslint/no-require-imports const { runUpdate } = require("./pi-web-update"); process.exit(runUpdate()); From cc400276d07b0197298540fbe68003b393cf2edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jy=E8=80=B6?= <1849120682@qq.com> Date: Sun, 9 Aug 2026 21:36:22 +0800 Subject: [PATCH 4/9] fix: parse package manager version output and dispatch CLI tests - scan for the first semver line in yarn/bun output (yarn appends "Done in ..." noise that the last-line parser mistook for the version) - import getCommandPositionals in the entry script - add entry-point tests that execute bin/pi-web.js directly --- bin/pi-web-cli.test.mjs | 35 +++++++++++++++++++++++++++++++++++ bin/pi-web-update.js | 7 ++++++- bin/pi-web-update.test.mjs | 1 + bin/pi-web.js | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 bin/pi-web-cli.test.mjs diff --git a/bin/pi-web-cli.test.mjs b/bin/pi-web-cli.test.mjs new file mode 100644 index 000000000..aa2c28b77 --- /dev/null +++ b/bin/pi-web-cli.test.mjs @@ -0,0 +1,35 @@ +// Entry-point tests: execute bin/pi-web.js and assert the subcommand +// dispatch. The repo checkout has no .next build, so both paths terminate +// with a clean error instead of launching the server. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "pi-web.js"); + +test("pi-web update dispatches to the update command", () => { + const result = spawnSync(process.execPath, [BIN, "update"], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /could not determine how pi-web was installed/); +}); + +test("pi-web without a subcommand still takes the launch path", () => { + const result = spawnSync(process.execPath, [BIN], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Build artifacts not found/); +}); + +test("option values named update do not trigger the update command", () => { + const result = spawnSync(process.execPath, [BIN, "--hostname", "update"], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.doesNotMatch(result.stderr, /could not determine how pi-web was installed/); + assert.match(result.stderr, /Build artifacts not found/); +}); diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js index 51fc18dff..94b010e61 100644 --- a/bin/pi-web-update.js +++ b/bin/pi-web-update.js @@ -119,8 +119,13 @@ function parseVersionOutput(method, stdout) { } return ""; } + // yarn and bun print the bare version; scan for the first semver-looking + // line because yarn appends "Done in ..." noise after the value. const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean); - return lines.length > 0 ? lines[lines.length - 1] : ""; + for (const line of lines) { + if (STABLE_VERSION_PATTERN.test(line)) return line; + } + return ""; } function getLatestVersion(method) { diff --git a/bin/pi-web-update.test.mjs b/bin/pi-web-update.test.mjs index c5abc28f3..3569063ee 100644 --- a/bin/pi-web-update.test.mjs +++ b/bin/pi-web-update.test.mjs @@ -86,6 +86,7 @@ test("parses version output from each package manager", () => { assert.equal(parseVersionOutput("npm", '["0.8.8"]'), "0.8.8"); assert.equal(parseVersionOutput("pnpm", '"0.8.8"'), "0.8.8"); assert.equal(parseVersionOutput("yarn", "0.8.8\n"), "0.8.8"); + assert.equal(parseVersionOutput("yarn", "0.8.8\nDone in 0.11s.\n"), "0.8.8"); assert.equal(parseVersionOutput("bun", "0.8.8\n"), "0.8.8"); assert.equal(parseVersionOutput("npm", "not json"), ""); assert.equal(parseVersionOutput("bun", ""), ""); diff --git a/bin/pi-web.js b/bin/pi-web.js index 9c160b200..f4cff0b8e 100755 --- a/bin/pi-web.js +++ b/bin/pi-web.js @@ -16,7 +16,7 @@ const path = require("path"); // eslint-disable-next-line @typescript-eslint/no-require-imports const fs = require("fs"); // eslint-disable-next-line @typescript-eslint/no-require-imports -const { parseLaunchOptions } = require("./pi-web-options"); +const { getCommandPositionals, parseLaunchOptions } = require("./pi-web-options"); const pkgDir = path.join(__dirname, ".."); const nextDir = path.join(pkgDir, ".next"); From 57fe10b604a0b516a5a561f1a05c0c3e79808d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jy=E8=80=B6?= <1849120682@qq.com> Date: Sun, 9 Aug 2026 21:39:01 +0800 Subject: [PATCH 5/9] docs: trim update command description and fix stale wording --- README.md | 2 +- README.zh-CN.md | 2 +- bin/pi-web-update.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4f404d547..55293417d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pi-web pi-web update ``` -`pi-web update` checks the npm registry for a newer release and reinstalls pi-web globally with the package manager that was used to install it (npm, pnpm, yarn, or bun). If pi-web was run through `npx`, the update installs the latest version globally. Restart pi-web after updating. +`pi-web update` checks for a newer release and reinstalls pi-web globally with the package manager that installed it (npm, pnpm, yarn, or bun). Restart pi-web after updating. Then open [http://127.0.0.1:30141](http://127.0.0.1:30141). The CLI will try to open the browser automatically after the server is ready. Pi Web listens on `127.0.0.1` by default. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7efdcfafa..51299b83e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,7 +29,7 @@ pi-web pi-web update ``` -`pi-web update` 会检查 npm registry 中是否有新版本,并使用安装 pi-web 时所用的包管理器(npm、pnpm、yarn 或 bun)重新全局安装。如果通过 `npx` 运行 pi-web,该命令会将最新版本全局安装到本机。更新完成后请重启 pi-web。 +`pi-web update` 会检查新版本,并使用当初安装 pi-web 的包管理器(npm、pnpm、yarn 或 bun)重新全局安装。更新完成后请重启 pi-web。 启动后打开 [http://127.0.0.1:30141](http://127.0.0.1:30141)。命令行版本会在服务就绪后尝试自动打开浏览器。Pi Web 默认仅监听 `127.0.0.1`。 diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js index 94b010e61..cedd232ba 100644 --- a/bin/pi-web-update.js +++ b/bin/pi-web-update.js @@ -1,8 +1,8 @@ "use strict"; -// Implements `pi-web update`: check the npm registry for a newer release of -// @agegr/pi-web, then reinstall the package globally with the same package -// manager that was used to install it (npm, pnpm, yarn, or bun). +// Implements `pi-web update`: check for a newer release of @agegr/pi-web, +// then reinstall the package globally with the same package manager that +// installed it (npm, pnpm, yarn, or bun). // eslint-disable-next-line @typescript-eslint/no-require-imports const { spawnSync } = require("child_process"); @@ -103,7 +103,7 @@ function runCommand(command, args) { } // npm and pnpm print JSON (a quoted string, or an array on newer npm); yarn -// and bun print the bare version on the last line. +// and bun print the bare version. function parseVersionOutput(method, stdout) { if (method === "npm" || method === "pnpm") { let parsed; From 0b65a5e00e77cc4bbf4cecb138fa40d1cb605b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jy=E8=80=B6?= <1849120682@qq.com> Date: Sun, 9 Aug 2026 21:46:30 +0800 Subject: [PATCH 6/9] docs: keep update instructions minimal --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55293417d..8a8f6e0f5 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pi-web pi-web update ``` -`pi-web update` checks for a newer release and reinstalls pi-web globally with the package manager that installed it (npm, pnpm, yarn, or bun). Restart pi-web after updating. +Restart pi-web after updating. Then open [http://127.0.0.1:30141](http://127.0.0.1:30141). The CLI will try to open the browser automatically after the server is ready. Pi Web listens on `127.0.0.1` by default. diff --git a/README.zh-CN.md b/README.zh-CN.md index 51299b83e..815901798 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,7 +29,7 @@ pi-web pi-web update ``` -`pi-web update` 会检查新版本,并使用当初安装 pi-web 的包管理器(npm、pnpm、yarn 或 bun)重新全局安装。更新完成后请重启 pi-web。 +更新完成后请重启 pi-web。 启动后打开 [http://127.0.0.1:30141](http://127.0.0.1:30141)。命令行版本会在服务就绪后尝试自动打开浏览器。Pi Web 默认仅监听 `127.0.0.1`。 From 6179e5a45685a3e597985160e763ae89adcbc92c Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Tue, 11 Aug 2026 20:51:56 +0800 Subject: [PATCH 7/9] fix: harden update command dispatch --- bin/pi-web-cli.test.mjs | 35 ----------------------------------- bin/pi-web-options.js | 17 +++++++++++++++-- bin/pi-web-options.test.mjs | 24 +++++++++++++++++++++++- bin/pi-web.js | 4 ++-- 4 files changed, 40 insertions(+), 40 deletions(-) delete mode 100644 bin/pi-web-cli.test.mjs diff --git a/bin/pi-web-cli.test.mjs b/bin/pi-web-cli.test.mjs deleted file mode 100644 index aa2c28b77..000000000 --- a/bin/pi-web-cli.test.mjs +++ /dev/null @@ -1,35 +0,0 @@ -// Entry-point tests: execute bin/pi-web.js and assert the subcommand -// dispatch. The repo checkout has no .next build, so both paths terminate -// with a clean error instead of launching the server. -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "pi-web.js"); - -test("pi-web update dispatches to the update command", () => { - const result = spawnSync(process.execPath, [BIN, "update"], { - encoding: "utf8", - }); - assert.equal(result.status, 1); - assert.match(result.stderr, /could not determine how pi-web was installed/); -}); - -test("pi-web without a subcommand still takes the launch path", () => { - const result = spawnSync(process.execPath, [BIN], { - encoding: "utf8", - }); - assert.equal(result.status, 1); - assert.match(result.stderr, /Build artifacts not found/); -}); - -test("option values named update do not trigger the update command", () => { - const result = spawnSync(process.execPath, [BIN, "--hostname", "update"], { - encoding: "utf8", - }); - assert.equal(result.status, 1); - assert.doesNotMatch(result.stderr, /could not determine how pi-web was installed/); - assert.match(result.stderr, /Build artifacts not found/); -}); diff --git a/bin/pi-web-options.js b/bin/pi-web-options.js index df82c561c..ea10da0b3 100644 --- a/bin/pi-web-options.js +++ b/bin/pi-web-options.js @@ -32,7 +32,20 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { // Only used to detect the `update` subcommand. Kept separate from // parseLaunchOptions so the launch options object stays stable. function getCommandPositionals(args = process.argv.slice(2)) { - return parseArgs({ args, options: LAUNCH_OPTIONS, strict: false }).positionals; + try { + return parseArgs({ + args, + options: LAUNCH_OPTIONS, + strict: true, + allowPositionals: true, + }).positionals; + } catch { + return []; + } } -module.exports = { getCommandPositionals, parseLaunchOptions }; +function shouldRunUpdate(args = process.argv.slice(2)) { + return getCommandPositionals(args)[0] === "update"; +} + +module.exports = { getCommandPositionals, parseLaunchOptions, shouldRunUpdate }; diff --git a/bin/pi-web-options.test.mjs b/bin/pi-web-options.test.mjs index a8e3d4f79..48a0fd6dd 100644 --- a/bin/pi-web-options.test.mjs +++ b/bin/pi-web-options.test.mjs @@ -3,7 +3,11 @@ import { createRequire } from "node:module"; import test from "node:test"; const require = createRequire(import.meta.url); -const { getCommandPositionals, parseLaunchOptions } = require("./pi-web-options.js"); +const { + getCommandPositionals, + parseLaunchOptions, + shouldRunUpdate, +} = require("./pi-web-options.js"); test("reports the first positional argument", () => { assert.deepEqual(getCommandPositionals(["update"]), ["update"]); @@ -18,6 +22,24 @@ test("does not mistake option values for positional arguments", () => { assert.deepEqual(getCommandPositionals(["--port", "update"]), []); }); +test("dispatches only when the first positional is update", () => { + assert.equal(shouldRunUpdate(["update"]), true); + assert.equal(shouldRunUpdate(["--port", "8080", "update"]), true); + assert.equal(shouldRunUpdate(["serve", "update"]), false); + assert.equal(shouldRunUpdate([]), false); +}); + +test("does not dispatch update when argument parsing fails", () => { + assert.equal(shouldRunUpdate(["--host", "update"]), false); + assert.equal(shouldRunUpdate(["--unknown", "value", "update"]), false); + assert.equal(shouldRunUpdate(["--hostname"]), false); +}); + +test("does not dispatch update when it is a known option value", () => { + assert.equal(shouldRunUpdate(["--hostname", "update"]), false); + assert.equal(shouldRunUpdate(["--port", "update"]), false); +}); + test("keeps launch options stable", () => { const options = parseLaunchOptions(["-p", "8080", "-H", "0.0.0.0", "--no-open"]); assert.equal(options.port, "8080"); diff --git a/bin/pi-web.js b/bin/pi-web.js index f4cff0b8e..f0ca9734e 100755 --- a/bin/pi-web.js +++ b/bin/pi-web.js @@ -16,7 +16,7 @@ const path = require("path"); // eslint-disable-next-line @typescript-eslint/no-require-imports const fs = require("fs"); // eslint-disable-next-line @typescript-eslint/no-require-imports -const { getCommandPositionals, parseLaunchOptions } = require("./pi-web-options"); +const { parseLaunchOptions, shouldRunUpdate } = require("./pi-web-options"); const pkgDir = path.join(__dirname, ".."); const nextDir = path.join(pkgDir, ".next"); @@ -37,7 +37,7 @@ try { } const { port, hostname, openBrowser } = parseLaunchOptions(); -if (getCommandPositionals(process.argv.slice(2))[0] === "update") { +if (shouldRunUpdate(process.argv.slice(2))) { // eslint-disable-next-line @typescript-eslint/no-require-imports const { runUpdate } = require("./pi-web-update"); process.exit(runUpdate()); From 3c0e4082dab177cdfd4ad1c58c4bea75760da0f6 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Tue, 11 Aug 2026 20:54:15 +0800 Subject: [PATCH 8/9] fix: validate self-update installation --- bin/pi-web-update.js | 208 ++++++++++++++++++++++++++++-------- bin/pi-web-update.test.mjs | 210 ++++++++++++++++++++++++++++++++++++- 2 files changed, 372 insertions(+), 46 deletions(-) diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js index cedd232ba..6a648b500 100644 --- a/bin/pi-web-update.js +++ b/bin/pi-web-update.js @@ -7,13 +7,17 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const { spawnSync } = require("child_process"); // eslint-disable-next-line @typescript-eslint/no-require-imports +const fs = require("fs"); +// eslint-disable-next-line @typescript-eslint/no-require-imports const path = require("path"); // eslint-disable-next-line @typescript-eslint/no-require-imports const { name: PACKAGE_NAME, version: CURRENT_VERSION } = require("../package.json"); const VERSION_CHECK_TIMEOUT_MS = 15_000; +const PACKAGE_ROOT = path.join(__dirname, ".."); const STABLE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; +const CURRENT_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; function parseStableVersion(version) { const match = STABLE_VERSION_PATTERN.exec(String(version).trim()); @@ -23,17 +27,25 @@ function parseStableVersion(version) { return parts; } +function parseCurrentVersion(version) { + const match = CURRENT_VERSION_PATTERN.exec(String(version).trim()); + if (!match) return null; + const parts = match.slice(1, 4).map(Number); + if (parts.some((part) => !Number.isSafeInteger(part))) return null; + return { parts, isPrerelease: match[4] !== undefined }; +} + function isNewerVersion(candidate, current) { const candidateParts = parseStableVersion(candidate); - const currentParts = parseStableVersion(current); - if (!candidateParts || !currentParts) return false; + const parsedCurrent = parseCurrentVersion(current); + if (!candidateParts || !parsedCurrent) return false; for (let index = 0; index < candidateParts.length; index += 1) { - if (candidateParts[index] !== currentParts[index]) { - return candidateParts[index] > currentParts[index]; + if (candidateParts[index] !== parsedCurrent.parts[index]) { + return candidateParts[index] > parsedCurrent.parts[index]; } } - return false; + return parsedCurrent.isPrerelease; } // Detect the package manager that installed this copy of pi-web from the @@ -63,38 +75,48 @@ function getUpdateCommand(method, version) { } } +function getGlobalRootCommand(method) { + switch (method) { + case "npm": + case "pnpm": + return { command: method, args: ["root", "-g"] }; + case "yarn": + return { command: "yarn", args: ["global", "dir", "--silent"] }; + case "bun": + return { command: "bun", args: ["pm", "ls", "-g"] }; + default: + throw new Error(`unsupported package manager: ${method}`); + } +} + // Each package manager has its own registry-aware way to print the latest // version of a package. Using the detected package manager (instead of always // calling npm) keeps the check consistent with the registry, proxy, and // timeout configuration the update itself will use. -function getVersionCheckCommand(method) { +function getVersionCheckCommand(method, cwd = PACKAGE_ROOT) { switch (method) { case "pnpm": - return { command: "pnpm", args: ["view", PACKAGE_NAME, "version", "--json"] }; + return { command: "pnpm", args: ["view", "-g", PACKAGE_NAME, "version", "--json"], cwd }; case "yarn": - return { command: "yarn", args: ["info", PACKAGE_NAME, "version"] }; + return { command: "yarn", args: ["info", PACKAGE_NAME, "version"], cwd }; case "bun": - // `bun pm view` resolves the workspace from the current directory, so - // run it from the package directory, which always ships a package.json. - return { - command: "bun", - args: ["pm", "view", PACKAGE_NAME, "version"], - cwd: path.join(__dirname, ".."), - }; + return { command: "bun", args: ["pm", "view", PACKAGE_NAME, "version"], cwd }; default: return { command: "npm", - args: ["view", PACKAGE_NAME, "version", "--json", `--fetch-timeout=${VERSION_CHECK_TIMEOUT_MS}`], + args: ["view", "-g", PACKAGE_NAME, "version", "--json", `--fetch-timeout=${VERSION_CHECK_TIMEOUT_MS}`], + cwd, }; } } -function runCommand(command, args) { +function runCommand(command, args, cwd, spawn = spawnSync) { // npm/pnpm/yarn ship as .cmd shims on Windows and cannot be spawned // directly, so resolve them through the shell there. - const result = spawnSync(command, args, { + const result = spawn(command, args, { stdio: "inherit", shell: process.platform === "win32", + cwd, }); if (result.error) throw result.error; if (result.status !== 0) { @@ -112,9 +134,15 @@ function parseVersionOutput(method, stdout) { } catch { return ""; } - if (typeof parsed === "string") return parsed; + if (typeof parsed === "string") { + const version = parsed.trim(); + return parseStableVersion(version) ? version : ""; + } if (Array.isArray(parsed)) { - const versions = parsed.filter((value) => typeof value === "string"); + const versions = parsed + .filter((value) => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => parseStableVersion(value)); if (versions.length > 0) return versions[versions.length - 1]; } return ""; @@ -128,59 +156,148 @@ function parseVersionOutput(method, stdout) { return ""; } -function getLatestVersion(method) { - const check = getVersionCheckCommand(method); - const result = spawnSync(check.command, check.args, { +function runForOutput(spec, spawn = spawnSync) { + const result = spawn(spec.command, spec.args, { encoding: "utf8", shell: process.platform === "win32", timeout: VERSION_CHECK_TIMEOUT_MS, - cwd: check.cwd, + cwd: spec.cwd, }); if (result.error) throw result.error; if (result.status !== 0) { - const detail = (result.stderr || "").trim() || `${check.command} exited with code ${result.status}`; + const detail = (result.stderr || "").trim() || `${spec.command} exited with code ${result.status ?? "unknown"}`; throw new Error(detail); } - return parseVersionOutput(method, result.stdout); + return String(result.stdout || ""); +} + +function getGlobalInstallRoot(method, cwd = PACKAGE_ROOT, spawn = spawnSync) { + const output = runForOutput({ ...getGlobalRootCommand(method), cwd }, spawn); + const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (method === "bun") { + const headerMatch = /^(.+) node_modules \(\d+\)$/.exec(lines[0] || ""); + const globalProjectPath = headerMatch?.[1]; + if (!globalProjectPath || !path.isAbsolute(globalProjectPath)) { + throw new Error("bun did not return an absolute global project path"); + } + return path.resolve(globalProjectPath, "node_modules"); + } + + const reportedPath = lines.find((line) => path.isAbsolute(line)); + if (!reportedPath) throw new Error(`${method} did not return an absolute global install path`); + + if (method === "yarn") return path.resolve(reportedPath, "node_modules"); + return path.resolve(reportedPath); } -function getManualInstallHint(version = "latest") { - return `npm install -g ${PACKAGE_NAME}@${version}`; +function comparableRealPath(filePath, realpath = fs.realpathSync.native) { + const normalized = path.normalize(realpath(path.resolve(filePath))); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; } -function runUpdateInternal() { - const method = detectInstallMethod(); +function validateGlobalInstall(method, packageRoot = PACKAGE_ROOT, options = {}) { + const normalizedPackageRoot = packageRoot.replace(/\\/g, "/").toLowerCase(); + if (normalizedPackageRoot.includes("/_npx/")) { + throw new Error("the running copy is an npx temporary install"); + } + + const spawn = options.spawnSync || spawnSync; + const realpath = options.realpathSync || fs.realpathSync.native; + const globalRoot = getGlobalInstallRoot(method, packageRoot, spawn); + const globalPackageRoot = path.join(globalRoot, PACKAGE_NAME); + + let runningPath; + let globalPath; + try { + runningPath = comparableRealPath(packageRoot, realpath); + globalPath = comparableRealPath(globalPackageRoot, realpath); + } catch (error) { + throw new Error(`could not resolve the active global install: ${error.message}`); + } + if (runningPath !== globalPath) { + throw new Error(`the running copy is not ${method}'s active global install`); + } + + return { + globalRoot, + packageRoot: globalPackageRoot, + packageJsonPath: path.join(globalPackageRoot, "package.json"), + }; +} + +function getLatestVersion(method, cwd = PACKAGE_ROOT, spawn = spawnSync) { + const check = getVersionCheckCommand(method, cwd); + const latestVersion = parseVersionOutput(method, runForOutput(check, spawn)); + if (!latestVersion) { + throw new Error(`${method} returned an empty or invalid stable version`); + } + return latestVersion; +} + +function getManualInstallHint(method = "npm", version = "latest") { + const update = getUpdateCommand(method, version); + return [update.command, ...update.args].join(" "); +} + +function readInstalledVersion(packageJsonPath, readFile = fs.readFileSync) { + const manifest = JSON.parse(readFile(packageJsonPath, "utf8")); + return typeof manifest.version === "string" ? manifest.version : ""; +} + +function runUpdateInternal(options = {}) { + const packageRoot = options.packageRoot || PACKAGE_ROOT; + const currentVersion = options.currentVersion || CURRENT_VERSION; + const method = options.method || detectInstallMethod(path.join(packageRoot, "bin")); + const spawn = options.spawnSync || spawnSync; + const readFile = options.readFileSync || fs.readFileSync; + const logger = options.console || console; if (method === "unknown") { - console.error("error: could not determine how pi-web was installed."); - console.error(`Update it manually with: ${getManualInstallHint()}`); + logger.error("error: could not determine how pi-web was installed."); + logger.error(`Update it manually with: ${getManualInstallHint()}`); return 1; } - console.log(`Checking for updates to ${PACKAGE_NAME}...`); + let install; + try { + install = validateGlobalInstall(method, packageRoot, { + spawnSync: spawn, + realpathSync: options.realpathSync, + }); + } catch (error) { + logger.error(`error: cannot update this installation safely: ${error.message}`); + logger.error(`Update it manually with: ${getManualInstallHint(method)}`); + return 1; + } + + logger.log(`Checking for updates to ${PACKAGE_NAME}...`); let latestVersion; try { - latestVersion = getLatestVersion(method); + latestVersion = getLatestVersion(method, install.globalRoot, spawn); } catch (error) { - console.error(`error: could not check for updates: ${error.message}`); - console.error(`Update it manually with: ${getManualInstallHint()}`); + logger.error(`error: could not check for updates: ${error.message}`); + logger.error(`Update it manually with: ${getManualInstallHint(method)}`); return 1; } - if (!latestVersion || !isNewerVersion(latestVersion, CURRENT_VERSION)) { - console.log(`${PACKAGE_NAME} is already up to date (v${CURRENT_VERSION})`); + if (!isNewerVersion(latestVersion, currentVersion)) { + logger.log(`${PACKAGE_NAME} is already up to date (v${currentVersion})`); return 0; } const updateCommand = getUpdateCommand(method, latestVersion); const commandDisplay = [updateCommand.command, ...updateCommand.args].join(" "); - console.log(`Updating ${PACKAGE_NAME} from v${CURRENT_VERSION} to v${latestVersion} with ${commandDisplay}...`); + logger.log(`Updating ${PACKAGE_NAME} from v${currentVersion} to v${latestVersion} with ${commandDisplay}...`); try { - runCommand(updateCommand.command, updateCommand.args); + runCommand(updateCommand.command, updateCommand.args, install.globalRoot, spawn); + const installedVersion = readInstalledVersion(install.packageJsonPath, readFile); + if (installedVersion !== latestVersion) { + throw new Error(`installed version is v${installedVersion || "unknown"}, expected v${latestVersion}`); + } } catch (error) { - console.error(`error: update failed: ${error.message}`); - console.error(`If this keeps failing, run the command yourself: ${getManualInstallHint(latestVersion)}`); + logger.error(`error: update failed: ${error.message}`); + logger.error(`If this keeps failing, run the command yourself: ${getManualInstallHint(method, latestVersion)}`); return 1; } - console.log(`${PACKAGE_NAME} updated to v${latestVersion}. Restart pi-web to use the new version.`); + logger.log(`${PACKAGE_NAME} updated to v${latestVersion}. Restart pi-web to use the new version.`); return 0; } @@ -196,9 +313,14 @@ function runUpdate() { module.exports = { detectInstallMethod, + getGlobalInstallRoot, + getGlobalRootCommand, + getManualInstallHint, getUpdateCommand, getVersionCheckCommand, isNewerVersion, parseVersionOutput, + runUpdateInternal, runUpdate, + validateGlobalInstall, }; diff --git a/bin/pi-web-update.test.mjs b/bin/pi-web-update.test.mjs index 3569063ee..ea4ee12c3 100644 --- a/bin/pi-web-update.test.mjs +++ b/bin/pi-web-update.test.mjs @@ -1,11 +1,24 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; import { createRequire } from "node:module"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; const require = createRequire(import.meta.url); -const { detectInstallMethod, getUpdateCommand, getVersionCheckCommand, isNewerVersion, parseVersionOutput } = require("./pi-web-update.js"); +const { + detectInstallMethod, + getGlobalInstallRoot, + getGlobalRootCommand, + getManualInstallHint, + getUpdateCommand, + getVersionCheckCommand, + isNewerVersion, + parseVersionOutput, + runUpdateInternal, + validateGlobalInstall, +} = require("./pi-web-update.js"); // mirrors path.join(__dirname, "..") inside the module const PACKAGE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -14,12 +27,15 @@ test("detects newer stable versions", () => { assert.equal(isNewerVersion("0.8.8", "0.8.7"), true); assert.equal(isNewerVersion("0.9.0", "0.8.7"), true); assert.equal(isNewerVersion("1.0.0", "0.9.9"), true); + assert.equal(isNewerVersion("0.8.8", "0.8.8-beta.1"), true); + assert.equal(isNewerVersion("0.9.0", "0.8.8-rc.2"), true); }); test("does not report equal, older, or unsupported versions as updates", () => { assert.equal(isNewerVersion("0.8.7", "0.8.7"), false); assert.equal(isNewerVersion("0.8.6", "0.8.7"), false); assert.equal(isNewerVersion("0.8.8-beta.1", "0.8.7"), false); + assert.equal(isNewerVersion("0.8.7", "0.8.8-beta.1"), false); assert.equal(isNewerVersion("invalid", "0.8.7"), false); }); @@ -60,18 +76,28 @@ test("builds the update command for each package manager", () => { }); }); +test("builds the global root command for each package manager", () => { + assert.deepEqual(getGlobalRootCommand("npm"), { command: "npm", args: ["root", "-g"] }); + assert.deepEqual(getGlobalRootCommand("pnpm"), { command: "pnpm", args: ["root", "-g"] }); + assert.deepEqual(getGlobalRootCommand("yarn"), { command: "yarn", args: ["global", "dir", "--silent"] }); + assert.deepEqual(getGlobalRootCommand("bun"), { command: "bun", args: ["pm", "ls", "-g"] }); +}); + test("builds the version check command for each package manager", () => { assert.deepEqual(getVersionCheckCommand("npm"), { command: "npm", - args: ["view", "@agegr/pi-web", "version", "--json", "--fetch-timeout=15000"], + args: ["view", "-g", "@agegr/pi-web", "version", "--json", "--fetch-timeout=15000"], + cwd: PACKAGE_DIR, }); assert.deepEqual(getVersionCheckCommand("pnpm"), { command: "pnpm", - args: ["view", "@agegr/pi-web", "version", "--json"], + args: ["view", "-g", "@agegr/pi-web", "version", "--json"], + cwd: PACKAGE_DIR, }); assert.deepEqual(getVersionCheckCommand("yarn"), { command: "yarn", args: ["info", "@agegr/pi-web", "version"], + cwd: PACKAGE_DIR, }); const bun = getVersionCheckCommand("bun"); assert.deepEqual(bun, { @@ -83,11 +109,189 @@ test("builds the version check command for each package manager", () => { test("parses version output from each package manager", () => { assert.equal(parseVersionOutput("npm", '"0.8.8"'), "0.8.8"); + assert.equal(parseVersionOutput("npm", '" 0.8.8 "'), "0.8.8"); assert.equal(parseVersionOutput("npm", '["0.8.8"]'), "0.8.8"); assert.equal(parseVersionOutput("pnpm", '"0.8.8"'), "0.8.8"); assert.equal(parseVersionOutput("yarn", "0.8.8\n"), "0.8.8"); assert.equal(parseVersionOutput("yarn", "0.8.8\nDone in 0.11s.\n"), "0.8.8"); assert.equal(parseVersionOutput("bun", "0.8.8\n"), "0.8.8"); assert.equal(parseVersionOutput("npm", "not json"), ""); + assert.equal(parseVersionOutput("npm", '"0.8.9-beta.1"'), ""); + assert.equal(parseVersionOutput("pnpm", '["0.8.8", "0.8.9-beta.1"]'), "0.8.8"); assert.equal(parseVersionOutput("bun", ""), ""); }); + +test("derives each package manager's global node_modules directory", () => { + const outputs = { + npm: "npm warning text\n/prefix/npm/node_modules\n", + pnpm: "pnpm warning text\n/prefix/pnpm/global/5/node_modules\n", + yarn: "yarn global v1.22.22\n/prefix/yarn/global\nDone in 0.03s.\n", + bun: "/custom Bun/global project node_modules (2)\n├── first-package@1.0.0\n└── second-package@2.0.0\n", + }; + const calls = []; + const spawn = (command, args) => { + calls.push({ command, args }); + return { status: 0, stdout: outputs[command], stderr: "" }; + }; + + assert.equal(getGlobalInstallRoot("npm", PACKAGE_DIR, spawn), path.resolve("/prefix/npm/node_modules")); + assert.equal(getGlobalInstallRoot("pnpm", PACKAGE_DIR, spawn), path.resolve("/prefix/pnpm/global/5/node_modules")); + assert.equal(getGlobalInstallRoot("yarn", PACKAGE_DIR, spawn), path.resolve("/prefix/yarn/global/node_modules")); + assert.equal(getGlobalInstallRoot("bun", PACKAGE_DIR, spawn), path.resolve("/custom Bun/global project/node_modules")); + assert.deepEqual(calls.at(-1), { command: "bun", args: ["pm", "ls", "-g"] }); +}); + +test("manual install hints use the detected package manager", () => { + assert.equal(getManualInstallHint("npm"), "npm install -g @agegr/pi-web@latest"); + assert.equal(getManualInstallHint("pnpm", "0.8.8"), "pnpm add -g @agegr/pi-web@0.8.8"); + assert.equal(getManualInstallHint("yarn", "0.8.8"), "yarn global add @agegr/pi-web@0.8.8"); + assert.equal(getManualInstallHint("bun", "0.8.8"), "bun add -g @agegr/pi-web@0.8.8"); +}); + +function makeTempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-update-test-")); + t.after(() => fs.rmSync(dir, { force: true, recursive: true })); + return dir; +} + +function writeManifest(packageRoot, version) { + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ + name: "@agegr/pi-web", + version, + })); +} + +function makeGlobalInstall(t, version = "0.8.7") { + const tempDir = makeTempDir(t); + const globalRoot = path.join(tempDir, "global", "node_modules"); + const packageRoot = path.join(globalRoot, "@agegr", "pi-web"); + writeManifest(packageRoot, version); + return { globalRoot, packageRoot, packageJsonPath: path.join(packageRoot, "package.json"), tempDir }; +} + +function makeLogger() { + const logs = []; + const errors = []; + return { + errors, + logs, + console: { + error: (...parts) => errors.push(parts.join(" ")), + log: (...parts) => logs.push(parts.join(" ")), + }, + }; +} + +function makeNpmSpawn({ globalRoot, latestOutput = '"0.8.8"', onInstall }) { + const calls = []; + const spawn = (command, args, options) => { + calls.push({ command, args, options }); + if (command !== "npm") throw new Error(`unexpected command: ${command}`); + if (args[0] === "root") return { status: 0, stdout: `${globalRoot}\n`, stderr: "" }; + if (args[0] === "view") return { status: 0, stdout: latestOutput, stderr: "" }; + if (args[0] === "install") { + onInstall?.(); + return { status: 0 }; + } + throw new Error(`unexpected npm arguments: ${args.join(" ")}`); + }; + return { calls, spawn }; +} + +test("accepts a global package symlink when its real path matches the running copy", (t) => { + const tempDir = makeTempDir(t); + const globalRoot = path.join(tempDir, "global", "node_modules"); + const globalPackageRoot = path.join(globalRoot, "@agegr", "pi-web"); + const storePackageRoot = path.join(tempDir, "store", "pi-web"); + writeManifest(storePackageRoot, "0.8.7"); + fs.mkdirSync(path.dirname(globalPackageRoot), { recursive: true }); + fs.symlinkSync(storePackageRoot, globalPackageRoot, process.platform === "win32" ? "junction" : "dir"); + const { spawn } = makeNpmSpawn({ globalRoot }); + + const install = validateGlobalInstall("npm", storePackageRoot, { spawnSync: spawn }); + assert.equal(install.packageRoot, globalPackageRoot); + assert.equal(install.packageJsonPath, path.join(globalPackageRoot, "package.json")); +}); + +test("rejects npx and a package under a different global prefix", (t) => { + const { globalRoot, tempDir } = makeGlobalInstall(t); + const npxPackageRoot = path.join(tempDir, ".npm", "_npx", "abc", "node_modules", "@agegr", "pi-web"); + const otherPrefixPackageRoot = path.join(tempDir, "old-prefix", "node_modules", "@agegr", "pi-web"); + writeManifest(npxPackageRoot, "0.8.7"); + writeManifest(otherPrefixPackageRoot, "0.8.7"); + const { calls, spawn } = makeNpmSpawn({ globalRoot }); + + assert.throws( + () => validateGlobalInstall("npm", npxPackageRoot, { spawnSync: spawn }), + /npx temporary install/, + ); + assert.equal(calls.length, 0); + assert.throws( + () => validateGlobalInstall("npm", otherPrefixPackageRoot, { spawnSync: spawn }), + /not npm's active global install/, + ); + assert.equal(calls.filter((call) => call.args[0] === "install").length, 0); +}); + +test("invalid registry version output fails instead of reporting up to date", (t) => { + const { globalRoot, packageRoot } = makeGlobalInstall(t); + const logger = makeLogger(); + const { calls, spawn } = makeNpmSpawn({ globalRoot, latestOutput: '"not-a-version"' }); + + const status = runUpdateInternal({ + console: logger.console, + currentVersion: "0.8.7", + method: "npm", + packageRoot, + spawnSync: spawn, + }); + + assert.equal(status, 1); + assert.match(logger.errors.join("\n"), /empty or invalid stable version/); + assert.doesNotMatch(logger.logs.join("\n"), /already up to date/); + assert.equal(calls.filter((call) => call.args[0] === "install").length, 0); +}); + +test("update commands share a cwd and verify the original package manifest", (t) => { + const { globalRoot, packageRoot, packageJsonPath } = makeGlobalInstall(t); + const logger = makeLogger(); + const { calls, spawn } = makeNpmSpawn({ + globalRoot, + onInstall: () => writeManifest(packageRoot, "0.8.8"), + }); + + const status = runUpdateInternal({ + console: logger.console, + currentVersion: "0.8.7", + method: "npm", + packageRoot, + spawnSync: spawn, + }); + + assert.equal(status, 0); + const viewCall = calls.find((call) => call.args[0] === "view"); + const installCall = calls.find((call) => call.args[0] === "install"); + assert.equal(viewCall.options.cwd, globalRoot); + assert.equal(installCall.options.cwd, globalRoot); + assert.equal(JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).version, "0.8.8"); + assert.match(logger.logs.at(-1), /updated to v0\.8\.8/); +}); + +test("reports failure when the package manager leaves the original version in place", (t) => { + const { globalRoot, packageRoot } = makeGlobalInstall(t); + const logger = makeLogger(); + const { spawn } = makeNpmSpawn({ globalRoot }); + + const status = runUpdateInternal({ + console: logger.console, + currentVersion: "0.8.7", + method: "npm", + packageRoot, + spawnSync: spawn, + }); + + assert.equal(status, 1); + assert.match(logger.errors.join("\n"), /installed version is v0\.8\.7, expected v0\.8\.8/); + assert.doesNotMatch(logger.logs.join("\n"), /updated to v0\.8\.8/); +}); From 5972e26490678aaad271d98f665b5740214b47c6 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Tue, 11 Aug 2026 21:04:10 +0800 Subject: [PATCH 9/9] fix: resolve active package manager --- bin/pi-web-update.js | 71 ++++++++++++++++++++------ bin/pi-web-update.test.mjs | 102 +++++++++++++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 18 deletions(-) diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js index 6a648b500..8815877c8 100644 --- a/bin/pi-web-update.js +++ b/bin/pi-web-update.js @@ -15,6 +15,7 @@ const path = require("path"); const { name: PACKAGE_NAME, version: CURRENT_VERSION } = require("../package.json"); const VERSION_CHECK_TIMEOUT_MS = 15_000; const PACKAGE_ROOT = path.join(__dirname, ".."); +const INSTALL_METHODS = ["npm", "pnpm", "yarn", "bun"]; const STABLE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/; const CURRENT_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; @@ -48,16 +49,15 @@ function isNewerVersion(candidate, current) { return parsedCurrent.isPrerelease; } -// Detect the package manager that installed this copy of pi-web from the -// installation path. Global installs always live under -// `/node_modules/@agegr/pi-web/bin`, and each package manager uses -// a recognizable directory layout above that. +// Return a preferred package manager when the installation path contains a +// manager-specific hint. Custom roots can imitate these layouts, so callers +// must still verify candidates against the managers' active global roots. function detectInstallMethod(dir = __dirname) { const normalized = dir.toLowerCase().replace(/\\/g, "/"); if (normalized.includes("/pnpm/") || normalized.includes("/.pnpm/")) return "pnpm"; if (normalized.includes("/yarn/") || normalized.includes("/.yarn/")) return "yarn"; if (normalized.includes("/install/global/node_modules/")) return "bun"; - if (normalized.includes("/npm/") || normalized.includes("/node_modules/")) return "npm"; + if (normalized.includes("/_npx/") || normalized.includes("/npm/")) return "npm"; return "unknown"; } @@ -195,11 +195,15 @@ function comparableRealPath(filePath, realpath = fs.realpathSync.native) { return process.platform === "win32" ? normalized.toLowerCase() : normalized; } -function validateGlobalInstall(method, packageRoot = PACKAGE_ROOT, options = {}) { +function assertNotNpxInstall(packageRoot) { const normalizedPackageRoot = packageRoot.replace(/\\/g, "/").toLowerCase(); if (normalizedPackageRoot.includes("/_npx/")) { throw new Error("the running copy is an npx temporary install"); } +} + +function validateGlobalInstall(method, packageRoot = PACKAGE_ROOT, options = {}) { + assertNotNpxInstall(packageRoot); const spawn = options.spawnSync || spawnSync; const realpath = options.realpathSync || fs.realpathSync.native; @@ -225,6 +229,45 @@ function validateGlobalInstall(method, packageRoot = PACKAGE_ROOT, options = {}) }; } +function resolveGlobalInstall(packageRoot = PACKAGE_ROOT, options = {}) { + const validationOptions = { + spawnSync: options.spawnSync, + realpathSync: options.realpathSync, + }; + + if (options.method && options.method !== "unknown") { + return { + method: options.method, + install: validateGlobalInstall(options.method, packageRoot, validationOptions), + }; + } + + assertNotNpxInstall(packageRoot); + const detectedMethod = detectInstallMethod(path.join(packageRoot, "bin")); + const methods = detectedMethod === "unknown" + ? INSTALL_METHODS + : [detectedMethod, ...INSTALL_METHODS.filter((method) => method !== detectedMethod)]; + const matches = []; + for (const method of methods) { + try { + matches.push({ + method, + install: validateGlobalInstall(method, packageRoot, validationOptions), + }); + } catch { + // Missing managers and non-matching global roots are expected while probing. + } + } + + if (matches.length === 0) { + throw new Error("no supported package manager points to the running copy"); + } + if (matches.length > 1) { + throw new Error(`multiple package managers point to the running copy: ${matches.map(({ method }) => method).join(", ")}`); + } + return matches[0]; +} + function getLatestVersion(method, cwd = PACKAGE_ROOT, spawn = spawnSync) { const check = getVersionCheckCommand(method, cwd); const latestVersion = parseVersionOutput(method, runForOutput(check, spawn)); @@ -247,25 +290,22 @@ function readInstalledVersion(packageJsonPath, readFile = fs.readFileSync) { function runUpdateInternal(options = {}) { const packageRoot = options.packageRoot || PACKAGE_ROOT; const currentVersion = options.currentVersion || CURRENT_VERSION; - const method = options.method || detectInstallMethod(path.join(packageRoot, "bin")); + const detectedMethod = options.method || detectInstallMethod(path.join(packageRoot, "bin")); const spawn = options.spawnSync || spawnSync; const readFile = options.readFileSync || fs.readFileSync; const logger = options.console || console; - if (method === "unknown") { - logger.error("error: could not determine how pi-web was installed."); - logger.error(`Update it manually with: ${getManualInstallHint()}`); - return 1; - } + let method; let install; try { - install = validateGlobalInstall(method, packageRoot, { + ({ method, install } = resolveGlobalInstall(packageRoot, { + method: options.method, spawnSync: spawn, realpathSync: options.realpathSync, - }); + })); } catch (error) { logger.error(`error: cannot update this installation safely: ${error.message}`); - logger.error(`Update it manually with: ${getManualInstallHint(method)}`); + logger.error(`Update it manually with: ${getManualInstallHint(detectedMethod === "unknown" ? "npm" : detectedMethod)}`); return 1; } @@ -320,6 +360,7 @@ module.exports = { getVersionCheckCommand, isNewerVersion, parseVersionOutput, + resolveGlobalInstall, runUpdateInternal, runUpdate, validateGlobalInstall, diff --git a/bin/pi-web-update.test.mjs b/bin/pi-web-update.test.mjs index ea4ee12c3..201f803ee 100644 --- a/bin/pi-web-update.test.mjs +++ b/bin/pi-web-update.test.mjs @@ -16,6 +16,7 @@ const { getVersionCheckCommand, isNewerVersion, parseVersionOutput, + resolveGlobalInstall, runUpdateInternal, validateGlobalInstall, } = require("./pi-web-update.js"); @@ -41,7 +42,6 @@ test("does not report equal, older, or unsupported versions as updates", () => { test("detects the package manager from the installation path", () => { assert.equal(detectInstallMethod("C:/Users/x/AppData/Roaming/npm/node_modules/@agegr/pi-web/bin"), "npm"); - assert.equal(detectInstallMethod("/usr/local/lib/node_modules/@agegr/pi-web/bin"), "npm"); assert.equal(detectInstallMethod("C:/Users/x/AppData/Local/npm-cache/_npx/abc123/node_modules/@agegr/pi-web/bin"), "npm"); assert.equal(detectInstallMethod("/home/x/.npm/_npx/abc123/node_modules/@agegr/pi-web/bin"), "npm"); assert.equal(detectInstallMethod("C:/Users/x/AppData/Local/pnpm/global/5/node_modules/.pnpm/@agegr+pi-web@0.8.7/node_modules/@agegr/pi-web/bin"), "pnpm"); @@ -53,6 +53,8 @@ test("detects the package manager from the installation path", () => { }); test("does not guess a package manager for non-global checkouts", () => { + assert.equal(detectInstallMethod("/usr/local/lib/node_modules/@agegr/pi-web/bin"), "unknown"); + assert.equal(detectInstallMethod("/opt/tools/node_modules/@agegr/pi-web/bin"), "unknown"); assert.equal(detectInstallMethod("C:/Users/x/projects/pi-web/bin"), "unknown"); assert.equal(detectInstallMethod("/home/x/pi-web/bin"), "unknown"); }); @@ -162,9 +164,9 @@ function writeManifest(packageRoot, version) { })); } -function makeGlobalInstall(t, version = "0.8.7") { +function makeGlobalInstall(t, version = "0.8.7", globalDirName = "global") { const tempDir = makeTempDir(t); - const globalRoot = path.join(tempDir, "global", "node_modules"); + const globalRoot = path.join(tempDir, globalDirName, "node_modules"); const packageRoot = path.join(globalRoot, "@agegr", "pi-web"); writeManifest(packageRoot, version); return { globalRoot, packageRoot, packageJsonPath: path.join(packageRoot, "package.json"), tempDir }; @@ -199,6 +201,37 @@ function makeNpmSpawn({ globalRoot, latestOutput = '"0.8.8"', onInstall }) { return { calls, spawn }; } +function makeCustomManagerSpawn({ method, globalRoot, onInstall }) { + const calls = []; + const spawn = (command, args, options) => { + calls.push({ command, args, options }); + const isRootQuery = ( + ((command === "npm" || command === "pnpm") && args[0] === "root") + || (command === "yarn" && args[0] === "global" && args[1] === "dir") + || (command === "bun" && args[0] === "pm" && args[1] === "ls") + ); + if (isRootQuery) { + if (command !== method) return { status: 1, stdout: "", stderr: `${command} unavailable` }; + if (method === "yarn") { + return { status: 0, stdout: `${path.dirname(globalRoot)}\n`, stderr: "" }; + } + if (method === "bun") { + return { status: 0, stdout: `${path.dirname(globalRoot)} node_modules (1)\n`, stderr: "" }; + } + } + if (command !== method) throw new Error(`unexpected command: ${command}`); + if ((method === "yarn" && args[0] === "info") || (method === "bun" && args[1] === "view")) { + return { status: 0, stdout: "0.8.8\n", stderr: "" }; + } + if ((method === "yarn" && args[1] === "add") || (method === "bun" && args[0] === "add")) { + onInstall?.(); + return { status: 0 }; + } + throw new Error(`unexpected ${method} arguments: ${args.join(" ")}`); + }; + return { calls, spawn }; +} + test("accepts a global package symlink when its real path matches the running copy", (t) => { const tempDir = makeTempDir(t); const globalRoot = path.join(tempDir, "global", "node_modules"); @@ -214,6 +247,69 @@ test("accepts a global package symlink when its real path matches the running co assert.equal(install.packageJsonPath, path.join(globalPackageRoot, "package.json")); }); +test("resolves custom Yarn and Bun global folders despite missing or misleading path hints", (t) => { + const cases = [ + { method: "yarn", globalDirName: "global", expectedHint: "unknown" }, + { method: "bun", globalDirName: "global", expectedHint: "unknown" }, + { method: "yarn", globalDirName: "npm", expectedHint: "npm" }, + { method: "bun", globalDirName: "yarn", expectedHint: "yarn" }, + ]; + for (const { method, globalDirName, expectedHint } of cases) { + const { globalRoot, packageRoot } = makeGlobalInstall(t, "0.8.7", globalDirName); + const logger = makeLogger(); + const { calls, spawn } = makeCustomManagerSpawn({ + method, + globalRoot, + onInstall: () => writeManifest(packageRoot, "0.8.8"), + }); + + const status = runUpdateInternal({ + console: logger.console, + currentVersion: "0.8.7", + packageRoot, + spawnSync: spawn, + }); + + assert.equal(status, 0); + assert.equal(detectInstallMethod(path.join(packageRoot, "bin")), expectedHint); + assert.equal(calls.some((call) => call.command === method && call.args.includes("add")), true); + assert.match(logger.logs.at(-1), /updated to v0\.8\.8/); + } +}); + +test("rejects ambiguous package manager global roots", (t) => { + const { globalRoot, packageRoot } = makeGlobalInstall(t); + const spawn = (command, args) => { + if (command === "npm" && args[0] === "root") { + return { status: 0, stdout: `${globalRoot}\n`, stderr: "" }; + } + if (command === "yarn" && args[0] === "global" && args[1] === "dir") { + return { status: 0, stdout: `${path.dirname(globalRoot)}\n`, stderr: "" }; + } + return { status: 1, stdout: "", stderr: `${command} unavailable` }; + }; + + assert.throws( + () => resolveGlobalInstall(packageRoot, { spawnSync: spawn }), + /multiple package managers point to the running copy: npm, yarn/, + ); +}); + +test("rejects installs that do not match any package manager global root", (t) => { + const { packageRoot } = makeGlobalInstall(t); + const calls = []; + const spawn = (command, args) => { + calls.push({ command, args }); + return { status: 1, stdout: "", stderr: `${command} unavailable` }; + }; + + assert.throws( + () => resolveGlobalInstall(packageRoot, { spawnSync: spawn }), + /no supported package manager points to the running copy/, + ); + assert.deepEqual(calls.map(({ command }) => command), ["npm", "pnpm", "yarn", "bun"]); +}); + test("rejects npx and a package under a different global prefix", (t) => { const { globalRoot, tempDir } = makeGlobalInstall(t); const npxPackageRoot = path.join(tempDir, ".npm", "_npx", "abc", "node_modules", "@agegr", "pi-web");