diff --git a/README.ja.md b/README.ja.md index 5179d931c4..dcbcb93451 100644 --- a/README.ja.md +++ b/README.ja.md @@ -36,7 +36,17 @@ npm install -g @agegr/pi-web@latest pi-web ``` -更新時は、実行中のプロセスを `Ctrl+C` で停止してから同じインストールコマンドを再実行します。アンインストールするには `npm uninstall -g @agegr/pi-web` を実行します。 +コマンド: + +```bash +pi-web version # インストール済みのバージョンを表示 +pi-web status # 実行中のサーバーを一覧表示 +pi-web stop [--port N] # 実行中のサーバーを停止 +pi-web open [--port N] # 実行中のサーバーをブラウザーで開く +pi-web update [--check] # グローバル npm インストールを更新 +``` + +更新時は `pi-web stop` を実行してから `pi-web update` を実行します(同じインストールコマンドの再実行でも構いません)。アンインストールするには `npm uninstall -g @agegr/pi-web` を実行します。 ## 設定 diff --git a/README.md b/README.md index fe66d3a497..7570943bce 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,17 @@ npm install -g @agegr/pi-web@latest pi-web ``` -To update, stop the running process with `Ctrl+C` and run the same install command again. To uninstall, run `npm uninstall -g @agegr/pi-web`. +Commands: + +```bash +pi-web version # print the installed version +pi-web status # list running servers +pi-web stop [--port N] # stop a running server +pi-web open [--port N] # open a running server in the browser +pi-web update [--check] # update a global npm install +``` + +To update, run `pi-web stop`, then `pi-web update` (or run the same install command again). To uninstall, run `npm uninstall -g @agegr/pi-web`. ## Configuration diff --git a/README.ru.md b/README.ru.md index cfbf5d42a5..354255efec 100644 --- a/README.ru.md +++ b/README.ru.md @@ -36,7 +36,17 @@ npm install -g @agegr/pi-web@latest pi-web ``` -Для обновления остановите запущенный процесс с помощью `Ctrl+C` и повторите ту же команду установки. Для удаления выполните `npm uninstall -g @agegr/pi-web`. +Команды: + +```bash +pi-web version # вывести установленную версию +pi-web status # список запущенных серверов +pi-web stop [--port N] # остановить запущенный сервер +pi-web open [--port N] # открыть запущенный сервер в браузере +pi-web update [--check] # обновить глобальную установку npm +``` + +Для обновления выполните `pi-web stop`, затем `pi-web update` (или повторите ту же команду установки). Для удаления выполните `npm uninstall -g @agegr/pi-web`. ## Настройка diff --git a/README.zh-CN.md b/README.zh-CN.md index 4bb93eb35a..0d263a8203 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,17 @@ npm install -g @agegr/pi-web@latest pi-web ``` -更新前先用 `Ctrl+C` 停止正在运行的进程,再次执行同一条安装命令。卸载时运行 `npm uninstall -g @agegr/pi-web`。 +命令: + +```bash +pi-web version # 打印已安装的版本 +pi-web status # 列出正在运行的服务 +pi-web stop [--port N] # 停止正在运行的服务 +pi-web open [--port N] # 在浏览器中打开正在运行的服务 +pi-web update [--check] # 更新全局 npm 安装 +``` + +更新时先运行 `pi-web stop`,再运行 `pi-web update`(也可以再次执行同一条安装命令)。卸载时运行 `npm uninstall -g @agegr/pi-web`。 ## 配置 diff --git a/bin/browser-opener.js b/bin/browser-opener.js new file mode 100644 index 0000000000..7e5c0a7934 --- /dev/null +++ b/bin/browser-opener.js @@ -0,0 +1,34 @@ +"use strict"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { spawn } = require("child_process"); + +function openInBrowser(url, platform = process.platform) { + // Avoid `shell: true` to suppress Node.js DEP0190 deprecation + // ("Passing args to a child process with shell option true can lead to + // security vulnerabilities, as the arguments are not escaped"). + // Pass a structured argv so Node.js handles escaping instead of + // concatenating the args into a shell command string. + let opener; + if (platform === "win32") { + // `start` is a cmd.exe built-in, so invoke cmd directly. The empty + // title argument is required by `start` before the target URL. + opener = spawn(process.env.ComSpec || "cmd.exe", ["/c", "start", "", url], { + stdio: "ignore", + detached: true, + }); + } else { + opener = spawn(platform === "darwin" ? "open" : "xdg-open", [url], { + stdio: "ignore", + detached: true, + }); + } + + opener.on("error", (error) => { + console.warn(`Could not open browser automatically: ${error.message}`); + }); + + opener.unref(); +} + +module.exports = { openInBrowser }; diff --git a/bin/pi-web-commands.js b/bin/pi-web-commands.js new file mode 100644 index 0000000000..19ec510214 --- /dev/null +++ b/bin/pi-web-commands.js @@ -0,0 +1,118 @@ +"use strict"; + +// 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 { openInBrowser } = require("./browser-opener"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { runUpdate } = require("./pi-web-update"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const records = require("./run-records"); + +const STOP_TIMEOUT_MS = 10_000; +const STOP_POLL_MS = 200; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function getPackageVersion(pkgDir = path.join(__dirname, "..")) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(path.join(pkgDir, "package.json")).version; +} + +function killWindowsProcessTree(pid) { + const result = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error((result.stderr || result.stdout || `taskkill exited ${result.status}`).trim()); +} + +async function stopServer(record, { + kill = process.kill, + killTree = killWindowsProcessTree, + platform = process.platform, + isAlive = records.isProcessAlive, + wait = sleep, + timeoutMs = STOP_TIMEOUT_MS, + log = console.log, + error = console.error, +} = {}) { + // The launcher, or the Next.js server a killed launcher left serving. + const pid = record.stopPid ?? record.pid; + try { + // The launcher forwards SIGTERM to Next.js and exits after it. Windows has + // no SIGTERM: process.kill() would end the launcher alone and leave Next.js + // serving the port, so end the whole process tree there. + if (platform === "win32") killTree(pid); + else kill(pid, "SIGTERM"); + } catch (killError) { + if (killError.code !== "ESRCH") { + error(`Could not stop pi-web (pid ${pid}): ${killError.message}`); + return 1; + } + } + for (let waited = 0; isAlive(pid); waited += STOP_POLL_MS) { + if (waited >= timeoutMs) { + error(`pi-web on ${record.url} (pid ${pid}) is still running after ${timeoutMs / 1000} s.`); + return 1; + } + await wait(STOP_POLL_MS); + } + log(`Stopped pi-web on ${record.url} (pid ${pid}).`); + return 0; +} + +async function runCommand(options, { + runDir = records.getRunDir(), + pkgDir = path.join(__dirname, ".."), + open = openInBrowser, + isAnswering = records.isServerAnswering, + isOurs = records.isRecordProcess, + log = console.log, + error = console.error, +} = {}) { + const list = () => records.listRunRecords(runDir, { isAnswering, isOurs }); + switch (options.command) { + case "version": + log(getPackageVersion(pkgDir)); + return 0; + case "status": { + const running = await list(); + log(running.length === 0 + ? "No pi-web server is running." + : `Running pi-web servers:\n${running.map(records.formatRunRecord).join("\n")}`); + return 0; + } + case "stop": + case "open": { + const selected = records.selectRunRecord(await list(), options.port); + if (!selected.record) { + error(selected.error); + return 1; + } + if (options.command === "stop") { + const code = await stopServer(selected.record, { log, error }); + if (code === 0) records.removeRunRecord(runDir, selected.record.pid); + return code; + } + log(`Opening ${selected.record.url}`); + open(selected.record.url); + return 0; + } + case "update": + return runUpdate({ + check: options.check, + currentVersion: getPackageVersion(pkgDir), + pkgDir, + listRecords: list, + formatRecord: records.formatRunRecord, + log, + error, + }); + default: + error(`Unknown command: ${options.command}`); + return 1; + } +} + +module.exports = { getPackageVersion, runCommand, stopServer }; diff --git a/bin/pi-web-options.js b/bin/pi-web-options.js index 1a5918de96..1ecb234c53 100644 --- a/bin/pi-web-options.js +++ b/bin/pi-web-options.js @@ -29,10 +29,27 @@ function normalizePort(value) { return String(port); } +// Subcommands and their own options. Matched only as the first argument, so a +// bare `pi-web [options]` still starts the server. +const COMMAND_OPTIONS = { + version: {}, + status: {}, + stop: { port: { type: "string", short: "p" } }, + open: { port: { type: "string", short: "p" } }, + update: { check: { type: "boolean" } }, +}; + function getHelpText() { - return `Usage: pi-web [options] + return `Usage: pi-web [command] [options] + +Start the Pi Web UI server, or run one of the commands below. -Start the Pi Web UI server. +Commands: + version Print the installed Pi Web version (also -v, --version) + status List running Pi Web servers + stop [--port ] Stop a running server + open [--port ] Open a running server in the browser + update [--check] Update a global npm install to the latest version Options: -p, --port Server port (default: 30141, or PORT) @@ -53,6 +70,42 @@ Environment: `; } +function toParseError(error) { + const message = error instanceof Error ? error.message : String(error); + const err = new Error(`${message}\nUse --help to see available options.`); + err.code = "ERR_PARSE_ARGS_UNKNOWN_OPTION"; + return err; +} + +// Returns { command: "start", ...launch options } for a bare launch, or +// { command, help, port?, check? } for a subcommand. +function parseCommandLine(args = process.argv.slice(2), env = process.env) { + const first = args[0]; + const command = first === "-v" || first === "--version" ? "version" : first; + if (!Object.hasOwn(COMMAND_OPTIONS, command)) { + return { command: "start", ...parseLaunchOptions(args, env) }; + } + + let values; + try { + ({ values } = parseArgs({ + args: args.slice(1), + options: { ...COMMAND_OPTIONS[command], help: { type: "boolean", short: "h" } }, + strict: true, + allowPositionals: false, + })); + } catch (error) { + throw toParseError(error); + } + if (values.help) return { command, help: true }; + return { + command, + help: false, + port: values.port === undefined ? undefined : normalizePort(values.port), + check: values.check === true, + }; +} + function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { let values; let positionals; @@ -64,10 +117,7 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { allowPositionals: true, })); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const err = new Error(`${message}\nUse --help to see available options.`); - err.code = "ERR_PARSE_ARGS_UNKNOWN_OPTION"; - throw err; + throw toParseError(error); } if (values.help) { @@ -88,4 +138,4 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) { }; } -module.exports = { parseLaunchOptions, getHelpText }; +module.exports = { parseCommandLine, parseLaunchOptions, getHelpText }; diff --git a/bin/pi-web-update.js b/bin/pi-web-update.js new file mode 100644 index 0000000000..2f19410dd3 --- /dev/null +++ b/bin/pi-web-update.js @@ -0,0 +1,148 @@ +"use strict"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { execFileSync, 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"); + +const NPM_LATEST_URL = "https://registry.npmjs.org/@agegr%2Fpi-web/latest"; +const FETCH_TIMEOUT_MS = 10_000; +const MANUAL_COMMAND = "npm install -g @agegr/pi-web@latest"; + +// Port of lib/app-update.ts (bin cannot import TypeScript). +function parseStableVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (!match) return null; + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function isNewerStableVersion(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; +} + +async function fetchLatestVersion() { + const response = await fetch(NPM_LATEST_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`); + const { version } = await response.json(); + if (typeof version !== "string" || !parseStableVersion(version)) { + throw new Error("npm registry returned an invalid version"); + } + return version; +} + +// npm's own CLI script run through this node, so no shell is needed on +// Windows (same lookup as lib/node-cli.ts). null: no way to run npm safely. +function findNpmInvocation(args, nodeDir = path.dirname(process.execPath), platform = process.platform, exists = fs.existsSync) { + for (const script of [ + path.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js"), + path.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), + ]) { + if (exists(script)) return { command: process.execPath, args: [script, ...args] }; + } + return platform === "win32" ? null : { command: "npm", args }; +} + +function realpathOrSelf(target, realpath = fs.realpathSync) { + try { + return realpath(target); + } catch { + return path.resolve(target); + } +} + +// "global": pkgDir is /@agegr/pi-web; "npx": an npx cache; else "other". +function getInstallKind({ pkgDir, npmRoot, realpath = fs.realpathSync }) { + const realPkgDir = realpathOrSelf(pkgDir, realpath); + if (realPkgDir.split(/[\\/]/).includes("_npx")) return "npx"; + if (!npmRoot) return "other"; + const inGlobalRoot = + path.basename(realPkgDir) === "pi-web" && + path.basename(path.dirname(realPkgDir)) === "@agegr" && + realpathOrSelf(path.dirname(path.dirname(realPkgDir)), realpath) === realpathOrSelf(npmRoot, realpath); + return inGlobalRoot ? "global" : "other"; +} + +function getGlobalNpmRoot() { + const invocation = findNpmInvocation(["root", "-g"]); + if (!invocation) return null; + try { + return execFileSync(invocation.command, invocation.args, { encoding: "utf8", timeout: 30_000 }).trim(); + } catch { + return null; + } +} + +function runNpmInstall(version) { + const invocation = findNpmInvocation(["install", "-g", `@agegr/pi-web@${version}`]); + if (!invocation) return null; + const result = spawnSync(invocation.command, invocation.args, { stdio: "inherit" }); + return result.status ?? 1; +} + +async function runUpdate({ + check, + currentVersion, + pkgDir, + listRecords, + formatRecord, + fetchLatest = fetchLatestVersion, + getNpmRoot = getGlobalNpmRoot, + install = runNpmInstall, + realpath = fs.realpathSync, + log = console.log, + error = console.error, +}) { + let latest; + try { + latest = await fetchLatest(); + } catch (fetchError) { + error(`Could not check for updates: ${fetchError.message}`); + return 1; + } + if (!isNewerStableVersion(latest, currentVersion)) { + log(`pi-web ${currentVersion} is up to date.`); + return 0; + } + log(`pi-web ${latest} is available (installed: ${currentVersion}).`); + if (check) return 0; + + // A running `next start` has .next/ open; never replace it underneath. + const running = await listRecords(); + if (running.length > 0) { + error(`pi-web is running:\n${running.map(formatRecord).join("\n")}\nRun \`pi-web stop\` first, then update.`); + return 1; + } + + const kind = getInstallKind({ pkgDir, npmRoot: getNpmRoot(), realpath }); + if (kind === "npx") { + error("This pi-web runs from the npx cache. Run `npx @agegr/pi-web@latest` instead."); + return 1; + } + if (kind !== "global") { + error(`This pi-web is not a global npm install. Update it yourself, for example: ${MANUAL_COMMAND}`); + return 1; + } + + const code = install(latest); + if (code === null) { + error(`Could not find npm. Run: ${MANUAL_COMMAND}`); + return 1; + } + return code; +} + +module.exports = { findNpmInvocation, getInstallKind, isNewerStableVersion, runUpdate }; diff --git a/bin/pi-web.js b/bin/pi-web.js index 0ddef84da5..4d3e325e22 100755 --- a/bin/pi-web.js +++ b/bin/pi-web.js @@ -16,17 +16,23 @@ 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 { getHelpText, parseLaunchOptions } = require("./pi-web-options"); +const { getHelpText, parseCommandLine } = require("./pi-web-options"); // eslint-disable-next-line @typescript-eslint/no-require-imports const { getNextNodeArgs } = require("./pi-web-node-args"); // eslint-disable-next-line @typescript-eslint/no-require-imports const { wireChildProcessLifecycle } = require("./process-lifecycle"); // eslint-disable-next-line @typescript-eslint/no-require-imports const { rotatePreviewSecrets, getRotationWarning } = require("./rotate-preview-secrets"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { openInBrowser } = require("./browser-opener"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { createRunRecordTracker, getOpenUrl, getRunDir } = require("./run-records"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { getPackageVersion, runCommand } = require("./pi-web-commands"); let launchOptions; try { - launchOptions = parseLaunchOptions(); + launchOptions = parseCommandLine(); } catch (error) { fs.writeSync( process.stderr.fd, @@ -40,105 +46,102 @@ if (launchOptions.help) { process.exit(0); } -const { port, hostname, openBrowser } = launchOptions; +if (launchOptions.command === "start") { + startServer(launchOptions); +} else { + runCommand(launchOptions).then( + (code) => process.exit(code), + (error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }, + ); +} + +function startServer({ port, hostname, openBrowser }) { -const pkgDir = path.join(__dirname, ".."); -const nextDir = path.join(pkgDir, ".next"); + const pkgDir = path.join(__dirname, ".."); + const nextDir = path.join(pkgDir, ".next"); -// Resolve next's CLI entry directly to avoid relying on .bin symlinks (which -// may not exist when installed via npx). -let nextBin; -try { - nextBin = require.resolve("next/dist/bin/next", { paths: [pkgDir] }); -} catch { - // Fallback: locate next package root and derive the bin path manually. + // Resolve next's CLI entry directly to avoid relying on .bin symlinks (which + // may not exist when installed via npx). + let nextBin; try { - const nextPkg = require.resolve("next/package.json", { paths: [pkgDir] }); - nextBin = path.join(path.dirname(nextPkg), "dist", "bin", "next"); + nextBin = require.resolve("next/dist/bin/next", { paths: [pkgDir] }); } catch { - nextBin = path.join(pkgDir, "node_modules", "next", "dist", "bin", "next"); + // Fallback: locate next package root and derive the bin path manually. + try { + const nextPkg = require.resolve("next/package.json", { paths: [pkgDir] }); + nextBin = path.join(path.dirname(nextPkg), "dist", "bin", "next"); + } catch { + nextBin = path.join(pkgDir, "node_modules", "next", "dist", "bin", "next"); + } } -} - -const loopbackHostnames = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); -const passwordEnabled = Boolean(process.env.PI_WEB_PASSWORD); -if (!fs.existsSync(nextDir)) { - console.error("Build artifacts not found. Please report this issue."); - process.exit(1); -} + const loopbackHostnames = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); + const passwordEnabled = Boolean(process.env.PI_WEB_PASSWORD); -// Replace the published preview-mode secrets with fresh random values so the -// previewModeId baked into the npm tarball cannot be used to skip the proxy -// (via the x-prerender-revalidate header). Must happen before `next start`. -const rotation = rotatePreviewSecrets(nextDir); -if (!rotation.ok) { - console.warn(getRotationWarning(rotation.reason)); -} + if (!fs.existsSync(nextDir)) { + console.error("Build artifacts not found. Please report this issue."); + process.exit(1); + } -if (!loopbackHostnames.has(hostname)) { - if (passwordEnabled) { - console.warn( - `Warning: pi-web is listening on ${hostname} with password authentication over HTTP. Use HTTPS or a trusted VPN to protect the password in transit.`, - ); - } else { - console.warn( - `Warning: pi-web is listening on ${hostname} without authentication. Only use this on a trusted network.`, - ); + // Replace the published preview-mode secrets with fresh random values so the + // previewModeId baked into the npm tarball cannot be used to skip the proxy + // (via the x-prerender-revalidate header). Must happen before `next start`. + const rotation = rotatePreviewSecrets(nextDir); + if (!rotation.ok) { + console.warn(getRotationWarning(rotation.reason)); } -} -const nextArgs = ["start", "-p", port]; -nextArgs.push("-H", hostname); - -// Always run next's JS entry with node directly — avoids .bin symlink issues -// and path-with-spaces problems on Windows when shell: true is used. -const child = spawn(process.execPath, getNextNodeArgs(nextBin, nextArgs), { - cwd: pkgDir, - stdio: ["inherit", "pipe", "inherit"], - env: { ...process.env, PI_WEB_HOSTNAME: hostname }, -}); -wireChildProcessLifecycle(child); - -let browserOpened = false; -const url = `http://${hostname}:${port}`; - -child.stdout.on("data", (chunk) => { - const text = chunk.toString(); - process.stdout.write(text); - if (openBrowser && !browserOpened && text.includes("Ready")) { - browserOpened = true; - const isWindows = process.platform === "win32"; - const isMac = process.platform === "darwin"; - // Avoid `shell: true` to suppress Node.js DEP0190 deprecation - // ("Passing args to a child process with shell option true can lead to - // security vulnerabilities, as the arguments are not escaped"). - // Pass a structured argv so Node.js handles escaping instead of - // concatenating the args into a shell command string. - let opener; - if (isWindows) { - // `start` is a cmd.exe built-in, so invoke cmd directly. The empty - // title argument is required by `start` before the target URL. - opener = spawn(process.env.ComSpec || "cmd.exe", ["/c", "start", "", url], { - stdio: "ignore", - detached: true, - }); - } else if (isMac) { - opener = spawn("open", [url], { - stdio: "ignore", - detached: true, - }); + if (!loopbackHostnames.has(hostname)) { + if (passwordEnabled) { + console.warn( + `Warning: pi-web is listening on ${hostname} with password authentication over HTTP. Use HTTPS or a trusted VPN to protect the password in transit.`, + ); } else { - opener = spawn("xdg-open", [url], { - stdio: "ignore", - detached: true, - }); + console.warn( + `Warning: pi-web is listening on ${hostname} without authentication. Only use this on a trusted network.`, + ); } - - opener.on("error", (error) => { - console.warn(`Could not open browser automatically: ${error.message}`); - }); - - opener.unref(); } -}); + + const nextArgs = ["start", "-p", port]; + nextArgs.push("-H", hostname); + + // Always run next's JS entry with node directly — avoids .bin symlink issues + // and path-with-spaces problems on Windows when shell: true is used. + const child = spawn(process.execPath, getNextNodeArgs(nextBin, nextArgs), { + cwd: pkgDir, + stdio: ["inherit", "pipe", "inherit"], + env: { ...process.env, PI_WEB_HOSTNAME: hostname }, + }); + wireChildProcessLifecycle(child); + + // Port 0 lets the OS pick a port we never learn, so such a server gets no record. + const runRecord = createRunRecordTracker({ + dir: getRunDir, + record: { + pid: process.pid, + nextPid: child.pid, + port: Number(port), + hostname, + url: getOpenUrl(hostname, port), + version: getPackageVersion(pkgDir), + startedAt: new Date().toISOString(), + }, + }); + + let serverReady = false; + const url = `http://${hostname}:${port}`; + + child.stdout.on("data", (chunk) => { + const text = chunk.toString(); + process.stdout.write(text); + if (!serverReady && text.includes("Ready")) { + serverReady = true; + if (port !== "0") runRecord.markReady(); + if (openBrowser) openInBrowser(url); + } + }); +} diff --git a/bin/run-records.js b/bin/run-records.js new file mode 100644 index 0000000000..b9b06aaad7 --- /dev/null +++ b/bin/run-records.js @@ -0,0 +1,208 @@ +"use strict"; + +// Records of running pi-web servers: one /pi-web-run/.json +// per server, written by the launcher once Next.js is ready and removed when it +// exits. pi-web keeps all its state in pi's agent directory, so this does too. + +// 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 os = require("os"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const path = require("path"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { fileURLToPath } = require("url"); + +// pi's getAgentDir(): PI_CODING_AGENT_DIR with `~` expanded, else ~/.pi/agent. +function getAgentDir(env = process.env, home = os.homedir()) { + const configured = env.PI_CODING_AGENT_DIR; + if (!configured) return path.join(home, ".pi", "agent"); + if (configured === "~") return home; + if (configured.startsWith("~/") || configured.startsWith("~\\")) return path.join(home, configured.slice(2)); + if (configured.startsWith("file://")) return fileURLToPath(configured); + return configured; +} + +function getRunDir(env = process.env, home = os.homedir()) { + return path.join(getAgentDir(env, home), "pi-web-run"); +} + +// The address a browser on this machine can open: wildcard binds become +// loopback, and IPv6 literals get brackets. +function getOpenUrl(hostname, port) { + let host = hostname; + if (host === "0.0.0.0") host = "127.0.0.1"; + else if (host === "::" || host === "[::]") host = "::1"; + if (host.includes(":") && !host.startsWith("[")) host = `[${host}]`; + return `http://${host}:${port}`; +} + +function isProcessAlive(pid, kill = process.kill) { + try { + kill(pid, 0); + return true; + } catch (error) { + // EPERM: the process exists but belongs to someone else. + return error.code === "EPERM"; + } +} + +// What pid is running now: its command line (Windows: its image name), "" when +// no such process exists, null when there is no way to tell. +function readProcessCommand(pid, { platform = process.platform, run = spawnSync, readFile = fs.readFileSync } = {}) { + if (platform === "linux") { + try { + return readFile(`/proc/${pid}/cmdline`, "utf8").split("\0").join(" ").trim(); + } catch (error) { + return error.code === "ENOENT" ? "" : null; + } + } + const result = platform === "win32" + ? run("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8", windowsHide: true, timeout: 5_000 }) + : run("ps", ["-ww", "-p", String(pid), "-o", "args="], { encoding: "utf8", timeout: 5_000 }); + if (result.error) return null; + const output = (result.stdout || "").trim(); + if (platform !== "win32") return output; + // `"node.exe","1234",...`, or an INFO line when nothing matches. + const match = /^"([^"]*)","(\d+)"/.exec(output); + return match && match[2] === String(pid) ? match[1] : ""; +} + +// Whether pid still is the launcher or the Next.js server of a record. After a +// crash or a reboot the pid may belong to an unrelated process, which stop must +// never signal. Next.js renames its process to "next-server (vX)". Windows only +// gives the image name. When nothing can tell, the URL check alone decides. +function isRecordProcess(pid, role, { platform = process.platform, readCommand = readProcessCommand } = {}) { + const command = readCommand(pid, { platform }); + if (command === null) return true; + if (platform === "win32") return /^node/i.test(command); + return role === "launcher" ? command.includes("pi-web") : command.includes("next"); +} + +function recordPath(dir, pid) { + return path.join(dir, `${pid}.json`); +} + +function writeRunRecord(dir, record) { + fs.mkdirSync(dir, { recursive: true }); + const file = recordPath(dir, record.pid); + const temp = `${file}.tmp`; + fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`); + fs.renameSync(temp, file); +} + +function removeRunRecord(dir, pid) { + fs.rmSync(recordPath(dir, pid), { force: true }); +} + +// Any HTTP answer counts (a password-protected server answers 401). +async function isServerAnswering(url, fetchImpl = fetch) { + try { + await fetchImpl(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) }); + return true; + } catch { + return false; + } +} + +function isValidPid(pid) { + return Number.isSafeInteger(pid) && pid > 0; +} + +// Running servers sorted by port, each with `stopPid`: the launcher, or the +// Next.js server when the launcher was killed and left it serving (`orphaned`). +// A record none of whose processes is still ours, or that cannot be read, is +// deleted. One whose process is ours but whose URL does not answer is skipped, +// not deleted. +async function listRunRecords(dir, { + isAlive = isProcessAlive, + isOurs = isRecordProcess, + isAnswering = isServerAnswering, +} = {}) { + let names; + try { + names = fs.readdirSync(dir); + } catch { + return []; + } + const candidates = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + const file = path.join(dir, name); + let record; + try { + record = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + record = null; + } + const launcherRuns = record && isValidPid(record.pid) && isAlive(record.pid) && isOurs(record.pid, "launcher"); + const nextRuns = !launcherRuns && record && isValidPid(record.nextPid) && isAlive(record.nextPid) && isOurs(record.nextPid, "next"); + if (launcherRuns || nextRuns) { + candidates.push({ ...record, stopPid: launcherRuns ? record.pid : record.nextPid, orphaned: !launcherRuns }); + } else { + fs.rmSync(file, { force: true }); + } + } + const answering = await Promise.all(candidates.map((record) => isAnswering(record.url))); + return candidates.filter((_, index) => answering[index]).sort((a, b) => a.port - b.port); +} + +// The --port rule shared by stop and open. +function selectRunRecord(records, port) { + const matching = port === undefined ? records : records.filter((record) => String(record.port) === String(port)); + if (matching.length === 1) return { record: matching[0] }; + if (matching.length === 0) { + return { error: port === undefined ? "No pi-web server is running." : `No pi-web server is running on port ${port}.` }; + } + if (port !== undefined) { + return { error: `Several pi-web servers are running on port ${port}:\n${matching.map(formatRunRecord).join("\n")}` }; + } + return { + error: `Several pi-web servers are running:\n${matching.map(formatRunRecord).join("\n")}\nPass --port to choose one.`, + }; +} + +function formatRunRecord(record) { + const pid = record.orphaned ? `Next.js pid ${record.stopPid} (launcher gone)` : `pid ${record.pid}`; + return ` ${record.url} ${pid} v${record.version} started ${record.startedAt}`; +} + +// Writes the record once the server is ready and removes it when the launcher +// exits. A failed write only warns: the server keeps running without a record. +// `dir` may be a function, so a failing home lookup is caught here too. +function createRunRecordTracker({ dir, record, parentProcess = process, warn = console.warn }) { + let written = false; + return { + markReady() { + if (written) return; + written = true; + let runDir; + try { + runDir = typeof dir === "function" ? dir() : dir; + writeRunRecord(runDir, record); + } catch (error) { + warn(`[pi-web] could not write the run record (pi-web status/stop/open will not see this server): ${error.message}`); + return; + } + parentProcess.once("exit", () => removeRunRecord(runDir, record.pid)); + }, + }; +} + +module.exports = { + createRunRecordTracker, + formatRunRecord, + getAgentDir, + getOpenUrl, + getRunDir, + isProcessAlive, + isRecordProcess, + isServerAnswering, + listRunRecords, + readProcessCommand, + removeRunRecord, + selectRunRecord, + writeRunRecord, +}; diff --git a/lib/pi-web-cli-commands.test.mjs b/lib/pi-web-cli-commands.test.mjs new file mode 100644 index 0000000000..5e61afd9aa --- /dev/null +++ b/lib/pi-web-cli-commands.test.mjs @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { getHelpText, parseCommandLine } = require("../bin/pi-web-options.js"); +const records = require("../bin/run-records.js"); +const { getInstallKind, isNewerStableVersion, runUpdate } = require("../bin/pi-web-update.js"); +const { runCommand, stopServer } = require("../bin/pi-web-commands.js"); +const { wireChildProcessLifecycle } = require("../bin/process-lifecycle.js"); +const packageJson = require("../package.json"); +const cliPath = fileURLToPath(new URL("../bin/pi-web.js", import.meta.url)); + +function tempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-cli-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function record(port, pid = process.pid, nextPid) { + return { + pid, + nextPid, + port, + hostname: "127.0.0.1", + url: `http://127.0.0.1:${port}`, + version: "1.0.0", + startedAt: "2026-01-01T00:00:00.000Z", + }; +} + +test("parses each command, -v and options after the command", () => { + for (const command of ["version", "status", "stop", "open", "update"]) { + assert.equal(parseCommandLine([command], {}).command, command); + } + assert.equal(parseCommandLine(["-v"], {}).command, "version"); + assert.equal(parseCommandLine(["--version"], {}).command, "version"); + assert.equal(parseCommandLine(["stop", "--port", "8080"], {}).port, "8080"); + assert.equal(parseCommandLine(["open", "-p", "9000"], {}).port, "9000"); + assert.equal(parseCommandLine(["stop"], {}).port, undefined); + assert.equal(parseCommandLine(["update", "--check"], {}).check, true); + assert.deepEqual(parseCommandLine(["status", "--help"], {}), { command: "status", help: true }); + assert.equal(parseCommandLine([], {}).command, "start"); + assert.equal(parseCommandLine(["-p", "8080"], {}).port, "8080"); + assert.match(getHelpText(), /Usage: pi-web \[command\] \[options\]/); + assert.match(getHelpText(), /update \[--check\]/); +}); + +test("rejects unknown commands, foreign options and bad ports", () => { + assert.throws(() => parseCommandLine(["serve"], {}), /Unexpected argument.*\nUse --help/); + assert.throws(() => parseCommandLine(["status", "--port", "1"], {}), /Use --help/); + assert.throws(() => parseCommandLine(["update", "--force"], {}), /Use --help/); + assert.throws(() => parseCommandLine(["stop", "extra"], {}), /Use --help/); + assert.throws(() => parseCommandLine(["stop", "-p", "x"], {}), /Port must be/); + + const unknown = spawnSync(process.execPath, [cliPath, "serve"], { encoding: "utf8" }); + assert.equal(unknown.status, 1); + assert.match(unknown.stderr, /Use --help/); +}); + +test("CLI prints the package version", () => { + for (const arg of ["version", "-v", "--version"]) { + const result = spawnSync(process.execPath, [cliPath, arg], { encoding: "utf8" }); + assert.equal(result.status, 0); + assert.equal(result.stdout.trim(), packageJson.version); + } +}); + +test("compares stable versions only", () => { + assert.equal(isNewerStableVersion("0.11.1", "0.11.0"), true); + assert.equal(isNewerStableVersion("1.0.0", "0.99.99"), true); + assert.equal(isNewerStableVersion("0.10.9", "0.11.0"), false); + assert.equal(isNewerStableVersion("0.11.0", "0.11.0"), false); + assert.equal(isNewerStableVersion("0.12.0-beta.1", "0.11.0"), false); + assert.equal(isNewerStableVersion("0.12.0", "dev"), false); +}); + +test("writes, reads and removes run records; deletes stale ones", async (t) => { + const dir = path.join(tempDir(t), "run"); + records.writeRunRecord(dir, record(4000, 11)); + records.writeRunRecord(dir, record(3000, 10)); + records.writeRunRecord(dir, record(5000, 999_999)); + fs.writeFileSync(path.join(dir, "6000.json"), "{not json"); + + const isAlive = (pid) => pid === 10 || pid === 11; + const isOurs = () => true; + const isAnswering = async () => true; + const listed = await records.listRunRecords(dir, { isAlive, isOurs, isAnswering }); + assert.deepEqual(listed.map((r) => [r.port, r.stopPid, r.orphaned]), [[3000, 10, false], [4000, 11, false]]); + assert.deepEqual(fs.readdirSync(dir).sort(), ["10.json", "11.json"]); + + records.removeRunRecord(dir, 10); + assert.deepEqual(fs.readdirSync(dir), ["11.json"]); + assert.deepEqual(await records.listRunRecords(path.join(dir, "missing")), []); +}); + +test("two servers on one port keep a record each", async (t) => { + const dir = path.join(tempDir(t), "run"); + records.writeRunRecord(dir, { ...record(3000, 10), hostname: "127.0.0.1" }); + records.writeRunRecord(dir, { ...record(3000, 11), hostname: "192.168.1.5", url: "http://192.168.1.5:3000" }); + const listed = await records.listRunRecords(dir, { isAlive: () => true, isOurs: () => true, isAnswering: async () => true }); + assert.equal(listed.length, 2); + assert.match(records.selectRunRecord(listed, "3000").error, /Several pi-web servers are running on port 3000/); +}); + +test("a live pid whose URL does not answer is skipped but its record kept", async (t) => { + const dir = path.join(tempDir(t), "run"); + records.writeRunRecord(dir, record(3000, 10)); + records.writeRunRecord(dir, record(4000, 11)); + const isAnswering = async (url) => url.endsWith(":4000"); + const listed = await records.listRunRecords(dir, { isAlive: () => true, isOurs: () => true, isAnswering }); + assert.deepEqual(listed.map((r) => r.port), [4000]); + assert.deepEqual(fs.readdirSync(dir).sort(), ["10.json", "11.json"]); +}); + +test("a recycled pid that is no longer pi-web is never listed, even when the URL answers", async (t) => { + const dir = path.join(tempDir(t), "run"); + records.writeRunRecord(dir, record(3000, 10, 20)); + const listed = await records.listRunRecords(dir, { isAlive: () => true, isOurs: () => false, isAnswering: async () => true }); + assert.deepEqual(listed, []); + assert.deepEqual(fs.readdirSync(dir), [], "neither process is ours: the record is stale"); +}); + +test("a Next.js server left by a killed launcher is listed for stop", async (t) => { + const dir = path.join(tempDir(t), "run"); + records.writeRunRecord(dir, record(3000, 10, 20)); + const isAlive = (pid) => pid === 20; + const isOurs = (pid, role) => pid === 20 && role === "next"; + const [orphan] = await records.listRunRecords(dir, { isAlive, isOurs, isAnswering: async () => true }); + assert.equal(orphan.stopPid, 20); + assert.equal(orphan.orphaned, true); + assert.match(records.formatRunRecord(orphan), /Next\.js pid 20 \(launcher gone\)/); +}); + +test("process identity: launcher, Next.js server, Windows image name, unknown", () => { + const as = (command, platform = "darwin") => ({ platform, readCommand: () => command }); + assert.equal(records.isRecordProcess(1, "launcher", as("node /usr/local/bin/pi-web -p 30141")), true); + assert.equal(records.isRecordProcess(1, "launcher", as("/usr/bin/sleep 300")), false); + assert.equal(records.isRecordProcess(1, "launcher", as("")), false, "no such process"); + assert.equal(records.isRecordProcess(1, "next", as("next-server (v16.3.8)")), true); + assert.equal(records.isRecordProcess(1, "next", as("/usr/bin/vim")), false); + assert.equal(records.isRecordProcess(1, "launcher", as("node.exe", "win32")), true); + assert.equal(records.isRecordProcess(1, "launcher", as("chrome.exe", "win32")), false); + assert.equal(records.isRecordProcess(1, "launcher", as(null)), true, "cannot tell: the URL check decides"); +}); + +test("reads a process command from /proc, ps or tasklist", () => { + const proc = (content) => ({ platform: "linux", readFile: () => content }); + assert.equal(records.readProcessCommand(1, proc("node\0/x/pi-web\0-p\0" + "30141\0")), "node /x/pi-web -p 30141"); + const enoent = { platform: "linux", readFile: () => { throw Object.assign(new Error("gone"), { code: "ENOENT" }); } }; + assert.equal(records.readProcessCommand(1, enoent), ""); + + const run = (stdout, error) => () => ({ stdout, error }); + assert.equal(records.readProcessCommand(7, { platform: "darwin", run: run("next-server (v16.3.8)\n") }), "next-server (v16.3.8)"); + assert.equal(records.readProcessCommand(7, { platform: "darwin", run: run("") }), ""); + assert.equal(records.readProcessCommand(7, { platform: "darwin", run: run("", new Error("ENOENT")) }), null); + assert.equal(records.readProcessCommand(7, { platform: "win32", run: run('"node.exe","7","Console","1","50,000 K"\r\n') }), "node.exe"); + assert.equal(records.readProcessCommand(7, { platform: "win32", run: run("INFO: No tasks are running which match the specified criteria.") }), ""); +}); + +test("run records live in pi's agent directory", () => { + const home = path.join(path.sep, "home", "a"); + assert.equal(records.getRunDir({}, home), path.join(home, ".pi", "agent", "pi-web-run")); + assert.equal(records.getRunDir({ PI_CODING_AGENT_DIR: "~/pi-test" }, home), path.join(home, "pi-test", "pi-web-run")); + assert.equal(records.getRunDir({ PI_CODING_AGENT_DIR: "/srv/agent" }, home), path.join("/srv/agent", "pi-web-run")); +}); + +test("any HTTP answer counts as a running server", async () => { + assert.equal(await records.isServerAnswering("http://x", async () => new Response("", { status: 401 })), true); + assert.equal(await records.isServerAnswering("http://x", async () => { throw new TypeError("fetch failed"); }), false); +}); + +test("treats EPERM as alive and ESRCH as dead", () => { + const fail = (code) => () => { + throw Object.assign(new Error(code), { code }); + }; + assert.equal(records.isProcessAlive(1, fail("EPERM")), true); + assert.equal(records.isProcessAlive(1, fail("ESRCH")), false); + assert.equal(records.isProcessAlive(1, () => true), true); +}); + +test("open URLs use loopback for wildcard binds", () => { + assert.equal(records.getOpenUrl("0.0.0.0", "8080"), "http://127.0.0.1:8080"); + assert.equal(records.getOpenUrl("::", "8080"), "http://[::1]:8080"); + assert.equal(records.getOpenUrl("::1", "8080"), "http://[::1]:8080"); + assert.equal(records.getOpenUrl("my-host", "8080"), "http://my-host:8080"); +}); + +test("the tracker writes on ready and cleans up when the child exits", (t) => { + const dir = path.join(tempDir(t), "run"); + const parent = new EventEmitter(); + parent.exit = (code) => parent.emit("exit", code); + const child = new EventEmitter(); + child.kill = () => true; + wireChildProcessLifecycle(child, parent, 10, () => {}); + + const tracker = records.createRunRecordTracker({ dir, record: record(4100), parentProcess: parent }); + assert.ok(!fs.existsSync(dir), "nothing before the server is ready"); + tracker.markReady(); + tracker.markReady(); + assert.equal(JSON.parse(fs.readFileSync(path.join(dir, `${process.pid}.json`), "utf8")).port, 4100); + + child.emit("exit", 0, null); + assert.deepEqual(fs.readdirSync(dir), []); +}); + +test("a failed record write warns once and does not throw", (t) => { + const file = path.join(tempDir(t), "not-a-dir"); + fs.writeFileSync(file, ""); + const warnings = []; + const parent = new EventEmitter(); + const tracker = records.createRunRecordTracker({ + dir: path.join(file, "run"), + record: record(4200), + parentProcess: parent, + warn: (message) => warnings.push(message), + }); + tracker.markReady(); + tracker.markReady(); + assert.equal(warnings.length, 1); + assert.equal(parent.listenerCount("exit"), 0); + + const throwing = records.createRunRecordTracker({ + dir: () => { + throw new Error("no home"); + }, + record: record(4300), + parentProcess: parent, + warn: (message) => warnings.push(message), + }); + throwing.markReady(); + assert.equal(warnings.length, 2); +}); + +test("--port selection rule", () => { + const two = [record(3000), record(4000)]; + assert.equal(records.selectRunRecord([], undefined).error, "No pi-web server is running."); + assert.equal(records.selectRunRecord([record(3000)], undefined).record.port, 3000); + assert.match(records.selectRunRecord(two, undefined).error, /Several[\s\S]*:3000[\s\S]*:4000[\s\S]*--port/); + assert.equal(records.selectRunRecord(two, "4000").record.port, 4000); + assert.match(records.selectRunRecord(two, "5000").error, /port 5000/); +}); + +test("status, stop and open use the injected run dir", async (t) => { + const dir = tempDir(t); + const out = []; + const err = []; + const deps = { runDir: dir, isAnswering: async () => true, isOurs: () => true, log: (m) => out.push(m), error: (m) => err.push(m), open: (url) => out.push(`open ${url}`) }; + + assert.equal(await runCommand({ command: "status" }, deps), 0); + assert.equal(out.pop(), "No pi-web server is running."); + assert.equal(await runCommand({ command: "open" }, deps), 1); + assert.equal(await runCommand({ command: "stop" }, deps), 1); + + records.writeRunRecord(dir, record(3000)); + assert.equal(await runCommand({ command: "status" }, deps), 0); + assert.match(out.pop(), /http:\/\/127\.0\.0\.1:3000/); + assert.equal(await runCommand({ command: "open" }, deps), 0); + assert.equal(out.pop(), "open http://127.0.0.1:3000"); +}); + +test("stop sends SIGTERM and waits for the launcher to exit", async () => { + const signals = []; + let alive = true; + const code = await stopServer(record(3000, 42), { + platform: "linux", + kill: (pid, signal) => { + signals.push([pid, signal]); + }, + isAlive: () => alive, + wait: async () => { + alive = false; + }, + log: () => {}, + }); + assert.equal(code, 0); + assert.deepEqual(signals, [[42, "SIGTERM"]]); + + const trees = []; + alive = true; + assert.equal(await stopServer(record(3000, 43), { + platform: "win32", + kill: () => assert.fail("no signal on Windows"), + killTree: (pid) => trees.push(pid), + isAlive: () => alive, + wait: async () => { + alive = false; + }, + log: () => {}, + }), 0); + assert.deepEqual(trees, [43], "the launcher's whole tree, not the launcher alone"); + + signals.length = 0; + alive = true; + assert.equal(await stopServer({ ...record(3000, 42, 77), stopPid: 77, orphaned: true }, { + platform: "linux", + kill: (pid, signal) => { + signals.push([pid, signal]); + }, + isAlive: () => alive, + wait: async () => { + alive = false; + }, + log: () => {}, + }), 0); + assert.deepEqual(signals, [[77, "SIGTERM"]], "an orphaned Next.js server is signalled directly"); + + const stuck = await stopServer(record(3000, 42), { + platform: "linux", + kill: () => {}, + isAlive: () => true, + wait: async () => {}, + timeoutMs: 400, + error: () => {}, + }); + assert.equal(stuck, 1); +}); + +test("install kind: global npm, npx cache or anything else", () => { + const realpath = (p) => p.replace("/link-root", "/real-root"); + assert.equal( + getInstallKind({ pkgDir: "/usr/lib/node_modules/@agegr/pi-web", npmRoot: "/usr/lib/node_modules", realpath }), + "global", + ); + assert.equal( + getInstallKind({ pkgDir: "/real-root/@agegr/pi-web", npmRoot: "/link-root", realpath }), + "global", + ); + assert.equal( + getInstallKind({ pkgDir: "/home/a/.npm/_npx/ab12/node_modules/@agegr/pi-web", npmRoot: "/usr/lib/node_modules", realpath }), + "npx", + ); + assert.equal(getInstallKind({ pkgDir: "/src/pi-web", npmRoot: "/usr/lib/node_modules", realpath }), "other"); + assert.equal( + getInstallKind({ pkgDir: "/home/a/.pnpm-global/5/node_modules/@agegr/pi-web", npmRoot: "/usr/lib/node_modules", realpath }), + "other", + ); + assert.equal(getInstallKind({ pkgDir: "/usr/lib/node_modules/@agegr/pi-web", npmRoot: null, realpath }), "other"); +}); + +test("update decisions", async () => { + const base = { + currentVersion: "0.11.0", + pkgDir: "/usr/lib/node_modules/@agegr/pi-web", + listRecords: () => [], + formatRecord: (r) => r.url, + fetchLatest: async () => "0.12.0", + getNpmRoot: () => "/usr/lib/node_modules", + realpath: (p) => p, + log: () => {}, + error: () => {}, + }; + const installs = []; + const install = (version) => { + installs.push(version); + return 0; + }; + + assert.equal(await runUpdate({ ...base, fetchLatest: async () => "0.11.0", install }), 0); + assert.equal(await runUpdate({ ...base, check: true, install }), 0); + assert.equal(await runUpdate({ ...base, fetchLatest: async () => { throw new Error("offline"); }, install }), 1); + assert.equal(await runUpdate({ ...base, listRecords: () => [record(3000)], install }), 1); + assert.equal(await runUpdate({ ...base, pkgDir: "/home/a/.npm/_npx/x/node_modules/@agegr/pi-web", install }), 1); + assert.equal(await runUpdate({ ...base, pkgDir: "/src/pi-web", install }), 1); + assert.deepEqual(installs, []); + + assert.equal(await runUpdate({ ...base, install }), 0); + assert.deepEqual(installs, ["0.12.0"]); + assert.equal(await runUpdate({ ...base, install: () => 7 }), 7); + assert.equal(await runUpdate({ ...base, install: () => null }), 1); +});