diff --git a/README.md b/README.md index a913d6d..4eaf005 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,48 @@ Pi Web keeps its own state intentionally small: - Sessions and chat history: Pi's default JSONL session storage - Active session runtimes and WebSockets: memory in the session daemon -## Quick start +## Install + +Recommended install uses npm plus systemd user services: + +```bash +npm install -g pi-web +pi-web install +``` + +This writes and starts: + +- `~/.config/systemd/user/pi-web-sessiond.service` +- `~/.config/systemd/user/pi-web.service` + +The generated services run through `bash -lc` so they see a shell environment similar to running `pi` from your terminal. + +Open . + +Useful commands: + +```bash +pi-web status +pi-web logs +pi-web restart +pi-web doctor +pi-web uninstall +``` + +One-line install is also available for users who prefer it: + +```bash +curl -fsSL https://raw.githubusercontent.com/earendil-works/pi-web/main/install.sh | sh +``` + +Advanced users may run the binaries however they prefer: + +```bash +pi-web-sessiond +PI_WEB_PORT=3000 pi-web-server +``` + +## Development quick start ```bash npm install @@ -104,7 +145,7 @@ npm run dev:client You can restart `dev:web` or `dev:client` without stopping active Pi sessions. -## Production-style run +## Production-style run from a checkout ```bash npm run build diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..2cf4724 --- /dev/null +++ b/install.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu + +npm install -g pi-web +pi-web install diff --git a/package-lock.json b/package-lock.json index fc56d1a..52aaf71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "pi-web-poc", + "name": "pi-web", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pi-web-poc", + "name": "pi-web", "version": "0.0.1", "license": "MIT", "dependencies": { @@ -30,6 +30,11 @@ "node-pty": "^1.1.0", "ws": "^8.18.3" }, + "bin": { + "pi-web": "dist/cli.js", + "pi-web-server": "dist/server/index.js", + "pi-web-sessiond": "dist/server/sessiond.js" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.10.1", diff --git a/package.json b/package.json index 352fc45..339659a 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,26 @@ { - "name": "pi-web-poc", + "name": "pi-web", "version": "0.0.1", "license": "MIT", "type": "module", + "bin": { + "pi-web": "./dist/cli.js", + "pi-web-server": "./dist/server/index.js", + "pi-web-sessiond": "./dist/server/sessiond.js" + }, + "files": [ + "dist", + "install.sh", + "README.md", + "LICENSE" + ], "scripts": { "dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", "dev:sessiond": "tsx watch src/server/sessiond.ts", "dev:web": "tsx watch src/server/index.ts", "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", - "build": "tsc && vite build", + "build": "tsc -p tsconfig.build.json && vite build", "typecheck": "tsc --noEmit", "lint": "eslint \"src/**/*.ts\" vite.config.ts vitest.config.ts", "test": "vitest run --config vitest.config.ts", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..7779064 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,198 @@ +#!/usr/bin/env node +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const serviceDir = join(homedir(), ".config", "systemd", "user"); +const sessiondServiceName = "pi-web-sessiond.service"; +const webServiceName = "pi-web.service"; + +interface InstallOptions { + host: string; + port: string; +} + +function run(command: string, args: string[], options: { check?: boolean } = {}): number { + const result = spawnSync(command, args, { stdio: "inherit" }); + const status = result.status ?? 1; + if (options.check === true && status !== 0) process.exit(status); + return status; +} + +function capture(command: string, args: string[]): { status: number; stdout: string; stderr: string } { + const result = spawnSync(command, args, { encoding: "utf8" }); + return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr }; +} + +function hasCommand(command: string): boolean { + return capture("/usr/bin/env", ["bash", "-lc", `command -v ${command}`]).status === 0; +} + +function parseInstallOptions(args: string[]): InstallOptions { + const options: InstallOptions = { host: "127.0.0.1", port: "3000" }; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === undefined) continue; + if (arg === "--host") { + const value = args[i + 1]; + if (value === undefined) throw new Error("--host requires a value"); + options.host = value; + i += 1; + } else if (arg.startsWith("--host=")) { + options.host = arg.slice("--host=".length); + } else if (arg === "--port") { + const value = args[i + 1]; + if (value === undefined) throw new Error("--port requires a value"); + options.port = value; + i += 1; + } else if (arg.startsWith("--port=")) { + options.port = arg.slice("--port=".length); + } else if (arg === "--user-systemd") { + // Accepted for readability; user systemd is the only installer target for now. + } else { + throw new Error(`Unknown install option: ${arg}`); + } + } + return options; +} + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function systemdEscape(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function sessiondUnit(): string { + return `[Unit] +Description=Pi Web session daemon + +[Service] +Type=simple +ExecStart=/usr/bin/env bash -lc ${shellSingleQuote("exec pi-web-sessiond")} +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target +`; +} + +function webUnit(options: InstallOptions): string { + return `[Unit] +Description=Pi Web server +After=${sessiondServiceName} +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")} +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target +`; +} + +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?"); + + await mkdir(serviceDir, { recursive: true }); + await writeFile(join(serviceDir, sessiondServiceName), sessiondUnit()); + await writeFile(join(serviceDir, webServiceName), webUnit(options)); + + run("systemctl", ["--user", "daemon-reload"], { check: true }); + run("systemctl", ["--user", "enable", "--now", sessiondServiceName], { check: true }); + run("systemctl", ["--user", "enable", "--now", webServiceName], { check: true }); + + console.log(`\nPi Web is installed and starting.`); + 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"); + console.log(" pi-web logs"); + console.log(" pi-web restart"); +} + +async function uninstall(): Promise { + run("systemctl", ["--user", "disable", "--now", webServiceName]); + run("systemctl", ["--user", "disable", "--now", sessiondServiceName]); + await rm(join(serviceDir, webServiceName), { force: true }); + await rm(join(serviceDir, sessiondServiceName), { force: true }); + run("systemctl", ["--user", "daemon-reload"]); + console.log("Pi Web systemd user services removed."); +} + +function serviceAction(action: "start" | "stop" | "restart" | "status"): void { + run("systemctl", ["--user", action, sessiondServiceName, webServiceName], { check: action !== "status" }); +} + +function logs(): void { + run("journalctl", ["--user", "-u", sessiondServiceName, "-u", webServiceName, "-f"]); +} + +function doctor(): void { + const checks: [string, string[]][] = [ + ["systemctl --user", ["systemctl", "--user", "--version"]], + ["bash login shell can find node", ["/usr/bin/env", "bash", "-lc", "command -v node"]], + ["bash login shell can find npm", ["/usr/bin/env", "bash", "-lc", "command -v npm"]], + ["bash login shell can find pi", ["/usr/bin/env", "bash", "-lc", "command -v pi"]], + ["bash login shell can find pi-web-server", ["/usr/bin/env", "bash", "-lc", "command -v pi-web-server"]], + ["bash login shell can find pi-web-sessiond", ["/usr/bin/env", "bash", "-lc", "command -v pi-web-sessiond"]], + ]; + + let failed = false; + for (const [label, command] of checks) { + const [bin, ...args] = command; + if (bin === undefined) continue; + const result = capture(bin, args); + const ok = result.status === 0; + failed ||= !ok; + console.log(`${ok ? "✓" : "✗"} ${label}`); + const output = (result.stdout || result.stderr).trim(); + if (output !== "") console.log(` ${output.split("\n")[0] ?? ""}`); + } + + if (failed) { + console.log("\nIf a command works in your terminal but fails here, make sure your bash login files set PATH the same way."); + process.exitCode = 1; + } +} + +function help(): void { + console.log(`Pi Web + +Usage: + pi-web install [--host 127.0.0.1] [--port 3000] + pi-web uninstall + pi-web start|stop|restart|status|logs + pi-web doctor + +Recommended install: + npm install -g pi-web + pi-web install +`); +} + +async function main(): Promise { + const [command = "help", ...args] = process.argv.slice(2); + if (command === "install") await install(args); + else if (command === "uninstall") await uninstall(); + else if (command === "start" || command === "stop" || command === "restart" || command === "status") serviceAction(command); + else if (command === "logs") logs(); + else if (command === "doctor") doctor(); + else if (command === "help" || command === "--help" || command === "-h") help(); + else throw new Error(`Unknown command: ${command}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/server/app.ts b/src/server/app.ts index be5dff3..555d3b8 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify"; import fastifyStatic from "@fastify/static"; import fastifyWebsocket from "@fastify/websocket"; @@ -78,7 +79,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise reply.sendFile("index.html")); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..05cc306 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": true + }, + "include": ["src/cli.ts", "src/server/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +}