@evandu/super-pi 0.1.80 → 0.1.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/super-pi.js CHANGED
@@ -3,6 +3,8 @@ import { spawn } from "node:child_process";
3
3
  import { resolve } from "node:path";
4
4
 
5
5
  const entry = resolve(import.meta.dirname, "../dist/server/index.js");
6
+ const packageRoot = resolve(import.meta.dirname, "..");
7
+ const packagePrefix = packageRoot.match(/^(.*)[\\/]node_modules[\\/]/i)?.[1];
6
8
 
7
9
  const sub = process.argv[2]?.toLowerCase();
8
10
  const port = Number(process.env.SUPER_PI_PORT ?? 3080);
@@ -61,7 +63,8 @@ if (sub === "update") {
61
63
  // the user's shell isn't tied to the upgrade.
62
64
  console.log("Updating @evandu/super-pi via npm...");
63
65
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
64
- const updater = spawn(npmCmd, ["install", "-g", "@evandu/super-pi"], {
66
+ const updateArgs = packagePrefix ? ["install", "--prefix", packagePrefix, "@evandu/super-pi"] : ["install", "-g", "@evandu/super-pi"];
67
+ const updater = spawn(npmCmd, updateArgs, {
65
68
  stdio: "inherit",
66
69
  shell: process.platform === "win32",
67
70
  env: process.env,
@@ -21,5 +21,5 @@
21
21
  "src/client/types.ts": "11fd40e5784a2e765635f4f57d6edd5c97ad0eae85b1825b9da32a88d25b4db9",
22
22
  "src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
23
23
  },
24
- "recordedAt": "2026-10-18T00:04:03.485Z"
24
+ "recordedAt": "2026-10-18T00:14:15.331Z"
25
25
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { join } from "node:path";
3
+ import { packageUpdateArgs } from "./update.js";
3
4
  /**
4
5
  * Routes client commands to appropriate application services and converts results or errors.
5
6
  */
@@ -259,7 +260,8 @@ export class CommandHandler {
259
260
  // /api/update endpoint uses runs detached. The user sees npm's output stream in their
260
261
  // terminal because stdio: inherit is what makes a CLI feel like a CLI.
261
262
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
262
- const updater = spawn(npmCmd, ["install", "-g", "@evandu/super-pi"], { stdio: "inherit", shell: process.platform === "win32", env: process.env });
263
+ const update = packageUpdateArgs(this.rootDir ?? import.meta.dirname);
264
+ const updater = spawn(npmCmd, update.args, { stdio: "inherit", shell: process.platform === "win32", env: process.env });
263
265
  updater.on("error", (error) => { console.error(`Super Pi: npm install failed: ${error instanceof Error ? error.message : "unknown error"}`); });
264
266
  updater.on("close", (code) => {
265
267
  if (code !== 0) {
@@ -9,6 +9,7 @@ import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
9
  import { checkDiskVersion } from "./version.js";
10
10
  import { gitDiff, gitLog, gitStatus } from "./git.js";
11
11
  import { verifyCapability } from "./capability.js";
12
+ import { packageUpdateArgs } from "./update.js";
12
13
  const MIME_TYPES = {
13
14
  ".css": "text/css; charset=utf-8",
14
15
  ".html": "text/html; charset=utf-8",
@@ -427,11 +428,13 @@ export function createHttpServer(options) {
427
428
  // Use npm directly instead of `pi update` to avoid Windows detached+shell exit timing issues.
428
429
  // npm's exit code correctly reflects whether the update succeeded.
429
430
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
430
- const child = spawn(npmCmd, ["install", "-g", "@evandu/super-pi"], { stdio: "ignore", shell: process.platform === "win32", env: process.env });
431
+ const update = packageUpdateArgs(rootDir);
432
+ const child = spawn(npmCmd, update.args, { stdio: "ignore", shell: process.platform === "win32", env: process.env });
431
433
  child.on("close", (code) => {
432
434
  if (code === 0 || code === null) {
433
435
  console.log("Super Pi: npm install completed, triggering restart.");
434
436
  const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
437
+ restart.on("error", (restartError) => console.error(`Super Pi: restart after update failed: ${restartError instanceof Error ? restartError.message : String(restartError)}`));
435
438
  restart.unref();
436
439
  }
437
440
  else {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Return npm's install arguments for the package that is actually running.
3
+ *
4
+ * Pi can keep Super Pi in its own npm prefix, separate from the user's normal
5
+ * global npm prefix. A bare `npm install -g` therefore may update a different
6
+ * copy while this process keeps serving the old one. Installed package roots
7
+ * contain `node_modules/@evandu/super-pi`; walking back to the prefix makes the
8
+ * update target unambiguous. Source checkouts retain the global fallback.
9
+ */
10
+ export declare function packageUpdateArgs(rootDir: string): {
11
+ args: string[];
12
+ prefix?: string;
13
+ };
@@ -0,0 +1,20 @@
1
+ import { sep } from "node:path";
2
+ /**
3
+ * Return npm's install arguments for the package that is actually running.
4
+ *
5
+ * Pi can keep Super Pi in its own npm prefix, separate from the user's normal
6
+ * global npm prefix. A bare `npm install -g` therefore may update a different
7
+ * copy while this process keeps serving the old one. Installed package roots
8
+ * contain `node_modules/@evandu/super-pi`; walking back to the prefix makes the
9
+ * update target unambiguous. Source checkouts retain the global fallback.
10
+ */
11
+ export function packageUpdateArgs(rootDir) {
12
+ const marker = `${sep}node_modules${sep}`;
13
+ const lower = rootDir.toLowerCase();
14
+ const index = lower.lastIndexOf(marker);
15
+ if (index >= 0) {
16
+ const prefix = rootDir.slice(0, index);
17
+ return { prefix, args: ["install", "--prefix", prefix, "@evandu/super-pi"] };
18
+ }
19
+ return { args: ["install", "-g", "@evandu/super-pi"] };
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,6 +16,7 @@
16
16
  "dist",
17
17
  "agents",
18
18
  "bin",
19
+ "scripts/restart-server.mjs",
19
20
  "README.md"
20
21
  ],
21
22
  "engines": {
@@ -0,0 +1,62 @@
1
+ import { spawn, execFile } from "node:child_process";
2
+ import { openSync, readFileSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const port = Number(process.env.SUPER_PI_PORT ?? 3080);
8
+ const entry = "dist/server/index.js";
9
+ const allowedEntries = [entry, "src/server/index.ts"];
10
+ const waitMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ async function pidsOnPort() {
12
+ if (platform() === "win32") {
13
+ const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "TCP"]);
14
+ return [...new Set(stdout.split(/\r?\n/).filter((line) => line.includes(`:${port}`) && /LISTENING\s+\d+/.test(line)).map((line) => line.trim().split(/\s+/).at(-1)))];
15
+ }
16
+ try { const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]); return stdout.trim().split(/\s+/).filter(Boolean); }
17
+ catch { const { stdout } = await execFileAsync("sh", ["-c", `ss -lptn 'sport = :${port}' 2>/dev/null`]); return [...new Set([...stdout.matchAll(/pid=(\d+)/g)].map((match) => match[1]))]; }
18
+ }
19
+ async function commandLine(pid) {
20
+ if (platform() === "win32") { const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\").CommandLine`]); return stdout.trim(); }
21
+ try { return (await import("node:fs/promises")).readFile(`/proc/${pid}/cmdline`, "utf8").then((value) => value.replaceAll("\0", " ").trim()); } catch { const { stdout } = await execFileAsync("ps", ["-p", pid, "-o", "command="]); return stdout.trim(); }
22
+ }
23
+ async function requestShutdown() {
24
+ const headers = {};
25
+ if (process.env.SUPER_PI_ACCESS_TOKEN) headers.authorization = `Bearer ${process.env.SUPER_PI_ACCESS_TOKEN}`;
26
+ try { return (await fetch(`http://127.0.0.1:${port}/api/shutdown`, { method: "POST", headers, signal: AbortSignal.timeout(2000) })).status; }
27
+ catch { return null; }
28
+ }
29
+ async function waitForPortRelease(timeoutMs) {
30
+ for (let elapsed = 0; elapsed < timeoutMs; elapsed += 100) { if (!(await pidsOnPort()).length) return true; await waitMs(100); }
31
+ return false;
32
+ }
33
+ // On Windows a detached, console-less node.exe has no window to receive WM_CLOSE, so plain
34
+ // taskkill (SIGTERM-like) always fails; /F (or SIGKILL elsewhere) is the only force option.
35
+ async function forceKill(pid) {
36
+ console.log(`Force-killing PID ${pid} (graceful shutdown unavailable or timed out)...`);
37
+ if (platform() === "win32") await execFileAsync("taskkill", ["/PID", pid, "/T", "/F"]).catch((error) => console.log(`taskkill /F warning: ${error.message}`));
38
+ else process.kill(Number(pid), "SIGKILL");
39
+ }
40
+ async function stopConflicts() {
41
+ const pids = await pidsOnPort(); if (!pids.length) { console.log(`Port ${port} is free.`); return true; }
42
+ for (const pid of pids) { const cmd = await commandLine(pid); console.log(`Port ${port} is occupied by PID ${pid}: ${cmd}`); if (!allowedEntries.some((candidate) => cmd.includes(candidate))) { console.error(`Refusing to kill non-super-pi process on port ${port}: ${cmd}`); process.exitCode = 2; return false; } }
43
+ const status = await requestShutdown();
44
+ if (status !== null && status >= 200 && status < 300) {
45
+ console.log(`Graceful shutdown accepted (HTTP ${status}); waiting for port ${port} to release...`);
46
+ if (await waitForPortRelease(3000)) { console.log(`Port ${port} released after graceful shutdown.`); return true; }
47
+ console.log(`Graceful shutdown did not release port ${port} within 3s.`);
48
+ } else {
49
+ console.log(`Graceful shutdown unavailable (HTTP ${status ?? "no response"}); the running server may predate the shutdown endpoint.`);
50
+ }
51
+ for (const pid of await pidsOnPort()) await forceKill(pid);
52
+ if (await waitForPortRelease(2000)) { console.log(`Port ${port} released after force kill.`); return true; }
53
+ console.error(`Port ${port} is still occupied after force kill.`); process.exitCode = 1; return false;
54
+ }
55
+ if (await stopConflicts() === false) process.exit(2);
56
+ console.log(`Starting ${entry} on port ${port}...`);
57
+ const logPath = process.env.SUPER_PI_LOG ?? "restart-server.log";
58
+ const logFd = openSync(logPath, "a");
59
+ const child = spawn(process.execPath, [entry], { stdio: ["ignore", logFd, logFd], detached: true, env: process.env });
60
+ child.unref();
61
+ for (let elapsed = 0; elapsed < 10000; elapsed += 200) { try { const response = await fetch(`http://127.0.0.1:${port}/api/health`); const body = await response.json(); if (response.ok && body.status === "ok" && body.service === "super-pi") { console.log("Server ready: /api/health passed."); process.exit(0); } } catch {} await waitMs(200); }
62
+ console.error(`Server failed readiness check. See ${logPath}:`); try { console.error(readFileSync(logPath, "utf8").slice(-8000)); } catch {} child.kill(); process.exit(1);