Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` を実行します。

## 設定

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

## Настройка

Expand Down
12 changes: 11 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

## 配置

Expand Down
34 changes: 34 additions & 0 deletions bin/browser-opener.js
Original file line number Diff line number Diff line change
@@ -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 };
118 changes: 118 additions & 0 deletions bin/pi-web-commands.js
Original file line number Diff line number Diff line change
@@ -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 };
64 changes: 57 additions & 7 deletions bin/pi-web-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port>] Stop a running server
open [--port <port>] Open a running server in the browser
update [--check] Update a global npm install to the latest version

Options:
-p, --port <port> Server port (default: 30141, or PORT)
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -88,4 +138,4 @@ function parseLaunchOptions(args = process.argv.slice(2), env = process.env) {
};
}

module.exports = { parseLaunchOptions, getHelpText };
module.exports = { parseCommandLine, parseLaunchOptions, getHelpText };
Loading
Loading