diff --git a/README.md b/README.md index 4eaf005..2e31dda 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ This writes and starts: The generated services run through `bash -lc` so they see a shell environment similar to running `pi` from your terminal. -Open . +Open . Useful commands: @@ -123,7 +123,7 @@ Advanced users may run the binaries however they prefer: ```bash pi-web-sessiond -PI_WEB_PORT=3000 pi-web-server +PI_WEB_PORT=8504 pi-web-server ``` ## Development quick start @@ -133,7 +133,7 @@ npm install npm run dev ``` -Open the Vite URL, usually . +Open the Vite URL, usually . For the recommended split development setup, run these in separate terminals: @@ -150,10 +150,10 @@ You can restart `dev:web` or `dev:client` without stopping active Pi sessions. ```bash npm run build npm run start:sessiond -PI_WEB_PORT=3000 npm start +PI_WEB_PORT=8504 npm start ``` -The web server defaults to `127.0.0.1:3000`. Set `PI_WEB_HOST=0.0.0.0` only when you intentionally want to bind directly on all interfaces. +The web server defaults to `127.0.0.1:8504`. Set `PI_WEB_HOST=0.0.0.0` only when you intentionally want to bind directly on all interfaces. The session daemon defaults to a private Unix socket at: @@ -163,7 +163,7 @@ The session daemon defaults to a private Unix socket at: Environment variables: -- `PI_WEB_PORT` / `PORT` — web server port. Defaults to `3000`. +- `PI_WEB_PORT` / `PORT` — web server port. Defaults to `8504`. - `PI_WEB_HOST` — web server bind host. Defaults to `127.0.0.1`. - `PI_WEB_SESSIOND_SOCKET` — Unix socket path used by both the daemon and web process when `PI_WEB_SESSIOND_URL` is not set. Defaults to `~/.pi-web/sessiond.sock`. - `PI_WEB_SESSIOND_PORT` — optional TCP port for the daemon. If unset, the daemon listens on the Unix socket instead. diff --git a/src/cli.ts b/src/cli.ts index 7779064..e3fbd9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node +import { existsSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { defaultPiWebConfigPath, examplePiWebConfig } from "./config.js"; const serviceDir = join(homedir(), ".config", "systemd", "user"); const sessiondServiceName = "pi-web-sessiond.service"; @@ -11,6 +13,7 @@ const webServiceName = "pi-web.service"; interface InstallOptions { host: string; port: string; + config?: string; } function run(command: string, args: string[], options: { check?: boolean } = {}): number { @@ -30,7 +33,7 @@ function hasCommand(command: string): boolean { } function parseInstallOptions(args: string[]): InstallOptions { - const options: InstallOptions = { host: "127.0.0.1", port: "3000" }; + const options: InstallOptions = { host: "127.0.0.1", port: "8504" }; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === undefined) continue; @@ -48,6 +51,13 @@ function parseInstallOptions(args: string[]): InstallOptions { i += 1; } else if (arg.startsWith("--port=")) { options.port = arg.slice("--port=".length); + } else if (arg === "--config") { + const value = args[i + 1]; + if (value === undefined) throw new Error("--config requires a value"); + options.config = value; + i += 1; + } else if (arg.startsWith("--config=")) { + options.config = arg.slice("--config=".length); } else if (arg === "--user-systemd") { // Accepted for readability; user systemd is the only installer target for now. } else { @@ -81,6 +91,7 @@ WantedBy=default.target } function webUnit(options: InstallOptions): string { + const configEnvironment = options.config === undefined ? "" : `Environment="PI_WEB_CONFIG=${systemdEscape(resolve(options.config))}"\n`; return `[Unit] Description=Pi Web server After=${sessiondServiceName} @@ -88,9 +99,7 @@ Wants=${sessiondServiceName} [Service] Type=simple -Environment="PI_WEB_HOST=${systemdEscape(options.host)}" -Environment="PI_WEB_PORT=${systemdEscape(options.port)}" -ExecStart=/usr/bin/env bash -lc ${shellSingleQuote("exec pi-web-server")} +${configEnvironment}ExecStart=/usr/bin/env bash -lc ${shellSingleQuote("exec pi-web-server")} Restart=on-failure RestartSec=2 @@ -99,12 +108,23 @@ WantedBy=default.target `; } +async function writeInitialConfig(options: InstallOptions): Promise { + const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config); + await mkdir(dirname(configPath), { recursive: true }); + if (!existsSync(configPath)) { + await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) })); + } + return configPath; +} + async function install(args: string[]): Promise { const options = parseInstallOptions(args); if (!hasCommand("systemctl")) throw new Error("systemctl was not found in a bash login shell"); if (!hasCommand("pi-web-server")) throw new Error("pi-web-server was not found in a bash login shell. Is pi-web installed globally?"); if (!hasCommand("pi-web-sessiond")) throw new Error("pi-web-sessiond was not found in a bash login shell. Is pi-web installed globally?"); + const configPath = await writeInitialConfig(options); + await mkdir(serviceDir, { recursive: true }); await writeFile(join(serviceDir, sessiondServiceName), sessiondUnit()); await writeFile(join(serviceDir, webServiceName), webUnit(options)); @@ -114,6 +134,7 @@ async function install(args: string[]): Promise { run("systemctl", ["--user", "enable", "--now", webServiceName], { check: true }); console.log(`\nPi Web is installed and starting.`); + console.log(`Config: ${configPath}`); console.log(`Open: http://${options.host === "0.0.0.0" ? "127.0.0.1" : options.host}:${options.port}`); console.log("\nUseful commands:"); console.log(" pi-web status"); @@ -170,7 +191,7 @@ function help(): void { console.log(`Pi Web Usage: - pi-web install [--host 127.0.0.1] [--port 3000] + pi-web install [--host 127.0.0.1] [--port 8504] [--config ~/.config/pi-web/config.json] pi-web uninstall pi-web start|stop|restart|status|logs pi-web doctor diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..03061a7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export interface PiWebConfig { + host?: string; + port?: number; + allowedHosts?: string[] | true; +} + +export interface LoadedPiWebConfig { + path: string; + exists: boolean; + config: PiWebConfig; +} + +interface LoadOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; +} + +export function defaultPiWebConfigPath(env: NodeJS.ProcessEnv = process.env): string { + const xdgConfigHome = env["XDG_CONFIG_HOME"]; + return join(xdgConfigHome !== undefined && xdgConfigHome !== "" ? xdgConfigHome : join(homedir(), ".config"), "pi-web", "config.json"); +} + +export function piWebConfigPath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string { + const configured = env["PI_WEB_CONFIG"]; + if (configured === undefined || configured === "") return defaultPiWebConfigPath(env); + return resolve(cwd, configured); +} + +export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { + const env = options.env ?? process.env; + const path = piWebConfigPath(env, options.cwd ?? process.cwd()); + if (!existsSync(path)) return { path, exists: false, config: {} }; + + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isRecord(parsed)) throw new Error(`Pi Web config must be a JSON object: ${path}`); + + return { path, exists: true, config: parsePiWebConfig(parsed, path) }; +} + +export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { + const loaded = loadPiWebConfig(options); + const env = options.env ?? process.env; + const host = env["PI_WEB_HOST"]; + const port = env["PI_WEB_PORT"] ?? env["PORT"]; + const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; + + return { + ...loaded, + config: { + ...loaded.config, + ...(host !== undefined && host !== "" ? { host } : {}), + ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), + ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), + }, + }; +} + +function parsePiWebConfig(value: Record, path: string): PiWebConfig { + return { + ...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}), + ...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}), + ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), + }; +} + +function parseString(value: unknown, key: string, path: string): string { + if (typeof value !== "string" || value === "") throw new Error(`Pi Web config ${key} must be a non-empty string: ${path}`); + return value; +} + +function parsePort(value: unknown, key: string, path = "environment"): number { + const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Pi Web config ${key} must be an integer from 1 to 65535: ${path}`); + return port; +} + +function parseAllowedHosts(value: unknown, path: string): string[] | true { + if (value === true) return true; + if (!isNonEmptyStringArray(value)) { + throw new Error(`Pi Web config allowedHosts must be true or an array of non-empty strings: ${path}`); + } + return value; +} + +function parseAllowedHostsEnv(value: string): string[] | true { + if (value === "true") return true; + return value.split(",").map((host) => host.trim()).filter((host) => host !== ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== ""); +} + +export function examplePiWebConfig(config: PiWebConfig = {}): string { + return `${JSON.stringify({ host: config.host ?? "127.0.0.1", port: config.port ?? 8504, allowedHosts: config.allowedHosts ?? [] }, null, 2)}\n`; +} + +export function piWebConfigDir(env: NodeJS.ProcessEnv = process.env): string { + return dirname(defaultPiWebConfigPath(env)); +} diff --git a/src/server/index.ts b/src/server/index.ts index 33a87e7..f9f29ba 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,6 @@ +import { effectivePiWebConfig } from "../config.js"; import { buildApp } from "./app.js"; const app = await buildApp(); -const port = Number(process.env["PI_WEB_PORT"] ?? process.env["PORT"] ?? 3000); -const host = process.env["PI_WEB_HOST"] ?? "127.0.0.1"; -await app.listen({ port, host }); +const { config } = effectivePiWebConfig(); +await app.listen({ port: config.port ?? 8504, host: config.host ?? "127.0.0.1" }); diff --git a/vite.config.ts b/vite.config.ts index 3320d22..1326a8f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,8 @@ import { defineConfig } from "vite"; +import { effectivePiWebConfig } from "./src/config"; + +const { config } = effectivePiWebConfig(); +const apiPort = config.port ?? 8504; export default defineConfig({ root: "src/client", @@ -14,15 +18,17 @@ export default defineConfig({ if (id.includes("@codemirror/lang-") || id.includes("@lezer/")) return "vendor-editor-languages"; if (id.includes("@codemirror") || id.includes("codemirror")) return "vendor-editor-core"; if (id.includes("@xterm")) return "vendor-terminal"; + return undefined; }, }, }, }, server: { - port: 5173, + port: 8505, strictPort: true, + ...(config.allowedHosts === undefined ? {} : { allowedHosts: config.allowedHosts }), proxy: { - "/api": { target: "http://localhost:3000", ws: true }, + "/api": { target: `http://localhost:${String(apiPort)}`, ws: true }, }, }, });