From 5a2204c62f68ef72d6c5375d0cd9352a526b2c5d Mon Sep 17 00:00:00 2001 From: pyeom Date: Thu, 16 Jul 2026 01:33:35 +0000 Subject: [PATCH 1/4] feat: add CI/release workflows and e2e integration tests - ci.yml: build + test on node 20.x/22.x for pushes and PRs - release.yml: npm publish with provenance on GitHub release/tag - test/e2e.test.ts: spawns the real built daemon as a child process and exercises deploy, public fetch, auth, touch, protected deploys, and removal end-to-end Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 25 ++++ .github/workflows/release.yml | 33 ++++ README.md | 2 + test/e2e.test.ts | 274 ++++++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 test/e2e.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1cf4ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x, 22.x] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + - run: npm run build + - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dc63db6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + release: + types: [published] + push: + tags: + - "v*" + +permissions: + id-token: write + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + registry-url: https://registry.npmjs.org + + - run: npm ci + - run: npm run build + - run: npm test + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index d0da273..a861016 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # uptool +[![CI](https://github.com/pyeom/uptool/actions/workflows/ci.yml/badge.svg)](https://github.com/pyeom/uptool/actions/workflows/ci.yml) + Serve LLM-generated HTML files from your own machine via wildcard subdomains. Your LLM runs `uptool deploy` → gets back a URL → you open it anywhere. diff --git a/test/e2e.test.ts b/test/e2e.test.ts new file mode 100644 index 0000000..9453225 --- /dev/null +++ b/test/e2e.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as http from "node:http"; +import * as net from "node:net"; +import * as child_process from "node:child_process"; + +/** + * End-to-end suite: builds the CLI (if needed) and drives the real daemon as + * a child process — the only test file here that doesn't use in-process + * harnesses. Exercises deploy / fetch / auth / touch / protect / remove + * through the actual API + public HTTP servers. + */ + +const ROOT = path.join(__dirname, ".."); +const CLI_PATH = path.join(ROOT, "dist", "cli.js"); + +/** Find a free TCP port by binding to port 0 and reading it back. */ +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as net.AddressInfo; + const port = addr.port; + srv.close(() => resolve(port)); + }); + }); +} + +/** Poll until a TCP port accepts connections, or reject after timeoutMs. */ +function waitForPort(port: number, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const sock = net.connect(port, "127.0.0.1"); + sock.once("connect", () => { + sock.destroy(); + resolve(); + }); + sock.once("error", () => { + sock.destroy(); + if (Date.now() > deadline) { + reject(new Error(`Timed out waiting for port ${port}`)); + } else { + setTimeout(attempt, 100); + } + }); + }; + attempt(); + }); +} + +interface RawResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function request(opts: http.RequestOptions, body?: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request(opts, (res) => { + let raw = ""; + res.on("data", (c) => (raw += c)); + res.on("end", () => { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: raw }); + }); + }); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +describe("e2e", () => { + let tmpHome: string; + let child: child_process.ChildProcess; + let apiPort: number; + let pubPort: number; + let token: string; + const baseUrl = "e2e.local"; + + beforeAll(async () => { + if (!fs.existsSync(CLI_PATH)) { + child_process.execSync("npm run build", { cwd: ROOT, stdio: "inherit" }); + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-e2e-")); + const uptoolDir = path.join(tmpHome, ".uptool"); + fs.mkdirSync(uptoolDir, { recursive: true }); + + pubPort = await findFreePort(); + apiPort = await findFreePort(); + + const configToml = [ + `base_url = "${baseUrl}"`, + `port = ${pubPort}`, + `api_port = ${apiPort}`, + `ttl = "72h"`, + `storage_path = "${path.join(uptoolDir, "files").replace(/\\/g, "\\\\")}"`, + ].join("\n"); + fs.writeFileSync(path.join(uptoolDir, "config.toml"), configToml); + + child = child_process.spawn(process.execPath, [CLI_PATH, "serve", "--foreground"], { + env: { ...process.env, HOME: tmpHome }, + stdio: ["ignore", "pipe", "pipe"], + }); + + await waitForPort(apiPort); + await waitForPort(pubPort); + + // Token is written by the daemon on startup — wait for it to appear. + const tokenPath = path.join(uptoolDir, "token"); + const deadline = Date.now() + 10_000; + while (!fs.existsSync(tokenPath)) { + if (Date.now() > deadline) throw new Error("Timed out waiting for token file"); + await new Promise((r) => setTimeout(r, 50)); + } + token = fs.readFileSync(tokenPath, "utf8").trim(); + }, 20_000); + + afterAll(async () => { + if (child && !child.killed) { + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("exit", resolve); + setTimeout(resolve, 3000); + }); + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("deploys via POST /deploy with a valid token", async () => { + const payload = JSON.stringify({ html: "

Hello e2e

" }); + const res = await request( + { + hostname: "127.0.0.1", + port: apiPort, + path: "/deploy", + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + payload + ); + expect(res.status).toBe(200); + const data = JSON.parse(res.body) as { slug: string }; + expect(data.slug).toMatch(/^[a-z0-9]+$/); + slug = data.slug; + }); + + let slug: string; + + it("serves the deployed HTML on the public server via Host header", async () => { + const res = await request({ + hostname: "127.0.0.1", + port: pubPort, + path: "/", + method: "GET", + headers: { Host: `${slug}.${baseUrl}` }, + }); + expect(res.status).toBe(200); + expect(res.body).toContain("Hello e2e"); + }); + + it("rejects deploy without a token", async () => { + const payload = JSON.stringify({ html: "

nope

" }); + const res = await request( + { + hostname: "127.0.0.1", + port: apiPort, + path: "/deploy", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + payload + ); + expect(res.status).toBe(401); + }); + + it("renews expiry via touch", async () => { + const payload = JSON.stringify({ ttl: "7d" }); + const res = await request( + { + hostname: "127.0.0.1", + port: apiPort, + path: `/files/${slug}/touch`, + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + payload + ); + expect(res.status).toBe(200); + const data = JSON.parse(res.body) as { expires: number }; + expect(data.expires).toBeGreaterThan(Date.now()); + }); + + let protectedSlug: string; + + it("deploys a protected site requiring Basic Auth", async () => { + const payload = JSON.stringify({ html: "

secret e2e

", key: "sekret" }); + const res = await request( + { + hostname: "127.0.0.1", + port: apiPort, + path: "/deploy", + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + payload + ); + expect(res.status).toBe(200); + protectedSlug = (JSON.parse(res.body) as { slug: string }).slug; + + const unauth = await request({ + hostname: "127.0.0.1", + port: pubPort, + path: "/", + method: "GET", + headers: { Host: `${protectedSlug}.${baseUrl}` }, + }); + expect(unauth.status).toBe(401); + + const basic = Buffer.from(":sekret").toString("base64"); + const authed = await request({ + hostname: "127.0.0.1", + port: pubPort, + path: "/", + method: "GET", + headers: { + Host: `${protectedSlug}.${baseUrl}`, + Authorization: `Basic ${basic}`, + }, + }); + expect(authed.status).toBe(200); + expect(authed.body).toContain("secret e2e"); + }); + + it("removes a deployment and 404s on subsequent public fetch", async () => { + const res = await request({ + hostname: "127.0.0.1", + port: apiPort, + path: `/files/${slug}`, + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + + const fetched = await request({ + hostname: "127.0.0.1", + port: pubPort, + path: "/", + method: "GET", + headers: { Host: `${slug}.${baseUrl}` }, + }); + expect(fetched.status).toBe(404); + }); +}); From 3cf53225e5b075a57e1b85ca34b0f370b2ce8da1 Mon Sep 17 00:00:00 2001 From: pyeom Date: Thu, 16 Jul 2026 01:35:16 +0000 Subject: [PATCH 2/4] feat: local admin web UI (`uptool admin`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 100% local, token-authenticated admin page served by the internal API at GET /admin?token=. Lists deployments with slug/name/file, relative created/expiry times, a lock indicator for protected deploys, per-row Delete, and a preview link — auto-refreshing every 10s, inline CSS/JS only (no CDN). Factors safeEqual() out of isAuthorized() and reuses it for the query-param token check without bypassing the Host loopback guard. Co-Authored-By: Claude Fable 5 --- README.md | 13 ++++ src/cli.ts | 6 ++ src/commands/admin.ts | 26 ++++++++ src/server/admin.ts | 146 ++++++++++++++++++++++++++++++++++++++++++ src/server/api.ts | 36 +++++++++-- test/api.test.ts | 34 +++++++++- 6 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 src/commands/admin.ts create mode 100644 src/server/admin.ts diff --git a/README.md b/README.md index d0da273..059f1e8 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,19 @@ uptool list uptool rm x7k2mq ``` +### Admin page + +```bash +uptool admin +``` + +Opens a 100% local, token-authenticated web UI (served by the internal API on +`127.0.0.1:`, no CORS, no external assets or CDNs) listing every +deployment — slug, name, filename, created/expires as relative times, a lock +icon for protected deploys, a preview link to the public URL, and a Delete +button per row. Auto-refreshes every 10s. The token is passed once in the URL +and immediately scrubbed from the browser's address bar. + ### Daemon control ```bash diff --git a/src/cli.ts b/src/cli.ts index f3b8086..22fa461 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { statusCommand } from "./commands/status.js"; import { installServiceCommand } from "./commands/install-service.js"; import { touchCommand } from "./commands/touch.js"; import { openCommand } from "./commands/open.js"; +import { adminCommand } from "./commands/admin.js"; import { rollbackCommand } from "./commands/rollback.js"; import { mcpCommand } from "./commands/mcp.js"; import { @@ -117,6 +118,11 @@ program .description("Reconfigure all settings interactively") .action(() => configCommand()); +program + .command("admin") + .description("Open the local admin web UI (100% local, token-authenticated)") + .action(() => adminCommand()); + program .command("mcp") .description("Start MCP server (stdio, for Claude Code integration)") diff --git a/src/commands/admin.ts b/src/commands/admin.ts new file mode 100644 index 0000000..9d322b5 --- /dev/null +++ b/src/commands/admin.ts @@ -0,0 +1,26 @@ +import * as fs from "node:fs"; +import * as child_process from "node:child_process"; +import { loadConfig, tokenPath } from "../config/index.js"; + +export async function adminCommand(): Promise { + const config = loadConfig(); + + if (!fs.existsSync(tokenPath())) { + console.error("Auth token not found. Run: uptool init"); + process.exitCode = 1; + return; + } + const token = fs.readFileSync(tokenPath(), "utf8").trim(); + + const url = `http://127.0.0.1:${config.api_port}/admin?token=${token}`; + console.log(`✓ Admin page: ${url}`); + + const launcher = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + + child_process.spawn(launcher, [url], { stdio: "ignore", detached: true }).unref(); +} diff --git a/src/server/admin.ts b/src/server/admin.ts new file mode 100644 index 0000000..4ebd0c5 --- /dev/null +++ b/src/server/admin.ts @@ -0,0 +1,146 @@ +import { Config } from "../config/index.js"; + +/** + * Render the self-contained local admin page. Inline CSS+JS only — no + * external assets, no CDN (selfhosted philosophy). The token is embedded in + * a + + +`; +} diff --git a/src/server/api.ts b/src/server/api.ts index 248df89..07068c7 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -2,6 +2,7 @@ import * as http from "node:http"; import * as crypto from "node:crypto"; import { Config } from "../config/index.js"; import { ManifestStore, stripMarkdownFences, isValidName } from "../storage/index.js"; +import { renderAdminPage } from "./admin.js"; const DEFAULT_ENTRY = "index.html"; @@ -47,12 +48,22 @@ function isAllowedApiHost(host: string): boolean { return name === "127.0.0.1" || name === "localhost" || name === "[::1]"; } +/** Constant-time comparison of two strings (byte length + content). */ +function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + /** Constant-time comparison of the Authorization header against the token. */ function isAuthorized(req: http.IncomingMessage, token: string): boolean { - const header = Buffer.from(req.headers.authorization ?? ""); - const expected = Buffer.from(`Bearer ${token}`); - if (header.length !== expected.length) return false; - return crypto.timingSafeEqual(header, expected); + return safeEqual(req.headers.authorization ?? "", `Bearer ${token}`); +} + +function html(res: http.ServerResponse, status: number, body: string): void { + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }); + res.end(body); } export function createApiServer( @@ -90,6 +101,23 @@ async function handleApiRequest( return; } + // ------------------------------------------------------------------ + // GET /admin?token= — local admin web UI. Auth comes from the + // query param (a page load can't set an Authorization header), checked + // with the same timing-safe comparison as the header-based auth below. + // The Host loopback check above still applies — this route does not + // bypass it. + // ------------------------------------------------------------------ + if (req.method === "GET" && url.pathname === "/admin") { + const provided = url.searchParams.get("token") ?? ""; + if (!safeEqual(provided, token)) { + html(res, 401, "401Unauthorized"); + return; + } + html(res, 200, renderAdminPage(config)); + return; + } + // Validate bearer token (generated at ~/.uptool/token by init/serve) if (!isAuthorized(req, token)) { json(res, 401, { diff --git a/test/api.test.ts b/test/api.test.ts index e7443b4..6c9e00d 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -22,7 +22,7 @@ function apiRequest( urlPath: string, body?: unknown, token: string | null = TEST_TOKEN -): Promise<{ status: number; data: unknown }> { +): Promise<{ status: number; data: unknown; contentType?: string }> { return new Promise((resolve, reject) => { const addr = server.address() as { port: number }; const payload = body ? JSON.stringify(body) : undefined; @@ -41,12 +41,13 @@ function apiRequest( }, (res) => { let raw = ""; + const contentType = res.headers["content-type"]; res.on("data", (c) => (raw += c)); res.on("end", () => { try { - resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); + resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw), contentType }); } catch { - resolve({ status: res.statusCode ?? 0, data: raw }); + resolve({ status: res.statusCode ?? 0, data: raw, contentType }); } }); } @@ -334,4 +335,31 @@ describe("API server", () => { const { status } = await apiRequest(server, "GET", "/unknown"); expect(status).toBe(404); }); + + // ------------------------------------------------------------------------- + // Admin page + // ------------------------------------------------------------------------- + + it("rejects /admin without a token", async () => { + const { status } = await apiRequest(server, "GET", "/admin", undefined, null); + expect(status).toBe(401); + }); + + it("rejects /admin with a bad token", async () => { + const { status } = await apiRequest(server, "GET", "/admin?token=nope", undefined, null); + expect(status).toBe(401); + }); + + it("serves the admin page with the correct token in the query string", async () => { + const { status, data, contentType } = await apiRequest( + server, + "GET", + `/admin?token=${TEST_TOKEN}`, + undefined, + null + ); + expect(status).toBe(200); + expect(contentType).toContain("text/html"); + expect(String(data)).toContain("uptool"); + }); }); From b60533f984e274f50bfdb8b87c1b34fe981d9c56 Mon Sep 17 00:00:00 2001 From: pyeom Date: Thu, 16 Jul 2026 01:35:57 +0000 Subject: [PATCH 3/4] feat: deploy --qr and --watch flags --qr renders a terminal QR code for the deployed URL (per file, on multi-deploy). --watch keeps the process alive and redeploys in place (same slug) on source change, debounced 300ms, for a single file or directory bundle; restricted to exactly one target and incompatible with stdin. Co-Authored-By: Claude Fable 5 --- README.md | 23 +++++++++++++ package-lock.json | 17 +++++++++ package.json | 2 ++ src/cli.ts | 6 ++++ src/commands/deploy.ts | 78 ++++++++++++++++++++++++++++++++++++++++-- src/lib/watch.ts | 19 ++++++++++ test/watch.test.ts | 58 +++++++++++++++++++++++++++++++ 7 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/lib/watch.ts create mode 100644 test/watch.test.ts diff --git a/README.md b/README.md index d0da273..e42ddfb 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,29 @@ uptool deploy v2.html --update x7k2mq # same URL, new content ``` +### QR code + +Print a scannable QR code for the URL, handy for pulling a deploy up on a phone: + +```bash +uptool deploy dashboard.html --qr +``` + +With multiple files in one invocation, a QR is printed after each URL. + +### Watch and redeploy + +Keep the process running and redeploy in place whenever the source changes: + +```bash +uptool deploy dashboard.html --watch +# ✓ http://x7k2mq.mydev.com +# Watching dashboard.html for changes... (Ctrl-C to stop) +# ↻ redeployed http://x7k2mq.mydev.com (14:32:07) +``` + +Works on a single file or a directory bundle, and combines with `--qr` (printed once, on the first deploy). Changes are debounced 300ms. `--watch` requires exactly one file/directory argument and can't be used with stdin. Stop with Ctrl-C. + ### Protected deployments Require a key to view (dashboards with semi-private data, drafts): diff --git a/package-lock.json b/package-lock.json index 138d82a..6c9ca20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "commander": "^12.1.0", + "qrcode-terminal": "^0.12.0", "smol-toml": "^1.3.1", "ws": "^8.21.0" }, @@ -18,6 +19,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "tsup": "^8.3.0", "typescript": "^5.6.0", @@ -1295,6 +1297,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2194,6 +2203,14 @@ } } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/package.json b/package.json index e71682a..b0826a7 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ }, "dependencies": { "commander": "^12.1.0", + "qrcode-terminal": "^0.12.0", "smol-toml": "^1.3.1", "ws": "^8.21.0" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "tsup": "^8.3.0", "typescript": "^5.6.0", diff --git a/src/cli.ts b/src/cli.ts index f3b8086..c5edf26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,12 @@ program "--protect [key]", "Require Basic Auth to view (autogenerates a key when none is given)" ) + .option("--qr", "Print a QR code for the public URL", false) + .option( + "--watch", + "Watch the file/directory and redeploy on change (single target only)", + false + ) .action((files, opts) => deployCommand(files, opts)); program diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index c7227a9..7ccffae 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,9 +1,11 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; -import { loadConfig, publicUrl, parseTtlMs } from "../config/index.js"; +import qrcode from "qrcode-terminal"; +import { loadConfig, publicUrl, parseTtlMs, type Config } from "../config/index.js"; import { callApi } from "../lib/api-client.js"; import { validateBundlePath } from "../storage/index.js"; +import { debounce, formatTime } from "../lib/watch.js"; function readStdin(): Promise { return new Promise((resolve) => { @@ -88,9 +90,65 @@ async function buildBody( return { html, filename: path.basename(filePath) }; } +/** Watch a file or directory and redeploy (in place, by slug) on change. */ +function watchAndRedeploy( + target: string, + slug: string, + key: string | undefined, + config: Config +): void { + console.log(`\nWatching ${target} for changes... (Ctrl-C to stop)`); + + const redeploy = debounce(async () => { + try { + const body = await buildBody(target); + body.slug = slug; + if (key) body.key = key; + const result = await callApi<{ slug?: string; error?: string }>( + config.api_port, + "POST", + "/deploy", + body + ); + if (result.error) throw new Error(result.error); + const url = publicUrl(config, result.slug ?? slug); + console.log(`↻ redeployed ${url} (${formatTime()})`); + } catch (err) { + console.error(`Error redeploying: ${(err as Error).message}`); + } + }, 300); + + const isDir = fs.statSync(target).isDirectory(); + + if (isDir) { + try { + fs.watch(target, { recursive: true }, () => redeploy()); + return; + } catch { + // Recursive fs.watch unavailable on this platform/Node version — fall + // back to watching each file individually. + for (const { full } of walkDir(target, target)) { + try { + fs.watch(full, () => redeploy()); + } catch { + // ignore files that can't be watched + } + } + } + } else { + fs.watch(target, () => redeploy()); + } +} + export async function deployCommand( filePaths: string[], - opts: { update?: string; name?: string; protect?: string | boolean } + opts: { + update?: string; + name?: string; + protect?: string | boolean; + qr?: boolean; + watch?: boolean; + } ): Promise { const config = loadConfig(); @@ -100,6 +158,11 @@ export async function deployCommand( process.exit(1); } + if (opts.watch && filePaths.length !== 1) { + console.error("--watch requires exactly one file or directory argument (no stdin)."); + process.exit(1); + } + // --protect: true = autogenerate a key, string = user-supplied key const key = opts.protect === true @@ -111,6 +174,8 @@ export async function deployCommand( const expiry = ttlMs > 0 ? ` (expires in ${config.ttl})` : ""; let anyError = false; + let watchTarget: string | undefined; + let watchSlug: string | undefined; for (const filePath of targets) { const body = await buildBody(filePath); @@ -131,6 +196,11 @@ export async function deployCommand( const url = publicUrl(config, slug); console.log(`✓ ${url}${expiry}`); if (key) console.log(` key: ${key} (Basic Auth password — any username)`); + if (opts.qr) qrcode.generate(url, { small: true }); + if (opts.watch && filePath) { + watchTarget = filePath; + watchSlug = slug; + } } catch (err) { console.error(`Error deploying ${filePath ?? "stdin"}: ${(err as Error).message}`); anyError = true; @@ -138,4 +208,8 @@ export async function deployCommand( } if (anyError) process.exit(1); + + if (opts.watch && watchTarget && watchSlug) { + watchAndRedeploy(watchTarget, watchSlug, key, config); + } } diff --git a/src/lib/watch.ts b/src/lib/watch.ts new file mode 100644 index 0000000..6293607 --- /dev/null +++ b/src/lib/watch.ts @@ -0,0 +1,19 @@ +/** + * Small helpers used by `deploy --watch`. Kept separate from deploy.ts so + * they're easy to unit test without spinning up fs.watch or the API client. + */ + +/** Debounce: collapse rapid-fire calls into one, `ms` after the last call. */ +export function debounce void>(fn: T, ms: number): T { + let timer: ReturnType | undefined; + return ((...args: Parameters) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +} + +/** Format a Date as local HH:MM:SS, used in the `↻ redeployed ...` log line. */ +export function formatTime(d: Date = new Date()): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/test/watch.test.ts b/test/watch.test.ts new file mode 100644 index 0000000..9b7516f --- /dev/null +++ b/test/watch.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { debounce, formatTime } from "../src/lib/watch.js"; + +describe("debounce", () => { + it("collapses rapid calls into a single invocation", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 300); + + debounced(); + debounced(); + debounced(); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(299); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("restarts the timer on each call", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 300); + + debounced(); + vi.advanceTimersByTime(200); + debounced(); + vi.advanceTimersByTime(200); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("passes through the latest arguments", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 300); + + debounced("first"); + debounced("second"); + vi.advanceTimersByTime(300); + + expect(fn).toHaveBeenCalledWith("second"); + vi.useRealTimers(); + }); +}); + +describe("formatTime", () => { + it("formats as zero-padded HH:MM:SS", () => { + expect(formatTime(new Date(2026, 0, 1, 3, 5, 9))).toBe("03:05:09"); + expect(formatTime(new Date(2026, 0, 1, 23, 59, 0))).toBe("23:59:00"); + }); +}); From 91afdd7ad4df361a254aa90b3f1cd3fddb57872f Mon Sep 17 00:00:00 2001 From: pyeom Date: Thu, 16 Jul 2026 01:57:19 +0000 Subject: [PATCH 4/4] Fix storage quota reclaim on update, hide key from list, secure cache - Reclaim freed bytes when replacing content without versioning - Hide access key from list() output; expose only protected flag - Serve protected content with private, no-store Cache-Control - Validate key type on API update (must be string) - URL-encode slug in touch endpoint - Escape paths in systemd unit file generation - Clean up stale PID file on status - Re-enforce 0600 permissions on existing token - Better error for empty token file --- .claude/worktrees/agent-a03a93fd17ee7f4bd | 1 + .claude/worktrees/agent-a26866f310f7301e9 | 1 + .claude/worktrees/agent-a877d7b21d08dcf4f | 1 + .claude/worktrees/agent-ab23fba714113176a | 1 + src/commands/init.ts | 19 +++++++-- src/commands/install-service.ts | 9 +++- src/commands/status.ts | 30 ++++++++++++-- src/commands/touch.ts | 2 +- src/config/index.ts | 10 ++++- src/lib/api-client.ts | 6 ++- src/server/admin.ts | 2 +- src/server/api.ts | 12 +++++- src/server/public.ts | 11 +++-- src/storage/index.ts | 38 +++++++++++++---- test/storage.test.ts | 50 +++++++++++++++++++++++ 15 files changed, 169 insertions(+), 24 deletions(-) create mode 160000 .claude/worktrees/agent-a03a93fd17ee7f4bd create mode 160000 .claude/worktrees/agent-a26866f310f7301e9 create mode 160000 .claude/worktrees/agent-a877d7b21d08dcf4f create mode 160000 .claude/worktrees/agent-ab23fba714113176a diff --git a/.claude/worktrees/agent-a03a93fd17ee7f4bd b/.claude/worktrees/agent-a03a93fd17ee7f4bd new file mode 160000 index 0000000..d4548e8 --- /dev/null +++ b/.claude/worktrees/agent-a03a93fd17ee7f4bd @@ -0,0 +1 @@ +Subproject commit d4548e85f09aecbf5e99fbdf9b993f060e2918cf diff --git a/.claude/worktrees/agent-a26866f310f7301e9 b/.claude/worktrees/agent-a26866f310f7301e9 new file mode 160000 index 0000000..5a2204c --- /dev/null +++ b/.claude/worktrees/agent-a26866f310f7301e9 @@ -0,0 +1 @@ +Subproject commit 5a2204c62f68ef72d6c5375d0cd9352a526b2c5d diff --git a/.claude/worktrees/agent-a877d7b21d08dcf4f b/.claude/worktrees/agent-a877d7b21d08dcf4f new file mode 160000 index 0000000..b60533f --- /dev/null +++ b/.claude/worktrees/agent-a877d7b21d08dcf4f @@ -0,0 +1 @@ +Subproject commit b60533f984e274f50bfdb8b87c1b34fe981d9c56 diff --git a/.claude/worktrees/agent-ab23fba714113176a b/.claude/worktrees/agent-ab23fba714113176a new file mode 160000 index 0000000..3cf5322 --- /dev/null +++ b/.claude/worktrees/agent-ab23fba714113176a @@ -0,0 +1 @@ +Subproject commit 3cf53225e5b075a57e1b85ca34b0f370b2ce8da1 diff --git a/src/commands/init.ts b/src/commands/init.ts index 2466e74..7f4efe8 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,15 @@ +import * as fs from "node:fs"; import * as readline from "node:readline"; import * as path from "node:path"; import * as os from "node:os"; -import { Config, DEFAULT_CONFIG, saveConfig, configPath, loadOrGenerateToken } from "../config/index.js"; +import { + Config, + DEFAULT_CONFIG, + saveConfig, + configPath, + loadOrGenerateToken, + tokenPath, +} from "../config/index.js"; function prompt(rl: readline.Interface, question: string, fallback: string): Promise { return new Promise((resolve) => { @@ -37,9 +45,14 @@ export async function initCommand(): Promise { }; saveConfig(config); - const token = loadOrGenerateToken(); + const tokenExisted = fs.existsSync(tokenPath()); + loadOrGenerateToken(); console.log(`\n✓ Config saved to ${configPath()}`); - console.log(`✓ Auth token generated (stored in ~/.uptool/token)`); + console.log( + tokenExisted + ? `✓ Auth token ready (existing ~/.uptool/token kept)` + : `✓ Auth token generated (stored in ~/.uptool/token)` + ); console.log(`\nDNS setup required:`); console.log(` Add a wildcard A record: *.${base_url} → `); console.log(` If behind a router, forward port ${config.port} to this machine.`); diff --git a/src/commands/install-service.ts b/src/commands/install-service.ts index 07479d4..3f278f8 100644 --- a/src/commands/install-service.ts +++ b/src/commands/install-service.ts @@ -12,9 +12,14 @@ export function installServiceCommand(): void { process.exit(1); } - const node = process.execPath; + // Quote + escape for a systemd ExecStart value: backslashes, double quotes, + // and % specifiers (%% is a literal percent in unit files). + const q = (s: string): string => + `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`; + + const node = q(process.execPath); // Resolve symlinks (npm global bin is usually a symlink into node_modules) - const cli = fs.realpathSync(process.argv[1]); + const cli = q(fs.realpathSync(process.argv[1])); const unit = `[Unit] Description=uptool — selfhosted HTML serving daemon diff --git a/src/commands/status.ts b/src/commands/status.ts index 22ce98f..778f80d 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -64,19 +64,43 @@ export async function statusCommand(opts: { json?: boolean } = {}): Promise( config.api_port, "POST", - `/files/${slug}/touch`, + `/files/${encodeURIComponent(slug)}/touch`, { ttl } ); if (result.error) throw new Error(result.error); diff --git a/src/config/index.ts b/src/config/index.ts index f198958..c0a58ba 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -83,7 +83,15 @@ export function loadOrGenerateToken(): string { const p = tokenPath(); if (fs.existsSync(p)) { const existing = fs.readFileSync(p, "utf8").trim(); - if (existing) return existing; + if (existing) { + // Re-enforce 0600 — the file may have been created or loosened externally + try { + fs.chmodSync(p, 0o600); + } catch { + // best-effort; reading it already proved we own access + } + return existing; + } } // Generate new token: 32 random bytes hex-encoded (64 chars) const token = crypto.randomBytes(32).toString("hex"); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 7aad87f..b34cf7c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -19,7 +19,11 @@ function getToken(): string { if (!fs.existsSync(p)) { throw new ApiError("Auth token not found. Run: uptool init"); } - return fs.readFileSync(p, "utf8").trim(); + const token = fs.readFileSync(p, "utf8").trim(); + if (!token) { + throw new ApiError("Auth token not found. Run: uptool init"); + } + return token; } export function callApi( diff --git a/src/server/admin.ts b/src/server/admin.ts index 4ebd0c5..420ac89 100644 --- a/src/server/admin.ts +++ b/src/server/admin.ts @@ -104,7 +104,7 @@ export function renderAdminPage(config: Config): string { var url = publicUrl(f.slug); var hits = (f.hits !== undefined) ? ' · ' + f.hits + ' hits' : ""; return '' + - '' + escapeHtml(f.slug) + (f.key ? '🔒' : '') + '' + + '' + escapeHtml(f.slug) + (f.protected ? '🔒' : '') + '' + '' + escapeHtml(f.name || "") + '' + '' + escapeHtml(f.filename) + hits + '' + '' + relTime(f.created) + '' + diff --git a/src/server/api.ts b/src/server/api.ts index 07068c7..cf5be33 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -162,6 +162,12 @@ async function handleApiRequest( key?: string; }; + // key must be a string when present (undefined keeps, "" removes) + if (parsed.key !== undefined && typeof parsed.key !== "string") { + json(res, 400, { error: "'key' must be a string" }); + return; + } + // Validate name if provided if (parsed.name && !isValidName(parsed.name)) { json(res, 400, { @@ -258,7 +264,11 @@ async function handleApiRequest( try { bodyStr = await readBody(req, config.max_body_bytes); } catch (err) { - json(res, 400, { error: String(err) }); + if ((err as NodeJS.ErrnoException).code === "TOO_LARGE") { + json(res, 413, { error: "Request entity too large" }); + } else { + json(res, 400, { error: String(err) }); + } return; } try { diff --git a/src/server/public.ts b/src/server/public.ts index fdf53b3..4d13e19 100644 --- a/src/server/public.ts +++ b/src/server/public.ts @@ -138,6 +138,7 @@ function handleRequest( // every asset request within the bundle (CSS/JS/images). const resolved = store.resolveSlug(slugOrName); const manifestEntry = resolved ? store.getEntry(resolved) : null; + const isProtected = Boolean(manifestEntry?.key); if (manifestEntry?.key && !basicAuthOk(req, manifestEntry.key)) { sendErrorPage(res, 401, config, "Authorization required", { "WWW-Authenticate": 'Basic realm="uptool"', @@ -160,9 +161,13 @@ function handleRequest( const headers: http.OutgoingHttpHeaders = { "Content-Type": result.contentType, - // Cache HTML with no-cache (LLM iterate loop — always fresh); - // long cache for static assets - "Cache-Control": isHtml ? "no-cache" : "public, max-age=3600", + // Protected content must never land in a shared cache. Otherwise: + // no-cache HTML (LLM iterate loop — always fresh), long cache for assets. + "Cache-Control": isProtected + ? "private, no-store" + : isHtml + ? "no-cache" + : "public, max-age=3600", }; applySecurityHeaders(headers, config); diff --git a/src/storage/index.ts b/src/storage/index.ts index 517d49b..a3b3561 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -282,8 +282,13 @@ export class ManifestStore extends EventEmitter { return this.manifest[slug] ?? null; } - list(): Array { - return Object.entries(this.manifest).map(([slug, entry]) => ({ slug, ...entry })); + list(): Array & { slug: string; protected: boolean }> { + // Never serialize the access key — list() feeds GET /files (CLI, MCP, + // admin page). Expose only a `protected` flag. + return Object.entries(this.manifest).map(([slug, entry]) => { + const { key, ...rest } = entry; + return { slug, ...rest, protected: Boolean(key) }; + }); } // ------------------------------------------------------------------------- @@ -356,11 +361,14 @@ export class ManifestStore extends EventEmitter { const slug = this.resolveSlug(slugOrName); if (!slug) throw new Error(`Slug not found: ${slugOrName}`); - this._checkLimits(html, files); - const existing = this.manifest[slug]; const slugDir = path.join(this.storageDir, slug); + // With versioning off the current content is replaced outright, so its + // bytes free up; with versioning on it moves into .versions and stays. + const reclaimed = this.maxVersions > 0 ? 0 : this._slugContentSize(slugDir); + this._checkLimits(html, files, reclaimed); + // Save current state as a version before overwriting if (this.maxVersions > 0) { this._saveVersion(slug, slugDir); @@ -563,7 +571,8 @@ export class ManifestStore extends EventEmitter { */ private _checkLimits( html: string | null, - files: Record | null + files: Record | null, + reclaimedBytes = 0 ): void { if (this.maxFileSize <= 0 && this.maxTotalStorage <= 0) return; @@ -587,8 +596,9 @@ export class ManifestStore extends EventEmitter { incoming = size; } else if (files) { for (const [relPath, base64Content] of Object.entries(files)) { - // Decoded size from base64 length — avoids decoding just to measure - const size = Math.floor((base64Content.length * 3) / 4); + // Decoded size from base64 length (minus padding) — exact without decoding + const padding = base64Content.endsWith("==") ? 2 : base64Content.endsWith("=") ? 1 : 0; + const size = Math.floor((base64Content.length * 3) / 4) - padding; if (this.maxFileSize > 0 && size > this.maxFileSize) { const err = new Error( `File too large: "${relPath}" is ${fmt(size)}, exceeds max_file_size (${fmt(this.maxFileSize)})` @@ -601,7 +611,7 @@ export class ManifestStore extends EventEmitter { } if (this.maxTotalStorage > 0) { - const used = dirSize(this.storageDir); + const used = Math.max(0, dirSize(this.storageDir) - reclaimedBytes); if (used + incoming > this.maxTotalStorage) { const err = new Error( `Storage quota exceeded: ${fmt(used)} used + ${fmt(incoming)} incoming exceeds max_total_storage (${fmt(this.maxTotalStorage)}). Remove old deployments with: uptool rm ` @@ -612,6 +622,18 @@ export class ManifestStore extends EventEmitter { } } + /** Size of a slug's current content, excluding the .versions archive. */ + private _slugContentSize(slugDir: string): number { + if (!fs.existsSync(slugDir)) return 0; + let total = 0; + for (const item of fs.readdirSync(slugDir, { withFileTypes: true })) { + if (item.name === ".versions") continue; + const full = path.join(slugDir, item.name); + total += item.isDirectory() ? dirSize(full) : fs.statSync(full).size; + } + return total; + } + private _writeBundleFiles( slugDir: string, html: string | null, diff --git a/test/storage.test.ts b/test/storage.test.ts index dbc021c..7cfad67 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -469,6 +469,56 @@ describe("ManifestStore", () => { fs.rmSync(dir, { recursive: true }); }); + it("allows an equal-sized replacement at the quota when versioning is off", () => { + const dir = tmpDir + "-lim7"; + const limited = new ManifestStore(dir, { + ttl: "72h", + max_versions: 0, // replaced content frees its bytes + max_total_storage: 300, + }); + const slug = limited.store("a".repeat(250), null, "index.html", "a.html"); + expect(() => + limited.update(slug, "b".repeat(250), null, "index.html", "a.html") + ).not.toThrow(); + // Growing past the quota still fails + expect(() => + limited.update(slug, "c".repeat(400), null, "index.html", "a.html") + ).toThrow(/max_total_storage/); + fs.rmSync(dir, { recursive: true }); + }); + + it("counts existing content against the quota when versioning keeps it", () => { + const dir = tmpDir + "-lim8"; + const limited = new ManifestStore(dir, { + ttl: "72h", + max_versions: 2, // old content archived into .versions — not freed + max_total_storage: 300, + }); + const slug = limited.store("a".repeat(250), null, "index.html", "a.html"); + expect(() => + limited.update(slug, "b".repeat(250), null, "index.html", "a.html") + ).toThrow(/max_total_storage/); + fs.rmSync(dir, { recursive: true }); + }); + + it("measures exact decoded base64 sizes (padding-aware)", () => { + const dir = tmpDir + "-lim9"; + const limited = new ManifestStore(dir, { + ttl: "72h", + max_versions: 0, + max_file_size: 1, + }); + // "YQ==" decodes to exactly 1 byte ("a") — must pass a 1-byte limit + expect(() => + limited.store(null, { "index.html": "YQ==" }, "index.html", "a.html") + ).not.toThrow(); + // 2 bytes must fail + expect(() => + limited.store(null, { "index.html": Buffer.from("ab").toString("base64") }, "index.html", "b.html") + ).toThrow(/max_file_size/); + fs.rmSync(dir, { recursive: true }); + }); + it("applies limits on update too", () => { const dir = tmpDir + "-lim6"; const limited = new ManifestStore(dir, {