feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden
Settings → AI Proxy gains an admin-only Update 9router button beside Config 9router. One in-memory job (server/lib/nine-router-update.js) stops 9router (its user unit, else this account's own 9router processes), runs npm install -g --prefix <its prefix> 9router@latest (sudo -n only when sudoers already allows it, probed before anything stops), then relaunches it hidden only if it was running. A failed update still relaunches the old version. Nothing in the request names a command, path or package.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @file nine-router-update.test.js
|
||||
* @description The **Update 9router** job (`server/lib/nine-router-update.js`)
|
||||
* and its routes, against a FAKE 9router: a throwaway npm prefix whose `cli.js`
|
||||
* listens on a free port and keeps a renamed child, and a fake `npm` that bumps
|
||||
* its version. PATH is cut down so the machine's real 9router can never be
|
||||
* found, let alone stopped — every test first checks the located install is
|
||||
* the fake one. No systemd: the unit path is a seam turned off here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "nine-router-update-"));
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
const PREFIX = path.join(TMP, "prefix");
|
||||
const PKG_DIR = path.join(PREFIX, "lib", "node_modules", "9router");
|
||||
const BIN_DIR = path.join(PREFIX, "bin");
|
||||
const NPM_DIR = path.join(TMP, "npmbin");
|
||||
const ORIGINAL_PATH = process.env.PATH;
|
||||
|
||||
const update = require("../lib/nine-router-update");
|
||||
const express = require("express");
|
||||
const router = require("../routes/ai-proxies");
|
||||
|
||||
const CLI = `#!/usr/bin/env node
|
||||
const net = require("net");
|
||||
const { spawn } = require("child_process");
|
||||
const i = process.argv.indexOf("-p");
|
||||
const port = Number(process.argv[i + 1]);
|
||||
// A child that renames itself, like 9router's next-server.
|
||||
const child = spawn(process.execPath, ["-e", "process.title='fake-next-server'; setInterval(()=>{},1e6)"], { stdio: "ignore" });
|
||||
const srv = net.createServer((s) => s.end()).listen(port, "127.0.0.1");
|
||||
process.on("SIGTERM", () => { try { child.kill("SIGKILL"); } catch {} srv.close(); process.exit(0); });
|
||||
setInterval(() => {}, 1e6);
|
||||
`;
|
||||
|
||||
const NPM = `#!/bin/sh
|
||||
# fake npm: --version, or install -g --prefix P ... 9router@latest
|
||||
if [ "$1" = "--version" ]; then echo 10.0.0; exit 0; fi
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prefix" ]; then P="$2"; shift; fi
|
||||
shift
|
||||
done
|
||||
[ -f "${TMP}/npm-fail" ] && { echo "boom" >&2; exit 1; }
|
||||
echo '{"name":"9router","version":"9.9.9"}' > "$P/lib/node_modules/9router/package.json"
|
||||
echo installed
|
||||
`;
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve) => {
|
||||
const s = net.createServer().listen(0, "127.0.0.1", () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const portOpen = (port) =>
|
||||
new Promise((resolve) => {
|
||||
const s = net.connect({ host: "127.0.0.1", port });
|
||||
s.once("connect", () => (s.destroy(), resolve(true)));
|
||||
s.once("error", () => resolve(false));
|
||||
});
|
||||
|
||||
async function until(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await fn()) return true;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** pids whose argv names the fake install — what a test must clean up. */
|
||||
function fakePids() {
|
||||
const out = [];
|
||||
for (const d of fs.readdirSync("/proc")) {
|
||||
if (!/^\d+$/.test(d)) continue;
|
||||
try {
|
||||
const argv = fs.readFileSync(`/proc/${d}/cmdline`, "utf8");
|
||||
if (argv.includes(TMP) || argv.includes("fake-next-server")) out.push(Number(d));
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
return out.filter((p) => p !== process.pid);
|
||||
}
|
||||
|
||||
function killFakes() {
|
||||
for (const pid of fakePids()) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startFake(port) {
|
||||
const child = spawn(process.execPath, [path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
async function finished() {
|
||||
await until(() => update.current()?.status !== "running", 60000);
|
||||
return update.current();
|
||||
}
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(PKG_DIR, { recursive: true });
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
fs.mkdirSync(NPM_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(PKG_DIR, "cli.js"), CLI, { mode: 0o755 });
|
||||
fs.symlinkSync("../lib/node_modules/9router/cli.js", path.join(BIN_DIR, "9router"));
|
||||
fs.writeFileSync(path.join(NPM_DIR, "npm"), NPM, { mode: 0o755 });
|
||||
// Only the fake bins, node's own dir and the basics — never /usr/local/bin.
|
||||
process.env.PATH = [BIN_DIR, NPM_DIR, path.dirname(process.execPath), "/bin", "/usr/bin"].join(":");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/ai-proxies", router);
|
||||
await new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
killFakes();
|
||||
update.__reset();
|
||||
fs.writeFileSync(path.join(PKG_DIR, "package.json"), JSON.stringify({ name: "9router", version: "1.0.0" }));
|
||||
fs.rmSync(path.join(TMP, "npm-fail"), { force: true });
|
||||
update.__setSeams({
|
||||
useSystemd: false,
|
||||
fetchLatest: async () => "2.0.0",
|
||||
timing: { termGraceMs: 2000, portFreeMs: 5000, portUpMs: 15000 },
|
||||
});
|
||||
const loc = update.locate();
|
||||
assert.ok(loc && loc.pkgDir.startsWith(TMP), `refusing to run against ${loc?.pkgDir}`);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
killFakes();
|
||||
process.env.PATH = ORIGINAL_PATH;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("nine-router-update", () => {
|
||||
it("reports the install, the latest version and that nothing runs", async () => {
|
||||
const s = await update.status();
|
||||
assert.equal(s.installed, true);
|
||||
assert.equal(s.version, "1.0.0");
|
||||
assert.equal(s.latest, "2.0.0");
|
||||
assert.equal(s.updateAvailable, true);
|
||||
assert.equal(s.running, false);
|
||||
assert.equal(s.blocker, null);
|
||||
});
|
||||
|
||||
it("refuses when already on the latest version", async () => {
|
||||
update.__setSeams({ fetchLatest: async () => "1.0.0" });
|
||||
const s = await update.status();
|
||||
assert.equal(s.blocker.code, "EUPTODATE");
|
||||
await assert.rejects(update.start(), { code: "EUPTODATE" });
|
||||
});
|
||||
|
||||
it("refuses with a manual command when the prefix is not writable and sudo asks", async () => {
|
||||
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
|
||||
update.__setSeams({
|
||||
exec: async (file, args) =>
|
||||
file === "sudo" ? { code: 1, stdout: "", stderr: "a password is required" } : { code: 0, stdout: "", stderr: "" },
|
||||
});
|
||||
try {
|
||||
if (process.getuid?.() === 0) return; // root writes anyway
|
||||
await assert.rejects(update.start(), (e) => e.code === "ENOPERM" && /sudo npm install -g 9router@latest/.test(e.command));
|
||||
assert.equal(update.current(), null, "no job, so 9router was never stopped");
|
||||
} finally {
|
||||
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it("stops a running 9router (child included), updates it, and starts it again hidden", async () => {
|
||||
const port = await freePort();
|
||||
startFake(port);
|
||||
assert.ok(await until(() => portOpen(port)), "fake 9router came up");
|
||||
assert.ok(await until(() => fakePids().length >= 2), "with its child");
|
||||
const before = fakePids();
|
||||
|
||||
const job = await update.start();
|
||||
assert.equal(job.status, "running");
|
||||
assert.equal(job.managedBy, "process");
|
||||
const done = await finished();
|
||||
assert.equal(done.status, "done", done.log + JSON.stringify(done.steps));
|
||||
assert.deepEqual(done.steps.map((s) => s.status), ["done", "done", "done"]);
|
||||
assert.equal(done.from, "1.0.0");
|
||||
assert.equal(done.to, "9.9.9");
|
||||
for (const pid of before) assert.ok(!fakePids().includes(pid), `old pid ${pid} is gone`);
|
||||
assert.ok(await portOpen(port), "relaunched on the same port");
|
||||
// Relaunched hidden: tray mode, no browser, no self-update.
|
||||
const argv = fakePids()
|
||||
.map((p) => fs.readFileSync(`/proc/${p}/cmdline`, "utf8").split("\0"))
|
||||
.find((a) => a.includes(path.join(PKG_DIR, "cli.js")));
|
||||
for (const flag of ["--tray", "--no-browser", "--skip-update"]) assert.ok(argv.includes(flag), flag);
|
||||
});
|
||||
|
||||
it("leaves 9router off when it was not running", async () => {
|
||||
await update.start();
|
||||
const done = await finished();
|
||||
assert.equal(done.status, "done");
|
||||
assert.deepEqual(done.steps.map((s) => s.status), ["skipped", "done", "skipped"]);
|
||||
assert.equal(fakePids().length, 0);
|
||||
});
|
||||
|
||||
it("still relaunches the old version when npm fails", async () => {
|
||||
fs.writeFileSync(path.join(TMP, "npm-fail"), "");
|
||||
const port = await freePort();
|
||||
startFake(port);
|
||||
assert.ok(await until(() => portOpen(port)));
|
||||
await update.start();
|
||||
const done = await finished();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.deepEqual(done.steps.map((s) => s.status), ["done", "failed", "done"]);
|
||||
assert.match(done.log, /boom/);
|
||||
assert.ok(await portOpen(port));
|
||||
});
|
||||
|
||||
it("refuses a second start while one runs", async () => {
|
||||
const port = await freePort();
|
||||
startFake(port);
|
||||
assert.ok(await until(() => portOpen(port)));
|
||||
await update.start();
|
||||
await assert.rejects(update.start(), { code: "EBUSY" });
|
||||
await finished();
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes /api/ai-proxies/9router/update", () => {
|
||||
it("GET reports status, POST starts and answers 202, a refusal is a 409", async () => {
|
||||
const s = await (await fetch(`${BASE}/api/ai-proxies/9router/update`)).json();
|
||||
assert.equal(s.version, "1.0.0");
|
||||
const r = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
||||
assert.equal(r.status, 202);
|
||||
await finished();
|
||||
update.__setSeams({ fetchLatest: async () => "9.9.9" });
|
||||
const again = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal((await again.json()).error.code, "EUPTODATE");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user