diff --git a/README.ja.md b/README.ja.md index 6701a7412..dae3ace36 100644 --- a/README.ja.md +++ b/README.ja.md @@ -26,6 +26,14 @@ npm install -g @agegr/pi-web@latest pi-web ``` +**最新バージョンに更新:** + +```bash +pi-web update +``` + +更新後に pi-web を再起動してください。 + **グローバルインストールをアンインストール:** ```bash diff --git a/README.md b/README.md index b21511370..fca590dd8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ npm install -g @agegr/pi-web@latest pi-web ``` +**Update to the latest version:** + +```bash +pi-web update +``` + +Restart pi-web after updating. + **Uninstall a global installation:** ```bash diff --git a/README.ru.md b/README.ru.md index 1b8652997..5d4eb9c7a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -26,6 +26,14 @@ npm install -g @agegr/pi-web@latest pi-web ``` +**Обновление до последней версии:** + +```bash +pi-web update +``` + +После обновления перезапустите pi-web. + **Удаление глобальной установки:** ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index f54c24c68..fe701e998 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,6 +24,14 @@ npm install -g @agegr/pi-web@latest pi-web ``` +**更新到最新版本:** + +```bash +pi-web update +``` + +更新完成后请重启 pi-web。 + **卸载全局安装:** ```bash diff --git a/bin/pi-web-options.js b/bin/pi-web-options.js index b27eb9773..ea10da0b3 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 } = parseArgs({ args, - options: { - port: { type: "string", short: "p" }, - hostname: { type: "string", short: "H" }, - "no-open": { type: "boolean" }, - }, + options: LAUNCH_OPTIONS, strict: false, }); @@ -27,4 +29,23 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { }; } -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)) { + try { + return parseArgs({ + args, + options: LAUNCH_OPTIONS, + strict: true, + allowPositionals: true, + }).positionals; + } catch { + return []; + } +} + +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 new file mode 100644 index 000000000..48a0fd6dd --- /dev/null +++ b/bin/pi-web-options.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const { + getCommandPositionals, + parseLaunchOptions, + shouldRunUpdate, +} = require("./pi-web-options.js"); + +test("reports the first positional argument", () => { + 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(getCommandPositionals(["--hostname", "update"]), []); + 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"); + 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 new file mode 100644 index 000000000..8815877c8 --- /dev/null +++ b/bin/pi-web-update.js @@ -0,0 +1,367 @@ +"use strict"; + +// 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"); +// 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 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-]+)*)?$/; + +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 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 parsedCurrent = parseCurrentVersion(current); + if (!candidateParts || !parsedCurrent) return false; + + for (let index = 0; index < candidateParts.length; index += 1) { + if (candidateParts[index] !== parsedCurrent.parts[index]) { + return candidateParts[index] > parsedCurrent.parts[index]; + } + } + return parsedCurrent.isPrerelease; +} + +// 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("/_npx/") || normalized.includes("/npm/")) return "npm"; + return "unknown"; +} + +function getUpdateCommand(method, version) { + const spec = `${PACKAGE_NAME}@${version}`; + switch (method) { + case "pnpm": + return { command: "pnpm", args: ["add", "-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 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, cwd = PACKAGE_ROOT) { + switch (method) { + case "pnpm": + return { command: "pnpm", args: ["view", "-g", PACKAGE_NAME, "version", "--json"], cwd }; + case "yarn": + return { command: "yarn", args: ["info", PACKAGE_NAME, "version"], cwd }; + case "bun": + return { command: "bun", args: ["pm", "view", PACKAGE_NAME, "version"], cwd }; + default: + return { + command: "npm", + args: ["view", "-g", PACKAGE_NAME, "version", "--json", `--fetch-timeout=${VERSION_CHECK_TIMEOUT_MS}`], + cwd, + }; + } +} + +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 = spawn(command, args, { + stdio: "inherit", + shell: process.platform === "win32", + cwd, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with code ${result.status ?? "unknown"}`); + } +} + +// npm and pnpm print JSON (a quoted string, or an array on newer npm); yarn +// and bun print the bare version. +function parseVersionOutput(method, stdout) { + if (method === "npm" || method === "pnpm") { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return ""; + } + if (typeof parsed === "string") { + const version = parsed.trim(); + return parseStableVersion(version) ? version : ""; + } + if (Array.isArray(parsed)) { + 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 ""; + } + // 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); + for (const line of lines) { + if (STABLE_VERSION_PATTERN.test(line)) return line; + } + return ""; +} + +function runForOutput(spec, spawn = spawnSync) { + const result = spawn(spec.command, spec.args, { + encoding: "utf8", + shell: process.platform === "win32", + timeout: VERSION_CHECK_TIMEOUT_MS, + cwd: spec.cwd, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = (result.stderr || "").trim() || `${spec.command} exited with code ${result.status ?? "unknown"}`; + throw new Error(detail); + } + 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 comparableRealPath(filePath, realpath = fs.realpathSync.native) { + const normalized = path.normalize(realpath(path.resolve(filePath))); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +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; + 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 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)); + 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 detectedMethod = options.method || detectInstallMethod(path.join(packageRoot, "bin")); + const spawn = options.spawnSync || spawnSync; + const readFile = options.readFileSync || fs.readFileSync; + const logger = options.console || console; + + let method; + let install; + try { + ({ 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(detectedMethod === "unknown" ? "npm" : detectedMethod)}`); + return 1; + } + + logger.log(`Checking for updates to ${PACKAGE_NAME}...`); + let latestVersion; + try { + latestVersion = getLatestVersion(method, install.globalRoot, spawn); + } catch (error) { + logger.error(`error: could not check for updates: ${error.message}`); + logger.error(`Update it manually with: ${getManualInstallHint(method)}`); + return 1; + } + 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(" "); + logger.log(`Updating ${PACKAGE_NAME} from v${currentVersion} to v${latestVersion} with ${commandDisplay}...`); + try { + 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) { + logger.error(`error: update failed: ${error.message}`); + logger.error(`If this keeps failing, run the command yourself: ${getManualInstallHint(method, latestVersion)}`); + return 1; + } + logger.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, + getGlobalInstallRoot, + getGlobalRootCommand, + getManualInstallHint, + getUpdateCommand, + getVersionCheckCommand, + isNewerVersion, + parseVersionOutput, + resolveGlobalInstall, + runUpdateInternal, + runUpdate, + validateGlobalInstall, +}; diff --git a/bin/pi-web-update.test.mjs b/bin/pi-web-update.test.mjs new file mode 100644 index 000000000..201f803ee --- /dev/null +++ b/bin/pi-web-update.test.mjs @@ -0,0 +1,393 @@ +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, + getGlobalInstallRoot, + getGlobalRootCommand, + getManualInstallHint, + getUpdateCommand, + getVersionCheckCommand, + isNewerVersion, + parseVersionOutput, + resolveGlobalInstall, + 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)), ".."); + +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); +}); + +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("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("/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"); +}); + +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: ["add", "-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"], + }); +}); + +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", "-g", "@agegr/pi-web", "version", "--json", "--fetch-timeout=15000"], + cwd: PACKAGE_DIR, + }); + assert.deepEqual(getVersionCheckCommand("pnpm"), { + command: "pnpm", + 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, { + 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("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", globalDirName = "global") { + const tempDir = makeTempDir(t); + 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 }; +} + +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 }; +} + +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"); + 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("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"); + 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/); +}); diff --git a/bin/pi-web.js b/bin/pi-web.js index ae7e571c0..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 { parseLaunchOptions } = require("./pi-web-options"); +const { parseLaunchOptions, shouldRunUpdate } = require("./pi-web-options"); const pkgDir = path.join(__dirname, ".."); const nextDir = path.join(pkgDir, ".next"); @@ -37,6 +37,11 @@ try { } const { port, hostname, openBrowser } = parseLaunchOptions(); +if (shouldRunUpdate(process.argv.slice(2))) { + // 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); diff --git a/package.json b/package.json index 3b6343426..b86cc57d0 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": {