From 7e77ee87fec09aecc14be38069c01861d23a14d5 Mon Sep 17 00:00:00 2001 From: pyeom Date: Tue, 4 Aug 2026 01:55:39 +0000 Subject: [PATCH 1/2] v0.3.0: add view counts, logs, and list --json - Removed MCP server and admin web UI (breaking changes) - Fixed multi-byte character corruption, WebSocket auth, HTTP method handling --- .github/workflows/ci.yml | 4 +- .gitignore | 1 + CHANGELOG.md | 53 ++ README.md | 38 +- package-lock.json | 621 +++++++++++++++------- package.json | 9 +- plan-uptool-hardening-observabilidad.html | 438 +++++++++++++++ src/cli.ts | 26 +- src/commands/admin.ts | 26 - src/commands/deploy.ts | 16 +- src/commands/init.ts | 1 + src/commands/list.ts | 56 +- src/commands/logs.ts | 69 +++ src/commands/mcp.ts | 241 --------- src/commands/rm.ts | 2 +- src/commands/rollback.ts | 2 +- src/commands/serve.ts | 2 +- src/commands/status.ts | 17 +- src/lib/api-client.ts | 7 +- src/lib/basic-auth.ts | 23 + src/lib/slug.ts | 11 + src/server/admin.ts | 146 ----- src/server/api.ts | 29 +- src/server/public.ts | 49 +- src/server/ws.ts | 56 +- src/storage/index.ts | 35 +- test/api-client.test.ts | 235 ++++++++ test/api.test.ts | 90 +++- test/cli.test.ts | 404 ++++++++++++++ test/config.test.ts | 191 ++++++- test/deploy.test.ts | 306 +++++++++++ test/global-setup.ts | 30 ++ test/helpers.test.ts | 57 ++ test/helpers.ts | 282 ++++++++++ test/public.test.ts | 369 ++++++++++++- test/status.test.ts | 454 ++++++++++++++++ test/storage.test.ts | 223 +++++++- test/ws.test.ts | 190 +++++++ vitest.config.ts | 32 +- 39 files changed, 4065 insertions(+), 776 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 plan-uptool-hardening-observabilidad.html delete mode 100644 src/commands/admin.ts create mode 100644 src/commands/logs.ts delete mode 100644 src/commands/mcp.ts create mode 100644 src/lib/basic-auth.ts create mode 100644 src/lib/slug.ts delete mode 100644 src/server/admin.ts create mode 100644 test/api-client.test.ts create mode 100644 test/cli.test.ts create mode 100644 test/deploy.test.ts create mode 100644 test/global-setup.ts create mode 100644 test/helpers.test.ts create mode 100644 test/helpers.ts create mode 100644 test/status.test.ts create mode 100644 test/ws.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1cf4ad..2ba67c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,7 @@ jobs: cache: npm - run: npm ci + - run: npm run typecheck - run: npm run build - - run: npm test + # Runs the suite and enforces the coverage thresholds in vitest.config.ts. + - run: npm run test:coverage diff --git a/.gitignore b/.gitignore index 1ab415f..edc5d77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tgz +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1ad72da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## 0.3.0 - 2026-08-01 + +### Added + +- **View counts.** Each deployment tracks how many times its page was actually + viewed (HTML page loads only — bundle assets, 404s and HEAD requests don't + count) plus when it was last seen. Shown in `uptool list`, persisted across + daemon restarts, and kept when you redeploy with `--update`. +- **`uptool list --json`** — machine-readable output for scripts and for the + LLM agent driving uptool. Access keys of protected deployments are never + included. +- **`uptool logs`** — print the daemon log without going through + `uptool status`. Supports `-n ` (default 50) and `-f` to follow, which + survives log rotation. Reads the file directly, so it works even when the + daemon is down — which is when you need it. + +### Fixed + +- **Request bodies with multi-byte characters over ~64 KB were silently + corrupted.** The API decoded each TCP chunk separately, so an accented letter + or emoji split across a chunk boundary became a replacement character. +- **`uptool deploy --name ` printed the random slug URL** instead of the + name-based one, even though the deployment was reachable at the name. +- **The live-reload WebSocket ignored `--protect`.** A protected deployment's + reload socket accepted any client, leaking the fact that a private deployment + had been updated. It now requires the same credentials as the page, and dead + sockets are reaped by a heartbeat instead of accumulating. +- **The public server answered every HTTP method as if it were GET**, including + returning a body for `HEAD`. Now only GET and HEAD are served; anything else + gets a 405. +- **`uptool status` mangled log lines at the 16 KB read boundary**, truncating a + line and turning a split multi-byte character into `�`. +- A failed background flush of the manifest could take down the daemon as an + uncaught exception; it's now logged, with the in-memory state authoritative. + +### Removed (BREAKING) + +- **MCP server** (`uptool mcp`). uptool is CLI-only now — an agent that already + has a shell doesn't need a second MCP surface exposing the same commands. + Replacement: call the CLI directly (`uptool deploy`, `uptool list`, etc.). +- **Admin web UI** (`uptool admin` and the `GET /admin` API route). Same + reasoning — one surface to keep in sync instead of two. Use `uptool list`, + `uptool rm`, `uptool rollback` from the CLI instead. + +## 0.2.0 + +Previous release. diff --git a/README.md b/README.md index 742044a..9aa91fe 100644 --- a/README.md +++ b/README.md @@ -198,28 +198,31 @@ uptool touch x7k2mq9a # renew with the configured default ttl ```bash uptool list -# x7k2mq http://x7k2mq.mydev.com [dashboard.html] expires in 71h 45m +# x7k2mq http://x7k2mq.mydev.com [dashboard.html] expires in 71h 45m 3 hits · last seen 12m ago +# a9f3kd2p http://a9f3kd2p.mydev.com [draft.html] expires in 20h 3m never viewed ``` -### Remove a deployment +View counts track HTML page loads only — assets inside a bundle, 404s and +`HEAD` requests don't inflate the number. They survive daemon restarts and +`--update` redeploys. + +For scripts (or for the LLM driving uptool), `--json` emits the full record: ```bash -uptool rm x7k2mq +uptool list --json +# [{"slug":"x7k2mq","url":"http://x7k2mq.mydev.com","filename":"dashboard.html", +# "created":1754006400000,"expires":1754265600000,"hits":3, +# "last_seen":1754092800000,"protected":false}] ``` -### Admin page +Access keys of protected deployments are never included in either output. + +### Remove a deployment ```bash -uptool admin +uptool rm x7k2mq ``` -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 @@ -228,6 +231,17 @@ uptool status # check if running + last 10 log lines uptool status --json # machine-readable health for monitoring (exit 1 if unhealthy) ``` +### Logs + +```bash +uptool logs # last 50 lines of ~/.uptool/server.log +uptool logs -n 200 # last 200 lines +uptool logs -f # follow as it grows (Ctrl-C to stop) +``` + +Reads the log file directly — no daemon or config needed, so it still works +when the daemon is down, which is usually when you want it. + ### Run as a systemd service (Linux) Survives reboots and restarts on failure: diff --git a/package-lock.json b/package-lock.json index 6c9ca20..467d9e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "uptool", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "uptool", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "commander": "^12.1.0", @@ -21,6 +21,7 @@ "@types/node": "^22.0.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.10", "tsup": "^8.3.0", "typescript": "^5.6.0", "vitest": "^4.1.9" @@ -29,22 +30,82 @@ "node": ">=18" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "dev": true, "license": "MIT", "optional": true, @@ -53,9 +114,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "dev": true, "license": "MIT", "optional": true, @@ -545,28 +606,31 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -574,9 +638,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -591,9 +655,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -608,9 +672,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -625,9 +689,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -642,9 +706,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -659,9 +723,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], @@ -679,9 +743,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], @@ -699,9 +763,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], @@ -719,9 +783,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], @@ -739,9 +803,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], @@ -759,9 +823,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], @@ -779,9 +843,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -796,28 +860,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -832,9 +893,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -1314,17 +1375,48 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1333,13 +1425,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1360,9 +1452,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1373,13 +1465,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1387,14 +1479,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1403,9 +1495,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1413,13 +1505,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1457,6 +1549,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1684,6 +1788,62 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1694,10 +1854,17 @@ "node": ">=10" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1711,23 +1878,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -1746,9 +1913,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -1767,9 +1934,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -1788,9 +1955,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -1809,9 +1976,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -1830,9 +1997,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -1854,9 +2021,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -1878,9 +2045,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -1902,9 +2069,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -1926,9 +2093,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -1947,9 +2114,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2007,6 +2174,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2040,9 +2235,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2097,9 +2292,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2132,9 +2327,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -2152,7 +2347,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2236,13 +2431,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.137.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2252,21 +2447,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.3", - "@rolldown/binding-darwin-arm64": "1.1.3", - "@rolldown/binding-darwin-x64": "1.1.3", - "@rolldown/binding-freebsd-x64": "1.1.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", - "@rolldown/binding-linux-arm64-gnu": "1.1.3", - "@rolldown/binding-linux-arm64-musl": "1.1.3", - "@rolldown/binding-linux-ppc64-gnu": "1.1.3", - "@rolldown/binding-linux-s390x-gnu": "1.1.3", - "@rolldown/binding-linux-x64-gnu": "1.1.3", - "@rolldown/binding-linux-x64-musl": "1.1.3", - "@rolldown/binding-openharmony-arm64": "1.1.3", - "@rolldown/binding-wasm32-wasi": "1.1.3", - "@rolldown/binding-win32-arm64-msvc": "1.1.3", - "@rolldown/binding-win32-x64-msvc": "1.1.3" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/rollup": { @@ -2314,6 +2509,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2400,6 +2608,19 @@ "node": ">= 6" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2571,16 +2792,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -2597,7 +2818,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2649,19 +2870,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2689,12 +2910,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index b0826a7..f774338 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptool", - "version": "0.2.0", + "version": "0.3.0", "description": "Serve LLM-generated HTML files via wildcard subdomains on your own domain", "type": "commonjs", "bin": { @@ -21,10 +21,12 @@ "url": "https://github.com/pyeom/uptool/issues" }, "scripts": { - "build": "tsup", + "build": "npm run typecheck && tsup", "dev": "tsup --watch", - "test": "vitest run", + "typecheck": "tsc --noEmit", + "test": "npm run build && vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build && npm test" }, "dependencies": { @@ -37,6 +39,7 @@ "@types/node": "^22.0.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.10", "tsup": "^8.3.0", "typescript": "^5.6.0", "vitest": "^4.1.9" diff --git a/plan-uptool-hardening-observabilidad.html b/plan-uptool-hardening-observabilidad.html new file mode 100644 index 0000000..4442e51 --- /dev/null +++ b/plan-uptool-hardening-observabilidad.html @@ -0,0 +1,438 @@ + + + + + +Plan — uptool: CLI-only, tests y hardening + + + +
+ +

uptool — CLI-only, tests y hardening

+

Achicar la superficie a una sola interfaz, poner una suite de tests que valga como red, y arreglar los defectos que hoy nada detecta.

+
+ Fecha 2026-08-01 + Esfuerzo 3–4 días + Enfoque CLI única + suite de tests como entregable central + Revisión 2 — reemplaza al plan anterior +
+ +

Qué cambió respecto de la revisión 1

+

+ Se elimina el servidor MCP y el admin web. Si el agente ya tiene la CLI, el MCP es una segunda + implementación del mismo contrato con la mitad de las capacidades — hoy expone 4 tools contra 15 + comandos, y toda fase de "paridad MCP" es trabajo de mantener sincronizadas dos superficies que + hacen lo mismo. El admin web tiene el mismo problema en versión visual. +

+

+ A cambio, la suite de tests pasa de ser un ítem dentro de otras fases a ser la fase más + grande del plan. Es el cambio de fondo: hoy los tests cubren bien las capas internas y + cero la superficie que el usuario y el agente realmente tocan. +

+ +

Contexto

+

+ uptool v0.2.0 son ~1.900 líneas de TypeScript sobre node:http, sin frameworks: un + servidor público que rutea por subdominio wildcard, una API interna en loopback con bearer token, + un ManifestStore en memoria que flushea a manifest.json, live-reload por + WebSocket, y 15 comandos de CLI. La arquitectura está bien: un solo dueño del estado, escrituras + atómicas de manifest, límites de cuota, timeouts contra slowloris, comparaciones en tiempo constante. +

+

+ El problema es que nada verifica los tipos. tsup compila con esbuild, + que borra tipos sin chequearlos, y CI corre build + test, nunca + tsc. Correr npx tsc --noEmit hoy tira 3 errores. Y los 539 tests que pasan + localmente son mentira: vitest levanta también los tests de 4 worktrees viejos en + .claude/worktrees/ — los reales son 6 archivos. Dos semáforos rotos al mismo tiempo. +

+ +

El hueco real de tests

+

+ Lo que hay está bien hecho: storage, api, public, + config y watch tienen tests directos y razonables. El problema es lo que + no tiene ninguno. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MóduloLíneasEstado
src/commands/deploy.ts215Cero tests. Es el camino más usado de todo el tool: walkDir, + resolución del entry point (index.html presente / un solo html / cero / varios), stdin con + fences, multi-archivo, --update, --protect, --watch. + Todo sin verificar.
Los otros 12 comandos~570Cero tests. Ningún test ejecuta el binario real: nadie verifica códigos de + salida, formato de stdout ni parseo de flags. El contrato con el agente es justamente ese.
src/lib/api-client.ts72Cero. Token ausente, token vacío, ECONNREFUSED, respuesta no-JSON — cuatro caminos + de error, ninguno probado.
src/server/ws.ts76Cero.
src/config/index.ts153Parcial. Sólo parseTtlMs y publicUrl. Sin probar: round-trip de + loadConfig/saveConfig, permisos 0600 del token, + resolvePath con ~.
readLogTail en status.ts14Cero. Lógica de lectura por chunk desde el final del archivo — exactamente el tipo de código + con casos borde (archivo menor al chunk, vacío, sin salto final).
Cobertura medidaNo existe. Sin @vitest/coverage-v8, sin umbral, sin gate.
+ +

Investigación

+

Qué hacen los comparables, y qué de eso aplica a un tool que corre en tu propia máquina.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReferenciaStackEscala / modeloQué tomamos
PreviewShipSaaS con CLI + servidor MCPOrientado a output de Claude Code / artifactsMantienen CLI y MCP en paralelo porque venden a usuarios que no tienen shell. uptool corre en la + máquina del agente: la premisa no aplica, y sostener dos superficies sí cuesta.
tiiny.host / HummingDeckSaaS gestionado, upload por webFree ~3 MB; password recién en planes de $18+/mesLo que cobran es password + métricas de vistas. uptool ya regala --protect; las + métricas se quedan, pero como columna de uptool list, no como dashboard.
CoolifySelf-hosted, Docker + Traefik, DNS wildcardVPS de 4 GB; preview por ramaMismo modelo de wildcard DNS, pero con TLS automático — y por eso arrastran Traefik entero. + Confirma que auto-TLS es la feature grande que falta, y también lo que cuesta.
Pinggy / zrok / frpTúneles self-hosted (Go/Rust)Producción, multi-protocoloResuelven transporte, no ciclo de vida del contenido (TTL, versiones, rollback). No compiten con + uptool: lo complementan. Refuerza no meterse a hacer túnel propio.
+

+ Conclusión: no hay nada que reemplace a uptool. El nicho — "el LLM deploya en tu + máquina, en tu dominio, sin subir nada a un tercero" — sigue vacío entre los SaaS (suben tu contenido) + y los túneles (no gestionan contenido). El proyecto tiene razón de existir; achicarlo a una sola + interfaz lo afila, no lo recorta. +

+ +

Hallazgos concretos del código

+

Todo verificado leyendo el archivo, no inferido.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#HallazgoDóndeImpacto
1El typecheck no corre en ningún lado y hoy falla. tsup usa esbuild + (borra tipos, no los valida) y CI hace build+test. 3 errores activos..github/workflows/ci.yml, src/commands/init.ts:39, + src/storage/index.ts:171,184Alto
2Los tests reales son 6 archivos, no 27. vitest.config.ts no excluye + .claude/worktrees/: corre los tests de 4 worktrees obsoletos. 539 tests / 13 s de + ruido que puede tanto tapar una falla como fallar por código viejo.vitest.config.tsAlto
3Corrupción UTF-8 en bodies grandes. readBody acumula + data += chunk.toString(): un carácter multibyte partido entre dos chunks TCP se + decodifica como . Cualquier HTML con acentos o emoji por encima de ~64 KB.src/server/api.ts:31Alto — corrupción silenciosa
4deploy --name dashboard imprime la URL equivocada. Usa el slug + aleatorio en vez del nombre. list sí usa el nombre: dos caminos, dos criterios.src/commands/deploy.ts:195-196Medio
5La versión está hardcodeada en 3 lugarespackage.json, + cli.ts:28 y el serverInfo de MCP. Dos de esos tres desaparecen con la + amputación; el que queda pasa a leerse de package.json.src/cli.tsMedio
6El WebSocket de live-reload no valida la key de los deploys protegidos. El + upgrade a /__lr sólo chequea que el slug exista. No filtra contenido, pero revela + que un deploy privado se actualizó. Tampoco hay ping/pong: las conexiones muertas se acumulan.src/server/ws.ts:31-59Medio
7extractSlug duplicado literal entre servidor público y WebSocket.src/server/public.ts:67, src/server/ws.ts:9Bajo
8La ruta del token está escrita a mano en el api-client en vez de usar + tokenPath() de config — tercera copia del mismo path.src/lib/api-client.ts:18Bajo
9El tope real de un bundle no es el que dice la config. Los bundles viajan como + base64 dentro de un JSON: max_body_bytes de 10 MB da un techo efectivo de ~7,5 MB.src/commands/deploy.ts:67, src/config/index.ts:52Bajo — documentar, no rediseñar
10El servidor público trata todo método como GET y no maneja HEAD + (devuelve body).src/server/public.ts:121-190Bajo
11rm y rollback no urlencodean el slug al armar el path + (touch sí).src/commands/rm.ts:204, src/commands/rollback.ts:178Bajo
12Higiene: 4 git worktrees de agentes quedaron registrados ocupando disco. Sin + CHANGELOG..claude/worktrees/Bajo
+ +

Plan de implementación

+ +

Fase 1 — Recuperar la señal

+

Entregable: el build y los tests vuelven a decir la verdad. Bloquea a todo lo demás — sin esto, cualquier arreglo posterior se verifica contra un semáforo roto.

+ + + + + + + + +
TareaArchivosDepende de
Script typecheck (tsc --noEmit), enganchado a build y al job de CIpackage.json, .github/workflows/ci.yml
Excluir .claude/** del include de vitest; verificar que quedan 6 archivosvitest.config.ts
Arreglar los 3 errores de tipos: init arma un Config parcial → partir de DEFAULT_CONFIG; el merge de declaración de ManifestStore → exportar la interface junto con la clasesrc/commands/init.ts, src/storage/index.tstypecheck activo
Podar los worktrees viejos (git worktree prune + borrar los 4 directorios).claude/worktrees/
+

Listo cuando: npm run typecheck sale limpio, npm test reporta 6 archivos, y CI falla si se mete un error de tipos a propósito.

+ +

Fase 2 — Amputar MCP y admin

+

Entregable: una sola superficie. Va antes que los arreglos para no arreglar código que se va a borrar.

+ + + + + + + + +
TareaArchivosDepende de
Borrar el servidor MCP y su comandosrc/commands/mcp.ts (−241)Fase 1
Borrar el admin: página, comando y ruta GET /admin de la API — con eso se va también la auth por token en query string, que era la única ruta que esquivaba el header Authorizationsrc/server/admin.ts (−146), src/commands/admin.ts (−26), src/server/api.tsFase 1
Sacar ambos comandos del CLI y sus 3 tests de admin de la suite de APIsrc/cli.ts, test/api.test.tsborrados
Actualizar README (secciones "Admin page" y "LLM integration"), bump a 0.3.0 y estrenar CHANGELOG.md anotando el breaking changeREADME.md, package.json, CHANGELOG.md (nuevo)borrados
+

Listo cuando: uptool --help no menciona mcp ni admin; la API responde 404 en /admin; se van ~420 líneas de src/ y el isAllowedApiHost queda como única defensa necesaria contra DNS-rebinding.

+ +

Fase 3 — Correctitud

+

Entregable: los defectos que el agujero de la Fase 1 dejaba pasar. Cada ítem entra con su test.

+ + + + + + + + + + +
TareaArchivosDepende de
Bug UTF-8: acumular Buffer[] y hacer Buffer.concat(...).toString("utf8") una vez al final, en vez de concatenar strings por chunksrc/server/api.ts, test/api.test.tsFase 1
deploy --name imprime la URL con nombre — misma regla que list (name ?? slug)src/commands/deploy.tsFase 1
Versión leída de package.json (resolveJsonModule ya está activo)src/cli.tsFase 2
Mover extractSlug a src/lib/slug.ts; consumirlo desde ambos servidores. El api-client pasa a usar tokenPath()src/lib/slug.ts (nuevo), src/server/public.ts, src/server/ws.ts, src/lib/api-client.tsFase 1
WebSocket: validar la key del deploy en el upgrade y agregar heartbeat ping/pong con limpieza de clientes muertossrc/server/ws.tsdedupe
encodeURIComponent en los slugs de rm y rollback; rechazar métodos que no sean GET/HEAD en el servidor público y responder HEAD sin bodysrc/commands/rm.ts, src/commands/rollback.ts, src/server/public.ts
+

Listo cuando: un deploy de 200 KB con acentos y emoji vuelve byte-idéntico; deploy --name x imprime x.tudominio; un cliente WS sin credenciales contra un slug protegido es rechazado.

+ +

Fase 4 — Suite de tests sólida núcleo del plan

+

Entregable: una red que atrapa regresiones en la superficie que el usuario y el agente realmente tocan — no sólo en las capas internas. Es la fase más larga: ~1,5–2 días.

+ +

4a · Instrumentar

+ + + + + + + +
TareaArchivosDepende de
Agregar @vitest/coverage-v8, script test:coverage y reporte por archivopackage.json, vitest.config.tsFase 1
Helper de test compartido: HOME temporal + puertos efímeros + daemon de usar y tirar. Ningún test puede tocar el ~/.uptool real — hoy nada lo garantizatest/helpers.ts (nuevo)
Helper que ejecuta el binario compilado (node dist/cli.js …) y devuelve stdout, stderr y exit codetest/helpers.tshelper de daemon
+ +

4b · Cubrir lo que hoy tiene cero

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SuiteQué verificaArchivo
CLI end-to-end
la más importante
Corriendo el binario real contra un daemon efímero: deploy de archivo, de stdin, de directorio y de varios archivos; --update, --name, --protect, --qr; después list, rm, touch, rollback, status --json, stop. Se afirma sobre exit code y stdout, que es el contrato con el agente.test/cli.test.ts (nuevo)
deploy — unitarioExportar walkDir y buildBody para probarlas directo: resolución del entry point en los 4 casos (index.html presente / un solo html / cero html / varios sin index), skip de dotfiles y node_modules, rutas relativas con separador normalizado, stdin con fences markdown, stdin vacío.test/deploy.test.ts (nuevo)
api-clientToken ausente, token vacío, ECONNREFUSED → mensaje "server not running", respuesta no-JSON → ApiError, header Authorization bien armado.test/api-client.test.ts (nuevo)
WebSocketUpgrade válido, path equivocado destruye el socket, host sin slug destruye el socket, broadcast llega sólo al slug correcto, rechazo sin credenciales en deploy protegido, heartbeat limpia clientes muertos.test/ws.test.ts (nuevo)
configRound-trip saveConfig/loadConfig, defaults aplicados a un TOML parcial, campos desconocidos preservados, resolvePath con ~, y que el token quede en 0600 tanto al crear como al sobrescribir.test/config.test.ts (extender)
readLogTailArchivo más chico que el chunk, más grande que el chunk, vacío, sin salto de línea final, y con menos líneas que las pedidas.test/status.test.ts (nuevo)
cuotas de storageEstimación de tamaño desde base64 con 0, 1 y 2 caracteres de padding; bytes recuperados en update con versionado activo y desactivado; FILE_TOO_LARGE vs QUOTA_EXCEEDED; flushNow en shutdown.test/storage.test.ts (extender)
servidor públicoHEAD sin body, métodos rechazados, Cache-Control por tipo de contenido (protegido / HTML / asset), rate limiter en el límite y pasado el límite, y que la ventana se purgue.test/public.test.ts (extender)
+ +

4c · Que no se pudra

+ + + + + + +
TareaArchivosDepende de
Fijar el umbral de cobertura en el valor que la suite alcance menos ~3 puntos, y que CI falle por debajo. Un número medido y con margen se sostiene; un 90% aspiracional se termina desactivandovitest.config.ts, .github/workflows/ci.yml4b
Job de CI corriendo typecheck + test:coverage en Node 20 y 22 (la matriz ya existe).github/workflows/ci.ymlumbral
+

Listo cuando: cada comando de la CLI tiene al menos un test que ejecuta el binario real y afirma sobre su exit code; la suite corre sin tocar el ~/.uptool del usuario; hay un número de cobertura publicado y CI falla si baja.

+ +

Fase 5 — Observabilidad por CLI

+

Entregable: saber qué pasa con lo deployado sin salir de la terminal.

+ + + + + + + + +
TareaArchivosDepende de
Agregar hits y last_seen a ManifestEntry; método recordHit(slug) que incrementa en memoria y se apoya en el scheduleFlush() debounced que ya existe — cero I/O extra por requestsrc/storage/index.ts, test/storage.test.tsFase 1
Llamar recordHit desde el servidor público sólo en respuestas HTML 200, no en assets, para que el número signifique "visitas" y no "requests"src/server/public.ts, test/public.test.tsrecordHit
Mostrar hits y última visita en uptool list, más --json para que sea consumible por scripts y por el agentesrc/commands/list.ts, test/cli.test.tsrecordHit
Comando uptool logs [-f] [-n N]: reusar readLogTail, que en la Fase 4 ya quedó testeado y extraídosrc/commands/logs.ts (nuevo), src/cli.tsFase 4b
+

Listo cuando: abrir un deploy dos veces muestra hits: 2 en uptool list; los hits sobreviven a un reinicio del daemon; uptool logs -f muestra un deploy nuevo en vivo.

+ +

Fuera de alcance

+
    +
  • Servidor MCP. Eliminado, no diferido. La CLI es la interfaz para el agente.
  • +
  • Cualquier interfaz visual — admin web, dashboard, upload por browser.
  • +
  • TLS automático (ACME/Let's Encrypt). Wildcard exige challenge DNS-01, o sea credenciales de API del DNS del usuario. Sigue siendo cert_file/key_file o terminar TLS en Cloudflare Tunnel.
  • +
  • launchd para macOS y servicio de Windows. install-service sigue siendo sólo systemd/Linux.
  • +
  • Analítica por visitante (IP, user-agent, ruta): el contador es en memoria, sin dato personal en disco.
  • +
  • Rediseño del transporte de bundles. El base64-sobre-JSON queda; el techo (~7,5 MB) se documenta.
  • +
  • Compresión y ETag/304 en el servidor público. Optimización sin problema reportado.
  • +
  • Tests de mutación y property-based. Primero que exista la red; refinarla es otra conversación.
  • +
+ +

Riesgos abiertos

+
    +
  • Sacar el MCP rompe a quien ya lo tenga configurado. Mitigación: bump a 0.3.0 y CHANGELOG explícito; el reemplazo es una línea de prompt diciéndole al agente que corra uptool deploy, que es lo que el README ya recomienda como camino principal.
  • +
  • Los tests de CLI dependen de dist/. Si se corren sin buildear, fallan de forma confusa. Mitigación: que test:cli dependa de build, y que el helper falle con un mensaje claro si no encuentra el binario.
  • +
  • Los tests que levantan daemons pueden dejar puertos o procesos colgados y volver la suite intermitente. Mitigación: puertos efímeros (puerto 0), afterEach que mata el proceso, y timeout por test.
  • +
  • Activar tsc puede destapar más de 3 errores — el compilador se frena en el primero de cada archivo. Mitigación: la Fase 1 es primera y aislada.
  • +
  • Los tests de los worktrees podían estar tapando una falla real. Mitigación: correr la suite recortada antes de tocar código, para tener una línea de base honesta.
  • +
  • Los hits agregan escrituras al manifest con cada visita. Mitigación: el scheduleFlush() debounced ya colapsa las escrituras a una cada 500 ms como mucho.
  • +
+ +
+ Plan generado con /postplan · 2026-08-01 · Revisión 2 · Pendiente de aprobación +
+ +
+ + diff --git a/src/cli.ts b/src/cli.ts index 72fda32..0bcfb6f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import { version } from "../package.json"; import { initCommand } from "./commands/init.js"; import { serveCommand } from "./commands/serve.js"; import { deployCommand } from "./commands/deploy.js"; @@ -9,9 +10,8 @@ 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 { logsCommand } from "./commands/logs.js"; import { setUrlCommand, setPortCommand, @@ -25,7 +25,7 @@ const program = new Command(); program .name("uptool") .description("Serve LLM-generated HTML files via wildcard subdomains on your own domain") - .version("0.2.0"); + .version(version); program .command("init") @@ -66,7 +66,8 @@ program program .command("list") .description("List all deployed files") - .action(() => listCommand()); + .option("--json", "Machine-readable output", false) + .action((opts) => listCommand(opts)); program .command("rm ") @@ -94,6 +95,13 @@ program .option("--json", "Machine-readable output for monitoring (exit 1 if unhealthy)", false) .action((opts) => statusCommand(opts)); +program + .command("logs") + .description("Print the daemon log") + .option("-n, --lines ", "Number of lines to show (default 50)") + .option("-f, --follow", "Follow the log as it grows (Ctrl-C to stop)", false) + .action((opts) => logsCommand(opts)); + program .command("install-service") .description("Install a systemd user service (auto-restart, start on boot)") @@ -124,14 +132,4 @@ 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)") - .action(() => mcpCommand()); - program.parse(); diff --git a/src/commands/admin.ts b/src/commands/admin.ts deleted file mode 100644 index 9d322b5..0000000 --- a/src/commands/admin.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/commands/deploy.ts b/src/commands/deploy.ts index 7ccffae..73c2861 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -17,7 +17,7 @@ function readStdin(): Promise { } /** Recursively collect files in a directory, skipping dotfiles and node_modules. */ -function walkDir(dir: string, baseDir: string): Array<{ rel: string; full: string }> { +export function walkDir(dir: string, baseDir: string): Array<{ rel: string; full: string }> { const results: Array<{ rel: string; full: string }> = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith(".")) continue; @@ -33,7 +33,7 @@ function walkDir(dir: string, baseDir: string): Array<{ rel: string; full: strin return results; } -async function buildBody( +export async function buildBody( filePath: string | undefined ): Promise> { if (!filePath) { @@ -111,7 +111,10 @@ function watchAndRedeploy( body ); if (result.error) throw new Error(result.error); - const url = publicUrl(config, result.slug ?? slug); + // `slug` here is the stable identifier (name if one was assigned, else + // the random slug) — it doesn't change across redeploys, unlike + // result.slug which is always the underlying random slug. + const url = publicUrl(config, slug); console.log(`↻ redeployed ${url} (${formatTime()})`); } catch (err) { console.error(`Error redeploying: ${(err as Error).message}`); @@ -193,13 +196,16 @@ export async function deployCommand( ); if (result.error) throw new Error(result.error); const slug = result.slug ?? (opts.update as string); - const url = publicUrl(config, slug); + // Prefer the name-based URL when a name was assigned (matches list.ts) + // — the deployment is reachable at name., not slug.. + const urlSlug = !opts.update && opts.name ? opts.name : slug; + const url = publicUrl(config, urlSlug); 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; + watchSlug = urlSlug; } } catch (err) { console.error(`Error deploying ${filePath ?? "stdin"}: ${(err as Error).message}`); diff --git a/src/commands/init.ts b/src/commands/init.ts index 7f4efe8..5a70376 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -37,6 +37,7 @@ export async function initCommand(): Promise { rl.close(); const config: Config = { + ...DEFAULT_CONFIG, base_url, port: parseInt(portStr, 10), api_port: parseInt(apiPortStr, 10), diff --git a/src/commands/list.ts b/src/commands/list.ts index f8c83c1..7e0f644 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -8,27 +8,53 @@ function formatDuration(ms: number): string { return `${m}m`; } -export async function listCommand(): Promise { +interface FileRecord { + slug: string; + filename: string; + created: number; + expires: number; + name?: string; + hits?: number; + last_seen?: number; + protected?: boolean; +} + +function formatHits(f: FileRecord, now: number): string { + const hits = f.hits ?? 0; + if (hits === 0 || !f.last_seen) return "never viewed"; + return `${hits} hit${hits === 1 ? "" : "s"} · last seen ${formatDuration(now - f.last_seen)} ago`; +} + +export async function listCommand(opts: { json?: boolean } = {}): Promise { const config = loadConfig(); try { - const result = await callApi<{ - files: Array<{ - slug: string; - filename: string; - created: number; - expires: number; - name?: string; - }>; - }>(config.api_port, "GET", "/files"); - - if (!result.files || result.files.length === 0) { + const result = await callApi<{ files: FileRecord[] }>(config.api_port, "GET", "/files"); + const files = result.files ?? []; + + if (opts.json) { + const records = files.map((f) => ({ + slug: f.slug, + url: publicUrl(config, f.name ?? f.slug), + filename: f.filename, + name: f.name ?? null, + created: f.created, + expires: f.expires, + hits: f.hits ?? 0, + last_seen: f.last_seen ?? null, + protected: Boolean(f.protected), + })); + console.log(JSON.stringify(records)); + return; + } + + if (files.length === 0) { console.log("No deployed files."); return; } const now = Date.now(); - for (const f of result.files) { + for (const f of files) { // Use name as the subdomain if available (stable URL) const url = publicUrl(config, f.name ?? f.slug); const expiry = @@ -38,7 +64,9 @@ export async function listCommand(): Promise { : "EXPIRED" : "no expiry"; const nameTag = f.name ? ` name: ${f.name}` : ""; - console.log(`${f.slug} ${url} [${f.filename}]${nameTag} ${expiry}`); + console.log( + `${f.slug} ${url} [${f.filename}]${nameTag} ${expiry} ${formatHits(f, now)}` + ); } } catch (err) { console.error(`Error: ${(err as Error).message}`); diff --git a/src/commands/logs.ts b/src/commands/logs.ts new file mode 100644 index 0000000..f150a81 --- /dev/null +++ b/src/commands/logs.ts @@ -0,0 +1,69 @@ +import * as fs from "node:fs"; +import { logPath } from "../config/index.js"; +import { readLogTail } from "./status.js"; + +const DEFAULT_LINES = 50; + +/** + * Print the daemon log. Reads `~/.uptool/server.log` directly — no config file + * and no running daemon required, so it still works on a half-set-up install + * (or precisely when the daemon died and you want to know why). + */ +export function logsCommand(opts: { follow?: boolean; lines?: string } = {}): void { + const file = logPath(); + + if (!fs.existsSync(file)) { + console.error(`No log file at ${file}. Start the daemon first: uptool serve`); + process.exitCode = 1; + return; + } + + const lineCount = opts.lines ? parseInt(opts.lines, 10) : DEFAULT_LINES; + if (!Number.isFinite(lineCount) || lineCount < 1) { + console.error(`Invalid line count: ${opts.lines}`); + process.exitCode = 1; + return; + } + + const tail = readLogTail(file, lineCount); + if (tail) console.log(tail); + + if (opts.follow) followLog(file); +} + +/** + * Print bytes appended to `file` after the initial tail, until Ctrl-C. + * + * Uses fs.watchFile (stat polling) rather than fs.watch: watch's inotify events + * are unreliable for append-only writes across platforms and give no size, and + * polling hands us the previous/current stat pair that truncation detection + * needs anyway. A log tailer is not hot enough for the 1s interval to matter. + */ +export function followLog(file: string, intervalMs = 1000): () => void { + let offset = fs.statSync(file).size; + + fs.watchFile(file, { interval: intervalMs }, (curr) => { + // Truncated or rotated: the file we were reading is gone or reset, so + // reading from the old offset would emit garbage. Restart from the top. + if (curr.size < offset) offset = 0; + if (curr.size === offset) return; + + const fd = fs.openSync(file, "r"); + try { + const len = curr.size - offset; + const buf = Buffer.alloc(len); + const read = fs.readSync(fd, buf, 0, len, offset); + offset += read; + process.stdout.write(buf.subarray(0, read).toString("utf8")); + } finally { + fs.closeSync(fd); + } + }); + + const stop = (): void => fs.unwatchFile(file); + process.on("SIGINT", () => { + stop(); + process.exit(0); + }); + return stop; +} diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts deleted file mode 100644 index 0234a58..0000000 --- a/src/commands/mcp.ts +++ /dev/null @@ -1,241 +0,0 @@ -import * as fs from "node:fs"; -import * as readline from "node:readline"; -import { loadConfig, parseTtlMs, pidPath, publicUrl } from "../config/index.js"; -import type { Config } from "../config/index.js"; -import { callApi } from "../lib/api-client.js"; - -interface RpcRequest { - jsonrpc: "2.0"; - id?: number | string | null; - method: string; - params?: unknown; -} - -function send(id: number | string | null | undefined, result: unknown): void { - process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, result }) + "\n"); -} - -function sendError(id: number | string | null | undefined, code: number, message: string): void { - process.stdout.write( - JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }) + "\n" - ); -} - -const TOOLS = [ - { - name: "deploy_html", - description: - "Deploy HTML content (or a file bundle) and get a public URL served from your wildcard subdomain. Returns the URL.", - inputSchema: { - type: "object", - properties: { - html: { type: "string", description: "HTML content to deploy (single-file mode)" }, - files: { - type: "object", - description: - "Bundle mode: object mapping relative file paths to base64-encoded content (e.g. { 'index.html': '', 'style.css': '' }). Use instead of 'html' for multi-file deployments.", - additionalProperties: { type: "string" }, - }, - entry: { - type: "string", - description: "Root file path within the bundle to serve for '/'. Default 'index.html'.", - }, - filename: { - type: "string", - description: "Display name for the deployment (optional, defaults to 'claude.html')", - }, - slug: { - type: "string", - description: - "Existing slug or name to update rather than create a new deployment (optional)", - }, - name: { - type: "string", - description: - "Stable human-readable name for the deployment, e.g. 'dashboard'. Becomes dashboard.mydev.com. Lowercase letters, digits, hyphens only.", - }, - }, - }, - }, - { - name: "list_deployments", - description: "List all currently deployed HTML files with their URLs and expiry times.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "remove_deployment", - description: "Remove a deployed HTML file by its slug or name.", - inputSchema: { - type: "object", - properties: { - slug: { type: "string", description: "Slug or name of the deployment to remove" }, - }, - required: ["slug"], - }, - }, - { - name: "server_status", - description: "Check if the uptool server daemon is running and get its base URL.", - inputSchema: { type: "object", properties: {} }, - }, -]; - -function formatDuration(ms: number): string { - const h = Math.floor(ms / (1000 * 60 * 60)); - const m = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -async function handleToolCall( - config: Config, - name: string, - args: Record -): Promise { - switch (name) { - case "deploy_html": { - const html = args.html !== undefined ? String(args.html) : undefined; - const files = args.files as Record | undefined; - const entry = args.entry ? String(args.entry) : "index.html"; - const filename = String(args.filename ?? "claude.html"); - const slugArg = args.slug ? String(args.slug) : undefined; - const nameArg = args.name ? String(args.name) : undefined; - - const body: Record = { filename, entry }; - if (html !== undefined) body.html = html; - else if (files) body.files = files; - else throw new Error("Provide either 'html' or 'files'"); - - if (slugArg) body.slug = slugArg; - if (nameArg) body.name = nameArg; - - const result = (await callApi<{ slug?: string; error?: string }>( - config.api_port, - "POST", - "/deploy", - body - )); - if (result.error) throw new Error(result.error); - - const resolvedSlug = result.slug ?? slugArg!; - const url = publicUrl(config, nameArg ?? resolvedSlug); - const ttlMs = parseTtlMs(config.ttl); - const expiry = ttlMs > 0 ? ` (expires in ${config.ttl})` : ""; - return `${url}${expiry}`; - } - - case "list_deployments": { - const result = await callApi<{ - files: Array<{ slug: string; filename: string; created: number; expires: number; name?: string }>; - }>(config.api_port, "GET", "/files"); - if (!result.files || result.files.length === 0) return "No deployments found."; - const now = Date.now(); - return result.files - .map((f) => { - const url = publicUrl(config, f.name ?? f.slug); - const expiry = - f.expires > 0 - ? f.expires > now - ? `expires in ${formatDuration(f.expires - now)}` - : "EXPIRED" - : "no expiry"; - const nameTag = f.name ? ` name: ${f.name}` : ""; - return `${f.slug} ${url} [${f.filename}]${nameTag} ${expiry}`; - }) - .join("\n"); - } - - case "remove_deployment": { - const slug = String(args.slug ?? ""); - const result = await callApi<{ removed: boolean }>( - config.api_port, - "DELETE", - `/files/${slug}` - ); - return result.removed ? `Removed: ${slug}` : `Not found: ${slug}`; - } - - case "server_status": { - const pidFile = pidPath(); - if (!fs.existsSync(pidFile)) return "uptool: stopped"; - const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); - try { - process.kill(pid, 0); - return `uptool: running (pid ${pid})\nBase URL: ${publicUrl(config, "")}`; - } catch { - return `uptool: stopped (stale PID ${pid})`; - } - } - - default: - throw new Error(`Unknown tool: ${name}`); - } -} - -export function mcpCommand(): void { - let config: Config; - try { - config = loadConfig(); - } catch (err) { - process.stderr.write(`uptool mcp: ${(err as Error).message}\n`); - process.exit(1); - } - - const rl = readline.createInterface({ input: process.stdin, terminal: false }); - - rl.on("line", async (line) => { - const trimmed = line.trim(); - if (!trimmed) return; - - let req: RpcRequest; - try { - req = JSON.parse(trimmed) as RpcRequest; - } catch { - sendError(null, -32700, "Parse error"); - return; - } - - // Notifications have no id and require no response - if (req.id === undefined && req.method.startsWith("notifications/")) return; - - try { - switch (req.method) { - case "initialize": - send(req.id, { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "uptool", version: "0.2.0" }, - }); - break; - - case "tools/list": - send(req.id, { tools: TOOLS }); - break; - - case "tools/call": { - const { name, arguments: args = {} } = req.params as { - name: string; - arguments?: Record; - }; - try { - const text = await handleToolCall(config, name, args); - send(req.id, { content: [{ type: "text", text }] }); - } catch (err) { - send(req.id, { - content: [{ type: "text", text: `Error: ${(err as Error).message}` }], - isError: true, - }); - } - break; - } - - default: - sendError(req.id, -32601, `Method not found: ${req.method}`); - } - } catch (err) { - sendError(req.id, -32603, `Internal error: ${(err as Error).message}`); - } - }); - - rl.on("close", () => process.exit(0)); -} diff --git a/src/commands/rm.ts b/src/commands/rm.ts index b94e232..61efb3d 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -8,7 +8,7 @@ export async function rmCommand(slug: string): Promise { const result = await callApi<{ removed: boolean; error?: string }>( config.api_port, "DELETE", - `/files/${slug}` + `/files/${encodeURIComponent(slug)}` ); if (result.removed) { console.log(`✓ Removed ${slug}`); diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts index 8c23998..87d00cd 100644 --- a/src/commands/rollback.ts +++ b/src/commands/rollback.ts @@ -8,7 +8,7 @@ export async function rollbackCommand(slug: string): Promise { const result = await callApi<{ restored?: string; error?: string }>( config.api_port, "POST", - `/files/${slug}/rollback` + `/files/${encodeURIComponent(slug)}/rollback` ); if (result.error) { diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 1f6b6b6..9431b35 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -72,7 +72,7 @@ export function serveCommand(opts: { foreground?: boolean }): void { // Live reload: attach WebSocket manager and wire store 'updated' events let wsManager: WsManager | null = null; if (config.live_reload) { - wsManager = new WsManager(publicServer, config); + wsManager = new WsManager(publicServer, config, store); store.on("updated", (slug: string) => wsManager!.broadcast(slug, "reload")); } diff --git a/src/commands/status.ts b/src/commands/status.ts index 778f80d..d9cda21 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -90,15 +90,26 @@ export async function statusCommand(opts: { json?: boolean } = {}): Promise 0) { + // The chunk didn't start at byte 0, so it may start mid-line — and if + // it starts mid-character, decoding to utf8 above already turned the + // split bytes into U+FFFD. Either way, that first (partial) line is + // garbage: drop everything up to and including its newline. + const nl = text.indexOf("\n"); + text = nl === -1 ? "" : text.slice(nl + 1); + } + const trimmed = text.trim(); + const lines = trimmed === "" ? [] : trimmed.split("\n"); return lines.slice(-lineCount).join("\n"); } finally { fs.closeSync(fd); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index b34cf7c..e45a6ab 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,11 +1,10 @@ /** * Shared HTTP client for CLI commands that talk to the daemon's internal API. - * Extracted from deploy.ts / mcp.ts to avoid duplication. + * Extracted from deploy.ts to avoid duplication. */ import * as http from "node:http"; import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; +import { tokenPath } from "../config/index.js"; export class ApiError extends Error { constructor(message: string) { @@ -15,7 +14,7 @@ export class ApiError extends Error { } function getToken(): string { - const p = path.join(os.homedir(), ".uptool", "token"); + const p = tokenPath(); if (!fs.existsSync(p)) { throw new ApiError("Auth token not found. Run: uptool init"); } diff --git a/src/lib/basic-auth.ts b/src/lib/basic-auth.ts new file mode 100644 index 0000000..7c07620 --- /dev/null +++ b/src/lib/basic-auth.ts @@ -0,0 +1,23 @@ +import * as crypto from "node:crypto"; +import * as http from "node:http"; + +/** + * Check Basic Auth against a deployment's access key. + * Any username is accepted; the password must equal the key (constant-time). + */ +export function basicAuthOk(req: http.IncomingMessage, key: string): boolean { + const header = req.headers.authorization ?? ""; + if (!header.startsWith("Basic ")) return false; + let decoded: string; + try { + decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); + } catch { + return false; + } + const sep = decoded.indexOf(":"); + if (sep === -1) return false; + const password = Buffer.from(decoded.slice(sep + 1)); + const expected = Buffer.from(key); + if (password.length !== expected.length) return false; + return crypto.timingSafeEqual(password, expected); +} diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..acce46c --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,11 @@ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Extract the subdomain slug from the Host header. Returns null if not a valid subdomain. */ +export function extractSlug(host: string, baseUrl: string): string | null { + const base = baseUrl.replace(/^https?:\/\//, ""); + const slug = host.replace(new RegExp(`\\.${escapeRegex(base)}(:\\d+)?$`), ""); + if (!slug || slug === host) return null; + return slug; +} diff --git a/src/server/admin.ts b/src/server/admin.ts deleted file mode 100644 index 420ac89..0000000 --- a/src/server/admin.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 cf5be33..94026e1 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -2,7 +2,6 @@ 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"; @@ -12,7 +11,7 @@ const DEFAULT_ENTRY = "index.html"; */ function readBody(req: http.IncomingMessage, maxBytes: number): Promise { return new Promise((resolve, reject) => { - let data = ""; + const chunks: Buffer[] = []; let size = 0; let overflow = false; @@ -28,10 +27,10 @@ function readBody(req: http.IncomingMessage, maxBytes: number): Promise // the 413 response while the connection is open. return; } - if (!overflow) data += chunk.toString(); + if (!overflow) chunks.push(chunk); }); - req.on("end", () => { if (!overflow) resolve(data); }); + req.on("end", () => { if (!overflow) resolve(Buffer.concat(chunks).toString("utf8")); }); req.on("error", (err) => { if (!overflow) reject(err); }); }); } @@ -61,11 +60,6 @@ function isAuthorized(req: http.IncomingMessage, token: string): boolean { 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( config: Config, store: ManifestStore, @@ -101,23 +95,6 @@ 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/src/server/public.ts b/src/server/public.ts index 4d13e19..291dd6e 100644 --- a/src/server/public.ts +++ b/src/server/public.ts @@ -1,9 +1,10 @@ -import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; import { Config } from "../config/index.js"; import { ManifestStore } from "../storage/index.js"; +import { extractSlug } from "../lib/slug.js"; +import { basicAuthOk } from "../lib/basic-auth.js"; /** * Tiny inline script injected before when live_reload is enabled. @@ -17,10 +18,6 @@ const RELOAD_SCRIPT = `ws.onclose=function(){setTimeout(function(){location.reload();},2000);};` + `}());`; -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - /** * Fixed-window per-IP rate limiter. Dependency-free; memory is bounded by * pruning expired windows. `limit <= 0` disables it (always allows). @@ -63,14 +60,6 @@ function clientIp(req: http.IncomingMessage, trustProxy: boolean): string { return req.socket.remoteAddress ?? "unknown"; } -/** Extract the subdomain slug from the Host header. Returns null if not a valid subdomain. */ -function extractSlug(host: string, baseUrl: string): string | null { - const base = baseUrl.replace(/^https?:\/\//, ""); - const slug = host.replace(new RegExp(`\\.${escapeRegex(base)}(:\\d+)?$`), ""); - if (!slug || slug === host) return null; - return slug; -} - function applySecurityHeaders( headers: http.OutgoingHttpHeaders, config: Config @@ -97,33 +86,18 @@ function sendErrorPage( res.end(`

${status}

${message}

`); } -/** - * Check Basic Auth against a deployment's access key. - * Any username is accepted; the password must equal the key (constant-time). - */ -function basicAuthOk(req: http.IncomingMessage, key: string): boolean { - const header = req.headers.authorization ?? ""; - if (!header.startsWith("Basic ")) return false; - let decoded: string; - try { - decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); - } catch { - return false; - } - const sep = decoded.indexOf(":"); - if (sep === -1) return false; - const password = Buffer.from(decoded.slice(sep + 1)); - const expected = Buffer.from(key); - if (password.length !== expected.length) return false; - return crypto.timingSafeEqual(password, expected); -} - function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, config: Config, store: ManifestStore ): void { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405, { "Content-Type": "text/plain", Allow: "GET, HEAD" }); + res.end("Method Not Allowed"); + return; + } + const host = req.headers.host ?? ""; const slugOrName = extractSlug(host, config.base_url); @@ -186,7 +160,12 @@ function handleRequest( headers["Content-Length"] = body.length; res.writeHead(200, headers); - res.end(body); + res.end(req.method === "HEAD" ? undefined : body); + + // Count actual page views only: successful, HTML, not a HEAD probe. + if (isHtml && req.method === "GET") { + store.recordHit(slugOrName); + } } /** diff --git a/src/server/ws.ts b/src/server/ws.ts index b1e9ac3..9aa5791 100644 --- a/src/server/ws.ts +++ b/src/server/ws.ts @@ -1,17 +1,15 @@ import * as http from "node:http"; import { WebSocketServer, WebSocket } from "ws"; import { Config } from "../config/index.js"; +import { ManifestStore } from "../storage/index.js"; +import { extractSlug } from "../lib/slug.js"; +import { basicAuthOk } from "../lib/basic-auth.js"; -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +interface TrackedSocket extends WebSocket { + isAlive?: boolean; } -function extractSlug(host: string, baseUrl: string): string | null { - const base = baseUrl.replace(/^https?:\/\//, ""); - const slug = host.replace(new RegExp(`\\.${escapeRegex(base)}(:\\d+)?$`), ""); - if (!slug || slug === host) return null; - return slug; -} +const HEARTBEAT_MS = 30_000; /** * Manages WebSocket connections for live-reload. @@ -22,10 +20,11 @@ function extractSlug(host: string, baseUrl: string): string | null { * open tabs for that slug. */ export class WsManager { - private clients = new Map>(); + private clients = new Map>(); private wss: WebSocketServer; + private heartbeat: NodeJS.Timeout; - constructor(server: http.Server, config: Config) { + constructor(server: http.Server, config: Config, store: ManifestStore) { this.wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (req, socket, head) => { @@ -42,11 +41,26 @@ export class WsManager { return; } - this.wss.handleUpgrade(req, socket, head, (ws) => { + // Protected deployments require the same Basic Auth as the public + // HTTP server, otherwise a third party who knows the URL could + // observe update events for a private deployment. + const resolved = store.resolveSlug(slug); + const manifestEntry = resolved ? store.getEntry(resolved) : null; + if (manifestEntry?.key && !basicAuthOk(req, manifestEntry.key)) { + socket.destroy(); + return; + } + + this.wss.handleUpgrade(req, socket, head, (ws: TrackedSocket) => { if (!this.clients.has(slug)) this.clients.set(slug, new Set()); const clientSet = this.clients.get(slug)!; clientSet.add(ws); + ws.isAlive = true; + ws.on("pong", () => { + ws.isAlive = true; + }); + ws.on("close", () => { clientSet.delete(ws); if (clientSet.size === 0) this.clients.delete(slug); @@ -57,6 +71,25 @@ export class WsManager { }); }); }); + + // Dead connections (client vanished without a clean close) never get + // removed on their own — sweep periodically and terminate anything + // that hasn't ponged since the previous sweep. + this.heartbeat = setInterval(() => { + for (const [slug, clientSet] of this.clients) { + for (const ws of clientSet) { + if (ws.isAlive === false) { + clientSet.delete(ws); + ws.terminate(); + continue; + } + ws.isAlive = false; + ws.ping(); + } + if (clientSet.size === 0) this.clients.delete(slug); + } + }, HEARTBEAT_MS); + this.heartbeat.unref(); } /** Broadcast a message to all open WebSocket clients for a slug. */ @@ -71,6 +104,7 @@ export class WsManager { } close(): void { + clearInterval(this.heartbeat); this.wss.close(); } } diff --git a/src/storage/index.ts b/src/storage/index.ts index a3b3561..b9c4c80 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -21,6 +21,10 @@ export interface ManifestEntry { key?: string; /** Saved version timestamps (newest first). Used for rollback. */ versions?: string[]; + /** View count (HTML page views only). Absent = 0. */ + hits?: number; + /** Epoch ms of the most recent view. Absent = never viewed. */ + last_seen?: number; } export type Manifest = Record; @@ -168,7 +172,7 @@ export function ensureStorageDir(storageDir: string): string { // --------------------------------------------------------------------------- // Typed EventEmitter declaration for TypeScript -declare interface ManifestStore { +export declare interface ManifestStore { on(event: "updated", listener: (slug: string) => void): this; emit(event: "updated", slug: string): boolean; } @@ -249,8 +253,15 @@ export class ManifestStore extends EventEmitter { private scheduleFlush(): void { if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer = setTimeout(() => { - saveManifestSync(this.storageDir, this.manifest); this.flushTimer = null; + // This runs detached on a timer, so a throw here is an uncaught exception + // rather than something a caller can handle. Keep the in-memory manifest + // authoritative and log instead — the next mutation schedules a retry. + try { + saveManifestSync(this.storageDir, this.manifest); + } catch (err) { + console.error(`[uptool] manifest flush failed: ${(err as Error).message}`); + } }, 500); } @@ -283,8 +294,8 @@ export class ManifestStore extends EventEmitter { } list(): Array & { slug: string; protected: boolean }> { - // Never serialize the access key — list() feeds GET /files (CLI, MCP, - // admin page). Expose only a `protected` flag. + // Never serialize the access key — list() feeds GET /files (CLI). + // Expose only a `protected` flag. return Object.entries(this.manifest).map(([slug, entry]) => { const { key, ...rest } = entry; return { slug, ...rest, protected: Boolean(key) }; @@ -491,6 +502,22 @@ export class ManifestStore extends EventEmitter { return { buffer: fs.readFileSync(fullPath), contentType: mimeForPath(fullPath) }; } + /** + * Record a page view for `slug`. Called on the hot request path (every + * HTML view), so it must stay cheap: in-memory increment only, no extra + * disk I/O — it rides the existing debounced flush. Unknown slug is a + * silent no-op. + */ + recordHit(slugOrName: string): void { + const slug = this.resolveSlug(slugOrName); + if (!slug) return; + const entry = this.manifest[slug]; + if (!entry) return; + entry.hits = (entry.hits ?? 0) + 1; + entry.last_seen = Date.now(); + this.scheduleFlush(); + } + // ------------------------------------------------------------------------- // Versioning (P3) // ------------------------------------------------------------------------- diff --git a/test/api-client.test.ts b/test/api-client.test.ts new file mode 100644 index 0000000..d9018f2 --- /dev/null +++ b/test/api-client.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, afterEach } 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 { callApi, ApiError } from "../src/lib/api-client.js"; + +/** + * In-process coverage: callApi() reads the token via tokenPath(), which + * derives from os.homedir(), which on Linux reads process.env.HOME at call + * time (verified — no caching). We override HOME per-test and always restore + * it in afterEach, even on failure. + */ + +let realHome: string | undefined; +let tmpHome: string; + +beforeEach(() => { + realHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-apiclient-")); + fs.mkdirSync(path.join(tmpHome, ".uptool"), { recursive: true }); + process.env.HOME = tmpHome; +}); + +afterEach(() => { + process.env.HOME = realHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +function writeToken(value: string) { + fs.writeFileSync(path.join(tmpHome, ".uptool", "token"), value); +} + +describe("callApi — token errors", () => { + it("rejects with ApiError when token file is missing", async () => { + await expect(callApi(9999, "GET", "/files")).rejects.toMatchObject({ + name: "ApiError", + message: "Auth token not found. Run: uptool init", + }); + }); + + it("rejects with the same error when token file is empty/whitespace", async () => { + writeToken(" \n\t "); + await expect(callApi(9999, "GET", "/files")).rejects.toMatchObject({ + name: "ApiError", + message: "Auth token not found. Run: uptool init", + }); + }); +}); + +describe("callApi — network errors", () => { + it("rejects with an actionable message on ECONNREFUSED", async () => { + writeToken("tok"); + // Nothing listening on this port. + const port = await freeUnusedPort(); + await expect(callApi(port, "GET", "/files")).rejects.toMatchObject({ + name: "ApiError", + message: "uptool server not running — run: uptool serve", + }); + }); + + it("propagates a synchronous client-side error (bad port) unwrapped", async () => { + writeToken("tok"); + // Negative port triggers a RangeError / ERR_SOCKET_BAD_PORT from Node's + // http client before any socket 'error' event, which is not + // ECONNREFUSED and must propagate unwrapped (not turned into ApiError). + await expect(callApi(-1, "GET", "/files")).rejects.not.toMatchObject({ + name: "ApiError", + }); + }); + + it("propagates non-ECONNREFUSED socket errors (e.g. ECONNRESET) via req.on('error') unwrapped", async () => { + writeToken("tok"); + // A server that destroys the connection immediately produces a genuine + // async 'error' event on the request with a non-ECONNREFUSED code, + // exercising the reject(err) passthrough branch distinct from the + // ECONNREFUSED branch. + const server = http.createServer(); + server.on("connection", (sock) => sock.destroy()); + await listen(server); + const port = (server.address() as { port: number }).port; + try { + const err = await callApi(port, "GET", "/files").catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).not.toBe("ApiError"); + expect((err as NodeJS.ErrnoException).code).not.toBe("ECONNREFUSED"); + } finally { + await closeServer(server); + } + }); +}); + +describe("callApi — request/response handling", () => { + it("sends Authorization: Bearer header", async () => { + writeToken("secret-token-123"); + const { port, getRequest, close } = await startEchoServer(); + try { + await expect(callApi(port, "GET", "/files")).resolves.toEqual({ ok: true }); + const req = getRequest(); + expect(req.headers["authorization"]).toBe("Bearer secret-token-123"); + } finally { + await close(); + } + }); + + it("rejects with ApiError including the body on non-JSON response", async () => { + writeToken("tok"); + const server = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end("not json"); + }); + await listen(server); + const port = (server.address() as { port: number }).port; + try { + await expect(callApi(port, "GET", "/files")).rejects.toMatchObject({ + name: "ApiError", + message: expect.stringContaining("not json"), + }); + } finally { + await closeServer(server); + } + }); + + it("serialises body with correct Content-Type and byte-accurate Content-Length (multi-byte UTF-8)", async () => { + writeToken("tok"); + const body = { text: "café 🎉 — accénts and emoji" }; + const expectedJson = JSON.stringify(body); + const expectedByteLength = Buffer.byteLength(expectedJson, "utf8"); + // Sanity: byte length must differ from JS string length for this input, + // otherwise the test wouldn't actually exercise the UTF-8 path. + expect(expectedByteLength).not.toBe(expectedJson.length); + + const { port, getRequest, getRawBody, close } = await startEchoServer(); + try { + await callApi(port, "POST", "/deploy", body); + const req = getRequest(); + expect(req.headers["content-type"]).toBe("application/json"); + expect(req.headers["content-length"]).toBe(String(expectedByteLength)); + expect(getRawBody()).toBe(expectedJson); + } finally { + await close(); + } + }); + + it("resolves parsed JSON on success", async () => { + writeToken("tok"); + const server = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ files: [1, 2, 3] })); + }); + await listen(server); + const port = (server.address() as { port: number }).port; + try { + await expect(callApi(port, "GET", "/files")).resolves.toEqual({ files: [1, 2, 3] }); + } finally { + await closeServer(server); + } + }); + + it("documents behaviour on empty response body", async () => { + writeToken("tok"); + const server = http.createServer((_req, res) => { + res.writeHead(204); + res.end(); + }); + await listen(server); + const port = (server.address() as { port: number }).port; + try { + // FINDING: an empty body is not valid JSON, so JSON.parse("") throws + // and callApi rejects with ApiError("Invalid API response: ") — even + // for a deliberate 204 No Content. Any real endpoint returning 204 + // would surface as a client-side "Invalid API response" error rather + // than a clean success. Documented here, not fixed (api-client.ts is + // not in scope for this task). + await expect(callApi(port, "GET", "/files")).rejects.toMatchObject({ + name: "ApiError", + message: "Invalid API response: ", + }); + } finally { + await closeServer(server); + } + }); +}); + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +function listen(server: http.Server): Promise { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function freeUnusedPort(): Promise { + const server = http.createServer(); + await listen(server); + const port = (server.address() as { port: number }).port; + await closeServer(server); + return port; // nothing listens here after close +} + +/** Spins up a server that echoes request metadata for inspection, replying { ok: true }. */ +async function startEchoServer(): Promise<{ + port: number; + getRequest: () => http.IncomingMessage; + getRawBody: () => string; + close: () => Promise; +}> { + let lastReq: http.IncomingMessage | undefined; + let lastBody = ""; + const server = http.createServer((req, res) => { + lastReq = req; + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + lastBody = raw; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + }); + await listen(server); + const port = (server.address() as { port: number }).port; + return { + port, + getRequest: () => { + if (!lastReq) throw new Error("no request received"); + return lastReq; + }, + getRawBody: () => lastBody, + close: () => closeServer(server), + }; +} diff --git a/test/api.test.ts b/test/api.test.ts index 6c9e00d..752ec29 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -336,30 +336,80 @@ describe("API server", () => { expect(status).toBe(404); }); + it("returns 404 for /admin (removed)", async () => { + const { status } = await apiRequest(server, "GET", "/admin"); + expect(status).toBe(404); + }); + // ------------------------------------------------------------------------- - // Admin page + // Body reassembly across chunk boundaries (UTF-8 safety) // ------------------------------------------------------------------------- - it("rejects /admin without a token", async () => { - const { status } = await apiRequest(server, "GET", "/admin", undefined, null); - expect(status).toBe(401); - }); + it("reassembles a multi-byte UTF-8 char split across TCP chunks without corruption", async () => { + // Use a dedicated server with a large enough max_body_bytes. + const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-api-utf8-")); + const bigStore = new ManifestStore(tmpDir2, { ttl: "72h", max_versions: 5 }); + const bigConfig = { ...TEST_CONFIG, max_body_bytes: 1024 * 1024 }; + const bigServer = createApiServer(bigConfig, bigStore, TEST_TOKEN); + await new Promise((resolve) => bigServer.listen(0, "127.0.0.1", resolve)); - it("rejects /admin with a bad token", async () => { - const { status } = await apiRequest(server, "GET", "/admin?token=nope", undefined, null); - expect(status).toBe(401); - }); + try { + // Build HTML well over 64KB containing accented chars and an emoji. + const filler = "café ".repeat(15000); // > 64KB, includes accented chars + const html = filler + "🎉END"; + const payloadStr = JSON.stringify({ html }); + const payloadBuf = Buffer.from(payloadStr, "utf8"); + expect(payloadBuf.length).toBeGreaterThan(65536); + + // Find a multi-byte character and split the buffer inside its byte + // sequence, so no single "chunk" write contains a complete character. + const emojiBuf = Buffer.from("🎉", "utf8"); // 4 bytes + const emojiIdx = payloadBuf.indexOf(emojiBuf); + expect(emojiIdx).toBeGreaterThan(-1); + const splitAt = emojiIdx + 2; // split mid-character + + const chunk1 = payloadBuf.subarray(0, splitAt); + const chunk2 = payloadBuf.subarray(splitAt); + + const addr = bigServer.address() as { port: number }; + + const parsed = await new Promise<{ slug: string }>((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: addr.port, + path: "/deploy", + method: "POST", + headers: { + Authorization: `Bearer ${TEST_TOKEN}`, + "Content-Type": "application/json", + "Content-Length": payloadBuf.length, + }, + }, + (res) => { + let raw = ""; + res.on("data", (c) => (raw += c)); + res.on("end", () => { + expect(res.statusCode).toBe(200); + resolve(JSON.parse(raw) as { slug: string }); + }); + } + ); + req.on("error", reject); + req.on("socket", (socket) => socket.setNoDelay(true)); + req.write(chunk1, () => { + // Force the two chunks onto separate TCP reads on the server side. + setTimeout(() => req.end(chunk2), 20); + }); + }); - 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"); + const stored = bigStore.readFile(parsed.slug, "/"); + expect(stored).not.toBeNull(); + expect(stored!.buffer.toString("utf8")).toBe(html); + } finally { + bigStore.flushNow(); + await new Promise((resolve) => bigServer.close(() => resolve())); + fs.rmSync(tmpDir2, { recursive: true }); + } }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..97f0798 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,404 @@ +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 { startDaemon, runCli, tempHome, writeConfig, type Daemon } from "./helpers.js"; + +/** + * test/cli.test.ts — the user-facing contract. + * + * Runs the real dist/cli.js as a child process against a real daemon and + * asserts on exit codes + stdout/stderr content. This is what an LLM agent + * driving uptool actually sees — no in-process shortcuts. + * + * KNOWN LIMITATION: v8 coverage does not instrument subprocesses, so this + * file does not move the coverage number for src/commands/*. That's expected + * — this suite locks the CLI contract, not code coverage. + */ + +function writeHtmlFile(dir: string, name: string, content: string): string { + const p = path.join(dir, name); + fs.writeFileSync(p, content); + return p; +} + +describe("cli.test.ts", () => { + let daemon: Daemon; + let scratch: string; + + beforeAll(async () => { + daemon = await startDaemon(); + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-cli-test-")); + }, 20_000); + + afterAll(async () => { + await daemon?.stop(); + if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); + }); + + // --------------------------------------------------------------------- + // deploy + // --------------------------------------------------------------------- + describe("deploy", () => { + it("deploys a single HTML file and prints its URL", async () => { + const f = writeHtmlFile(scratch, "single.html", "

single

"); + const res = await runCli(["deploy", f], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/https?:\/\/\S+/); + }); + + it("deploys from stdin", async () => { + const res = await runCli(["deploy"], { home: daemon.home, input: "

from stdin

" }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/https?:\/\/\S+/); + }); + + it("deploys a directory bundle", async () => { + const dir = path.join(scratch, "bundle1"); + fs.mkdirSync(dir); + writeHtmlFile(dir, "index.html", "

bundle root

"); + writeHtmlFile(dir, "other.txt", "hello"); + const res = await runCli(["deploy", dir], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/https?:\/\/\S+/); + }); + + it("deploys multiple files in one invocation, one URL per file", async () => { + const f1 = writeHtmlFile(scratch, "multi1.html", "

multi1

"); + const f2 = writeHtmlFile(scratch, "multi2.html", "

multi2

"); + const res = await runCli(["deploy", f1, f2], { home: daemon.home }); + expect(res.code).toBe(0); + const urls = res.stdout.match(/https?:\/\/\S+/g) ?? []; + expect(urls.length).toBe(2); + }); + + it("--update keeps the same URL across redeploys", async () => { + const f = writeHtmlFile(scratch, "update1.html", "

v1

"); + const first = await runCli(["deploy", f], { home: daemon.home }); + expect(first.code).toBe(0); + const firstUrl = first.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(firstUrl).hostname.split(".")[0]; + + fs.writeFileSync(f, "

v2

"); + const second = await runCli(["deploy", f, "--update", slug], { home: daemon.home }); + expect(second.code).toBe(0); + const secondUrl = second.stdout.match(/https?:\/\/\S+/)![0]; + expect(secondUrl).toBe(firstUrl); + }); + + it("--name prints the name-based URL name. (regression guard)", async () => { + const f = writeHtmlFile(scratch, "named.html", "

named

"); + const uniqueName = "dashboard" + Math.random().toString(36).slice(2, 8); + const res = await runCli(["deploy", f, "--name", uniqueName], { home: daemon.home }); + expect(res.code).toBe(0); + const url = res.stdout.match(/https?:\/\/\S+/)![0]; + expect(new URL(url).hostname).toMatch(new RegExp(`^${uniqueName}\\.`)); + }); + + it("--protect prints a key line and the URL", async () => { + const f = writeHtmlFile(scratch, "protected.html", "

secret

"); + const res = await runCli(["deploy", f, "--protect"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/https?:\/\/\S+/); + expect(res.stdout.toLowerCase()).toMatch(/key/); + }); + + it("--qr emits QR output", async () => { + const f = writeHtmlFile(scratch, "qr.html", "

qr

"); + const res = await runCli(["deploy", f, "--qr"], { home: daemon.home }); + expect(res.code).toBe(0); + // QR ascii art uses block characters; just assert stdout is much + // longer than a plain deploy (the QR ascii block dominates output). + expect(res.stdout.length).toBeGreaterThan(200); + }); + + it("rejects --name with multiple files", async () => { + const f1 = writeHtmlFile(scratch, "rejn1.html", "

1

"); + const f2 = writeHtmlFile(scratch, "rejn2.html", "

2

"); + const res = await runCli(["deploy", f1, f2, "--name", "nope"], { home: daemon.home }); + expect(res.code).not.toBe(0); + }); + + it("rejects --watch with zero targets", async () => { + const res = await runCli(["deploy", "--watch"], { home: daemon.home, timeoutMs: 5000 }); + expect(res.code).not.toBe(0); + }); + + it("rejects --watch with multiple targets", async () => { + const f1 = writeHtmlFile(scratch, "watch1.html", "

1

"); + const f2 = writeHtmlFile(scratch, "watch2.html", "

2

"); + const res = await runCli(["deploy", f1, f2, "--watch"], { home: daemon.home, timeoutMs: 5000 }); + expect(res.code).not.toBe(0); + }); + }); + + // --------------------------------------------------------------------- + // list + // --------------------------------------------------------------------- + describe("list", () => { + it("shows empty-state message when there are no deployments", async () => { + const empty = await startDaemon(); + try { + const res = await runCli(["list"], { home: empty.home }); + expect(res.code).toBe(0); + expect(res.stdout.toLowerCase()).toMatch(/no deployed/); + } finally { + await empty.stop(); + } + }, 20_000); + + it("lists one line per deployment containing slug and URL", async () => { + const f = writeHtmlFile(scratch, "listtarget.html", "

list me

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const url = deploy.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + const res = await runCli(["list"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toContain(slug); + expect(res.stdout).toContain(url); + }); + }); + + // --------------------------------------------------------------------- + // rm + // --------------------------------------------------------------------- + describe("rm", () => { + it("removes a deployment (exit 0)", async () => { + const f = writeHtmlFile(scratch, "rmtarget.html", "

rm me

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const url = deploy.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + const res = await runCli(["rm", slug], { home: daemon.home }); + expect(res.code).toBe(0); + }); + + it("unknown slug exits non-zero with a Not found message", async () => { + const res = await runCli(["rm", "does-not-exist-slug"], { home: daemon.home }); + expect(res.code).not.toBe(0); + expect(res.stderr.toLowerCase()).toMatch(/not found/); + }); + }); + + // --------------------------------------------------------------------- + // touch + // --------------------------------------------------------------------- + describe("touch", () => { + it("--ttl 7d reports the new expiry", async () => { + const f = writeHtmlFile(scratch, "touchttl.html", "

touch

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const url = deploy.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + const res = await runCli(["touch", slug, "--ttl", "7d"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/7d/); + }); + + it("--ttl 0 reports never-expires", async () => { + const f = writeHtmlFile(scratch, "touchnever.html", "

touch never

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const url = deploy.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + const res = await runCli(["touch", slug, "--ttl", "0"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout.toLowerCase()).toMatch(/never/); + }); + + it("invalid ttl exits non-zero", async () => { + const f = writeHtmlFile(scratch, "touchinvalid.html", "

touch invalid

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const url = deploy.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + const res = await runCli(["touch", slug, "--ttl", "not-a-ttl"], { home: daemon.home }); + expect(res.code).not.toBe(0); + }); + + it("unknown slug exits non-zero", async () => { + const res = await runCli(["touch", "does-not-exist-slug", "--ttl", "7d"], { + home: daemon.home, + }); + expect(res.code).not.toBe(0); + }); + }); + + // --------------------------------------------------------------------- + // rollback + // --------------------------------------------------------------------- + describe("rollback", () => { + it("rolls back after --update and reports the restored version", async () => { + const f = writeHtmlFile(scratch, "rollback1.html", "

rb v1

"); + const first = await runCli(["deploy", f], { home: daemon.home }); + const url = first.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + fs.writeFileSync(f, "

rb v2

"); + const update = await runCli(["deploy", f, "--update", slug], { home: daemon.home }); + expect(update.code).toBe(0); + + const res = await runCli(["rollback", slug], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout.toLowerCase()).toMatch(/version/); + }); + + it("a deployment with no versions exits non-zero", async () => { + const f = writeHtmlFile(scratch, "rollbacknone.html", "

no history

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const url = deploy.stdout.match(/https?:\/\/\S+/)![0]; + const slug = new URL(url).hostname.split(".")[0]; + + const res = await runCli(["rollback", slug], { home: daemon.home }); + expect(res.code).not.toBe(0); + }); + }); + + // --------------------------------------------------------------------- + // status + // --------------------------------------------------------------------- + describe("status", () => { + it("reports running + pid against a live daemon", async () => { + const res = await runCli(["status"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/running/); + expect(res.stdout).toMatch(/pid/i); + }); + + it("--json reports valid JSON with running/healthy true, exit 0", async () => { + const res = await runCli(["status", "--json"], { home: daemon.home }); + expect(res.code).toBe(0); + const data = JSON.parse(res.stdout); + expect(data.running).toBe(true); + expect(data.healthy).toBe(true); + }); + + it("with no daemon, --json exits 1 and reports unhealthy", async () => { + const { home } = tempHome(); + writeConfig(home); + const res = await runCli(["status", "--json"], { home }); + expect(res.code).toBe(1); + const data = JSON.parse(res.stdout); + expect(data.running).toBe(false); + expect(data.healthy).toBe(false); + }); + }); + + // --------------------------------------------------------------------- + // errors without a daemon + // --------------------------------------------------------------------- + describe("errors without a daemon", () => { + it("list against a stopped daemon exits non-zero with actionable message", async () => { + const { home } = tempHome(); + writeConfig(home); + const res = await runCli(["list"], { home }); + expect(res.code).not.toBe(0); + expect(res.stderr).toMatch(/uptool server not running.*uptool serve/); + }); + + it("deploy against a stopped daemon exits non-zero with actionable message", async () => { + const { home } = tempHome(); + writeConfig(home); + const f = writeHtmlFile(scratch, "nodaemon.html", "

no daemon

"); + const res = await runCli(["deploy", f], { home }); + expect(res.code).not.toBe(0); + expect(res.stderr).toMatch(/uptool server not running.*uptool serve/); + }); + }); + + // --------------------------------------------------------------------- + // no config at all + // --------------------------------------------------------------------- + describe("no config at all", () => { + it("running a command with no config.toml exits non-zero and tells user to run uptool init", async () => { + const { home } = tempHome(); + // Deliberately do NOT write a config — home only has an empty .uptool dir. + const res = await runCli(["list"], { home }); + expect(res.code).not.toBe(0); + expect(res.stdout + res.stderr).toMatch(/uptool init/); + }); + }); + + // --------------------------------------------------------------------- + // --version / --help + // --------------------------------------------------------------------- + describe("--version / --help", () => { + it("--version prints 0.3.0", async () => { + const res = await runCli(["--version"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toContain("0.3.0"); + }); + + it("--help lists the commands and does not mention mcp or admin (deleted this cycle)", async () => { + const res = await runCli(["--help"], { home: daemon.home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/deploy/); + expect(res.stdout).toMatch(/list/); + expect(res.stdout).not.toMatch(/\bmcp\b/i); + expect(res.stdout).not.toMatch(/\badmin\b/i); + }); + }); + + describe("list --json", () => { + it("emits parseable JSON on stdout with the observability fields", async () => { + const f = writeHtmlFile(scratch, "jsontarget.html", "

json

"); + const deploy = await runCli(["deploy", f], { home: daemon.home }); + const slug = new URL(deploy.stdout.match(/https?:\/\/\S+/)![0]).hostname.split(".")[0]; + + const res = await runCli(["list", "--json"], { home: daemon.home }); + expect(res.code).toBe(0); + // Must be pure JSON — an LLM agent parses this, so no decorative lines. + const parsed = JSON.parse(res.stdout); + expect(Array.isArray(parsed)).toBe(true); + const entry = parsed.find((e: { slug: string }) => e.slug === slug); + expect(entry).toBeDefined(); + expect(entry).toMatchObject({ slug, hits: 0, last_seen: null, protected: false }); + expect(typeof entry.url).toBe("string"); + }, 20_000); + + it("never leaks the access key of a protected deployment", async () => { + const f = writeHtmlFile(scratch, "secret.html", "

secret

"); + await runCli(["deploy", f, "--protect", "hunter2"], { home: daemon.home }); + const res = await runCli(["list", "--json"], { home: daemon.home }); + expect(res.stdout).not.toContain("hunter2"); + expect(JSON.parse(res.stdout).some((e: { protected: boolean }) => e.protected)).toBe(true); + }, 20_000); + }); + + describe("logs", () => { + // startDaemon runs `serve --foreground` with piped stdio, so no + // ~/.uptool/server.log exists in tests — that file is only written when + // the daemon is backgrounded. `logs` just reads a file, so write one. + function seedLog(home: string, content: string): void { + fs.writeFileSync(path.join(home, ".uptool", "server.log"), content); + } + + it("prints the daemon log", async () => { + const { home } = tempHome(); + writeConfig(home); + seedLog(home, "Public server listening on port 3000\nAPI server listening\n"); + const res = await runCli(["logs"], { home }); + expect(res.code).toBe(0); + expect(res.stdout).toMatch(/listening/i); + }); + + it("respects -n", async () => { + const { home } = tempHome(); + writeConfig(home); + seedLog(home, "one\ntwo\nthree\n"); + const res = await runCli(["logs", "-n", "1"], { home }); + expect(res.code).toBe(0); + expect(res.stdout.trim().split("\n")).toHaveLength(1); + expect(res.stdout.trim()).toBe("three"); + }); + + it("exits non-zero with guidance when no log file exists", async () => { + const { home } = tempHome(); + const res = await runCli(["logs"], { home }); + expect(res.code).not.toBe(0); + expect(res.stderr).toMatch(/uptool serve/); + }); + }); + +}); diff --git a/test/config.test.ts b/test/config.test.ts index 92d8a45..b483c95 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,5 +1,20 @@ -import { describe, it, expect } from "vitest"; -import { parseTtlMs, publicUrl, DEFAULT_CONFIG } from "../src/config/index.js"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + parseTtlMs, + publicUrl, + DEFAULT_CONFIG, + loadConfig, + saveConfig, + loadOrGenerateToken, + saveToken, + tokenPath, + configPath, + resolvePath, + type Config, +} from "../src/config/index.js"; describe("parseTtlMs", () => { it("parses hours", () => { @@ -27,6 +42,28 @@ describe("parseTtlMs", () => { expect(() => parseTtlMs("1y")).toThrow(/Invalid TTL/); expect(() => parseTtlMs("abc")).toThrow(/Invalid TTL/); }); + + it("throws on whitespace", () => { + expect(() => parseTtlMs(" 72h")).toThrow(/Invalid TTL/); + expect(() => parseTtlMs("72h ")).toThrow(/Invalid TTL/); + }); + + it("throws on a bare number with no unit", () => { + expect(() => parseTtlMs("72")).toThrow(/Invalid TTL/); + }); + + it("throws on an unknown unit", () => { + expect(() => parseTtlMs("72s")).toThrow(/Invalid TTL/); + expect(() => parseTtlMs("72w")).toThrow(/Invalid TTL/); + }); + + it("throws on a negative value", () => { + expect(() => parseTtlMs("-5h")).toThrow(/Invalid TTL/); + }); + + it("handles a huge value", () => { + expect(parseTtlMs("999999d")).toBe(999999 * 24 * 60 * 60 * 1000); + }); }); describe("publicUrl", () => { @@ -55,3 +92,153 @@ describe("publicUrl", () => { expect(publicUrl(secureCfg, "abc123de")).toBe("https://abc123de.mydev.com"); }); }); + +// --------------------------------------------------------------------------- +// File-system-touching config functions. +// +// loadConfig/saveConfig/tokenPath/etc. all derive paths from os.homedir(), +// which reads process.env.HOME on Linux. We override HOME to a disposable +// temp dir for every test in this block and restore it unconditionally in +// afterEach — a leak here would let these tests clobber the developer's +// real ~/.uptool. +// --------------------------------------------------------------------------- + +describe("config file I/O (isolated HOME)", () => { + const realHome = process.env.HOME; + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-cfg-test-")); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = realHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("HOME override actually takes effect", () => { + // os.homedir() re-reads process.env.HOME on Linux — confirm it, since + // everything else in this block relies on that being true. + expect(os.homedir()).toBe(tmpHome); + expect(configPath()).toBe(path.join(tmpHome, ".uptool", "config.toml")); + }); + + describe("saveConfig / loadConfig round-trip", () => { + it("round-trips a full config, including cert_file/key_file", () => { + const cfg: Config = { + ...DEFAULT_CONFIG, + base_url: "example.com", + port: 4000, + cert_file: "/etc/certs/cert.pem", + key_file: "/etc/certs/key.pem", + }; + saveConfig(cfg); + const loaded = loadConfig(); + expect(loaded.base_url).toBe("example.com"); + expect(loaded.port).toBe(4000); + expect(loaded.cert_file).toBe("/etc/certs/cert.pem"); + expect(loaded.key_file).toBe("/etc/certs/key.pem"); + }); + + it("omits cert_file/key_file from disk and from the reloaded config when undefined", () => { + const cfg: Config = { ...DEFAULT_CONFIG, base_url: "example.com" }; + expect(cfg.cert_file).toBeUndefined(); + saveConfig(cfg); + + // smol-toml can't serialize `undefined` — saveConfig explicitly filters + // it out. Assert it's actually absent from the written file, not just + // present-but-empty. + const raw = fs.readFileSync(configPath(), "utf8"); + expect(raw).not.toContain("cert_file"); + expect(raw).not.toContain("key_file"); + + const loaded = loadConfig(); + expect(loaded.cert_file).toBeUndefined(); + expect(loaded.key_file).toBeUndefined(); + }); + + it("merges a partial TOML on disk over DEFAULT_CONFIG", () => { + const dir = path.join(tmpHome, ".uptool"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "config.toml"), `base_url = "partial.example.com"\nport = 9999\n`); + + const loaded = loadConfig(); + // Specified keys win + expect(loaded.base_url).toBe("partial.example.com"); + expect(loaded.port).toBe(9999); + // Unspecified keys fall back to defaults + expect(loaded.api_port).toBe(DEFAULT_CONFIG.api_port); + expect(loaded.ttl).toBe(DEFAULT_CONFIG.ttl); + expect(loaded.max_versions).toBe(DEFAULT_CONFIG.max_versions); + }); + + it("throws with 'uptool init' guidance when config file is missing", () => { + expect(() => loadConfig()).toThrow(/uptool init/); + }); + + it("malformed TOML — document actual behaviour", () => { + const dir = path.join(tmpHome, ".uptool"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "config.toml"), `this is not valid toml === [[[`); + + // FINDING: loadConfig does not wrap the smol-toml parse error, so a + // malformed config.toml surfaces smol-toml's raw parser error (not the + // friendly "Run: uptool init" guidance a user gets for a missing file). + // Not fixed here per instructions — src/ is off-limits for this task. + expect(() => loadConfig()).toThrow(); + }); + }); + + describe("token handling", () => { + it("generates a 64-char hex token when absent", () => { + const token = loadOrGenerateToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("reuses an existing token instead of generating a new one", () => { + const first = loadOrGenerateToken(); + const second = loadOrGenerateToken(); + expect(second).toBe(first); + }); + + it("saveToken writes the token file with mode 0600", () => { + saveToken("abc123"); + const mode = fs.statSync(tokenPath()).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("re-chmods an existing token file that was loosened to 0644", () => { + saveToken("abc123"); + fs.chmodSync(tokenPath(), 0o644); + expect(fs.statSync(tokenPath()).mode & 0o777).toBe(0o644); + + const token = loadOrGenerateToken(); + expect(token).toBe("abc123"); + expect(fs.statSync(tokenPath()).mode & 0o777).toBe(0o600); + }); + + it("generates a fresh token when the existing token file is empty", () => { + const dir = path.join(tmpHome, ".uptool"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(tokenPath(), "", { mode: 0o600 }); + + const token = loadOrGenerateToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + }); +}); + +describe("resolvePath", () => { + it("expands a leading ~ to the home directory", () => { + expect(resolvePath("~/foo/bar")).toBe(path.join(os.homedir(), "foo/bar")); + }); + + it("leaves absolute paths unchanged", () => { + expect(resolvePath("/abs/path")).toBe("/abs/path"); + }); + + it("leaves relative paths unchanged", () => { + expect(resolvePath("rel/path")).toBe("rel/path"); + }); +}); diff --git a/test/deploy.test.ts b/test/deploy.test.ts new file mode 100644 index 0000000..1181e0f --- /dev/null +++ b/test/deploy.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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 { PassThrough } from "node:stream"; +import { walkDir, buildBody } from "../src/commands/deploy.js"; +import { startDaemon, runCli, type Daemon } from "./helpers.js"; + +// Partial mock: keep every real export of storage/index.js, but let +// validateBundlePath reject a specific fixture filename so we can exercise +// the "skip invalid path" branch in buildBody without needing a directory +// traversal that walkDir itself could never produce (walkDir already skips +// dotfiles/dot-dirs and never emits ".." segments — see report). +vi.mock("../src/storage/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateBundlePath: (p: string) => (p === "blocked.txt" ? false : actual.validateBundlePath(p)), + }; +}); + +function mkTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "uptool-deploy-test-")); +} + +// --------------------------------------------------------------------------- +// walkDir +// --------------------------------------------------------------------------- + +describe("walkDir", () => { + let dir: string; + + beforeEach(() => { + dir = mkTmpDir(); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("skips dotfiles and dot-directories", () => { + fs.writeFileSync(path.join(dir, ".env"), "secret"); + fs.mkdirSync(path.join(dir, ".git")); + fs.writeFileSync(path.join(dir, ".git", "config"), "x"); + fs.writeFileSync(path.join(dir, "keep.txt"), "ok"); + + const entries = walkDir(dir, dir); + expect(entries.map((e) => e.rel)).toEqual(["keep.txt"]); + }); + + it("skips node_modules", () => { + fs.mkdirSync(path.join(dir, "node_modules")); + fs.writeFileSync(path.join(dir, "node_modules", "pkg.js"), "x"); + fs.writeFileSync(path.join(dir, "app.js"), "x"); + + const entries = walkDir(dir, dir); + expect(entries.map((e) => e.rel)).toEqual(["app.js"]); + }); + + it("recurses into subdirectories with forward-slash relative paths", () => { + fs.mkdirSync(path.join(dir, "a", "b"), { recursive: true }); + fs.writeFileSync(path.join(dir, "a", "b", "deep.txt"), "x"); + + const entries = walkDir(dir, dir); + expect(entries.map((e) => e.rel)).toEqual(["a/b/deep.txt"]); + expect(entries[0].rel).not.toContain("\\"); + }); + + it("returns an empty array for an empty directory", () => { + expect(walkDir(dir, dir)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// buildBody +// --------------------------------------------------------------------------- + +describe("buildBody", () => { + let dir: string; + let exitSpy: ReturnType; + let errorSpy: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + dir = mkTmpDir(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((() => undefined) as unknown) as never); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + // -- single file ------------------------------------------------------ + + it("single .html file yields { html, filename }", async () => { + const file = path.join(dir, "page.html"); + fs.writeFileSync(file, "

hi

"); + + const body = await buildBody(file); + expect(body).toEqual({ html: "

hi

", filename: "page.html" }); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("non-existent path hits the error path", async () => { + const missing = path.join(dir, "nope.html"); + await buildBody(missing); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Not found")); + }); + + // -- entry-point resolution ------------------------------------------- + + it("directory with index.html: entry is index.html", async () => { + fs.writeFileSync(path.join(dir, "index.html"), "

root

"); + fs.writeFileSync(path.join(dir, "other.html"), "

other

"); + + const body = await buildBody(dir); + expect(body.entry).toBe("index.html"); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("directory with exactly one top-level .html and no index.html: that file is entry", async () => { + fs.writeFileSync(path.join(dir, "app.html"), "

app

"); + + const body = await buildBody(dir); + expect(body.entry).toBe("app.html"); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("directory with no top-level html: error path", async () => { + fs.writeFileSync(path.join(dir, "data.json"), "{}"); + + await buildBody(dir); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("No HTML files found")); + }); + + it("html only in a subdirectory still errors (not a top-level candidate)", async () => { + fs.mkdirSync(path.join(dir, "sub")); + fs.writeFileSync(path.join(dir, "sub", "page.html"), "

nested

"); + + await buildBody(dir); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("No HTML files found")); + }); + + it("directory with multiple top-level html files and no index.html: ambiguous error", async () => { + fs.writeFileSync(path.join(dir, "a.html"), "

a

"); + fs.writeFileSync(path.join(dir, "b.html"), "

b

"); + + await buildBody(dir); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Multiple HTML files")); + }); + + it("empty directory hits the 'directory is empty' error path", async () => { + await buildBody(dir); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy.mock.calls.some((c) => String(c[0]).includes("empty"))).toBe(true); + }); + + // -- bundle assembly ---------------------------------------------------- + + it("base64-encodes files keyed by relative path", async () => { + fs.writeFileSync(path.join(dir, "index.html"), "

root

"); + fs.mkdirSync(path.join(dir, "css")); + fs.writeFileSync(path.join(dir, "css", "style.css"), "body{margin:0}"); + + const body = await buildBody(dir); + const files = body.files as Record; + expect(files["index.html"]).toBe(Buffer.from("

root

").toString("base64")); + expect(files["css/style.css"]).toBe(Buffer.from("body{margin:0}").toString("base64")); + expect(body.filename).toBe(path.basename(dir)); + }); + + it("binary files round-trip byte-exactly through base64", async () => { + fs.writeFileSync(path.join(dir, "index.html"), "

root

"); + // Minimal PNG signature + a few bytes, not a valid image but real binary content + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0x10, 0x7f]); + fs.writeFileSync(path.join(dir, "logo.png"), pngBytes); + + const body = await buildBody(dir); + const files = body.files as Record; + const roundTripped = Buffer.from(files["logo.png"], "base64"); + expect(roundTripped.equals(pngBytes)).toBe(true); + }); + + it("skips paths rejected by validateBundlePath with a warning", async () => { + fs.writeFileSync(path.join(dir, "index.html"), "

root

"); + fs.writeFileSync(path.join(dir, "blocked.txt"), "should not be included"); + + const body = await buildBody(dir); + const files = body.files as Record; + expect(files["blocked.txt"]).toBeUndefined(); + expect(files["index.html"]).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("blocked.txt")); + }); + + // -- stdin ---------------------------------------------------------------- + + describe("stdin", () => { + let originalStdin: NodeJS.ReadStream; + + beforeEach(() => { + originalStdin = process.stdin; + }); + + afterEach(() => { + Object.defineProperty(process, "stdin", { value: originalStdin, configurable: true }); + }); + + it("reads stdin content and names it stdin.html", async () => { + const fake = new PassThrough(); + Object.defineProperty(process, "stdin", { value: fake, configurable: true }); + + const promise = buildBody(undefined); + fake.end("

from stdin

"); + const body = await promise; + + expect(body).toEqual({ html: "

from stdin

", filename: "stdin.html" }); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("empty/whitespace-only stdin hits the error path", async () => { + const fake = new PassThrough(); + Object.defineProperty(process, "stdin", { value: fake, configurable: true }); + + const promise = buildBody(undefined); + fake.end(" \n "); + await promise; + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("No content provided")); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: a deployed directory bundle actually serves +// --------------------------------------------------------------------------- + +function httpGet( + port: number, + host: string, + urlPath: string +): Promise<{ status: number; body: string; contentType?: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: "127.0.0.1", port, path: urlPath, method: "GET", headers: { Host: host } }, + (res) => { + let raw = ""; + res.on("data", (c) => (raw += c)); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, body: raw, contentType: res.headers["content-type"] }) + ); + } + ); + req.on("error", reject); + req.end(); + }); +} + +describe("deploy integration (directory bundle)", () => { + let daemon: Daemon; + let dir: string; + + beforeEach(async () => { + daemon = await startDaemon(); + dir = mkTmpDir(); + }); + + afterEach(async () => { + fs.rmSync(dir, { recursive: true, force: true }); + await daemon.stop(); + }); + + it("serves the entry at / and a nested asset at its path", async () => { + fs.writeFileSync(path.join(dir, "index.html"), "

Hello Bundle

"); + fs.mkdirSync(path.join(dir, "assets")); + fs.writeFileSync(path.join(dir, "assets", "style.css"), "body{color:red}"); + + const result = await runCli(["deploy", dir], { home: daemon.home, timeoutMs: 15000 }); + expect(result.code).toBe(0); + + const match = result.stdout.match(/https?:\/\/([^./]+)\.test\.local/); + expect(match).not.toBeNull(); + const slug = match![1]; + const host = `${slug}.test.local`; + + const root = await httpGet(daemon.pubPort, host, "/"); + expect(root.status).toBe(200); + expect(root.body).toContain("Hello Bundle"); + expect(root.contentType).toContain("text/html"); + + const asset = await httpGet(daemon.pubPort, host, "/assets/style.css"); + expect(asset.status).toBe(200); + expect(asset.body).toBe("body{color:red}"); + expect(asset.contentType).toContain("text/css"); + }); +}); diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 0000000..62cafde --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,30 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Sweep the temp dirs the suite creates. + * + * Individual tests clean up after themselves, and test/helpers.ts registers a + * process-exit handler as a second line of defence — but neither survives a + * worker being killed, so leftovers still accumulate in /tmp across runs. This + * teardown runs once after the whole suite and reclaims whatever is left. + * + * Only touches directories matching the suite's own `uptool-*-` prefixes + * inside the OS temp dir; nothing else is in scope. + */ +export function teardown(): void { + const tmp = os.tmpdir(); + let removed = 0; + for (const name of fs.readdirSync(tmp)) { + if (!/^uptool-[a-z-]+-/.test(name)) continue; + try { + fs.rmSync(path.join(tmp, name), { recursive: true, force: true }); + removed++; + } catch { + // Another run may own it, or it may be gone already — never fail the + // suite over cleanup. + } + } + if (removed > 0) console.log(`[test teardown] removed ${removed} temp dir(s)`); +} diff --git a/test/helpers.test.ts b/test/helpers.test.ts new file mode 100644 index 0000000..6536664 --- /dev/null +++ b/test/helpers.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, 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 { startDaemon, runCli, type Daemon } from "./helpers.js"; + +/** + * Smoke test for test/helpers.ts itself: proves the daemon-spawning + + * CLI-running plumbing works end to end. Not the CLI suite — just enough to + * catch a broken foundation before four other test suites build on it. + */ +describe("helpers smoke test", () => { + let daemon: Daemon | undefined; + + afterAll(async () => { + await daemon?.stop(); + }); + + it("deploys via the real CLI against a spawned daemon and serves the result", async () => { + daemon = await startDaemon(); + + const htmlPath = path.join(os.tmpdir(), `uptool-helpers-test-${Date.now()}.html`); + fs.writeFileSync(htmlPath, "

hello from helpers.test.ts

"); + + const result = await runCli(["deploy", htmlPath], { home: daemon.home }); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/https?:\/\//); + + const match = result.stdout.match(/https?:\/\/\S+/); + expect(match).not.toBeNull(); + const url = new URL(match![0]); + + const body = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: daemon!.pubPort, + path: url.pathname, + method: "GET", + headers: { Host: url.hostname }, + }, + (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => resolve(data)); + } + ); + req.on("error", reject); + req.end(); + }); + + expect(body).toContain("hello from helpers.test.ts"); + + fs.rmSync(htmlPath, { force: true }); + }, 20_000); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..adf4ac2 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,282 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as net from "node:net"; +import * as child_process from "node:child_process"; + +/** + * Shared test infrastructure for spawning the real CLI/daemon as child + * processes. Everything here isolates HOME so tests never touch the + * developer's real ~/.uptool (config, token, deployed files). + */ + +const ROOT = path.join(__dirname, ".."); +export const CLI_PATH = path.join(ROOT, "dist", "cli.js"); + +/** + * Throwaway directory to use as HOME for a spawned CLI process. + * + * GUARANTEE: src/config/index.ts derives every path it touches + * (configDir/configPath/tokenPath/storage_path default/etc.) from + * os.homedir(), and Node's os.homedir() on Linux/macOS reads the HOME env + * var. Every helper here that spawns a child process sets HOME to this + * directory, so nothing spawned through these helpers can read or write the + * real ~/.uptool. This does NOT protect you if you call functions from + * src/config directly in-process in the test runner itself (that process's + * real HOME is unaffected but so is os.homedir() — don't call loadConfig() + * etc. without an isolated HOME of your own in that case). + */ +export function tempHome(): { home: string; cleanup: () => void } { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-test-")); + fs.mkdirSync(path.join(home, ".uptool"), { recursive: true }); + liveHomes.add(home); + return { + home, + cleanup: () => { + liveHomes.delete(home); + fs.rmSync(home, { recursive: true, force: true }); + }, + }; +} + +// Safety net. A test that throws before its cleanup(), or a daemon whose stop() +// never runs, would otherwise leak a temp dir and a live `serve` process for +// every such case. These track what this process created so it can be reclaimed +// on the way out regardless of how the test ended. +const liveHomes = new Set(); +const liveDaemons = new Set(); + +process.on("exit", () => { + for (const child of liveDaemons) { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + } + for (const home of liveHomes) { + try { + fs.rmSync(home, { recursive: true, force: true }); + } catch { + // best effort — the exit handler must not throw + } + } +}); + +export interface ConfigOverrides { + base_url?: string; + port?: number; + api_port?: number; + ttl?: string; + storage_path?: string; + token?: string; +} + +/** + * Writes a valid config.toml + token file (mode 0600) into a temp home, so a + * spawned CLI has something to load. Returns the token used. + */ +export function writeConfig(home: string, overrides: ConfigOverrides = {}): string { + const uptoolDir = path.join(home, ".uptool"); + fs.mkdirSync(uptoolDir, { recursive: true }); + + const base_url = overrides.base_url ?? "test.local"; + const port = overrides.port ?? 0; + const api_port = overrides.api_port ?? 0; + const ttl = overrides.ttl ?? "72h"; + const storage_path = overrides.storage_path ?? path.join(uptoolDir, "files"); + const token = overrides.token ?? "test-token-" + Math.random().toString(36).slice(2); + + const configToml = [ + `base_url = "${base_url}"`, + `port = ${port}`, + `api_port = ${api_port}`, + `ttl = "${ttl}"`, + `storage_path = ${JSON.stringify(storage_path)}`, + ].join("\n"); + fs.writeFileSync(path.join(uptoolDir, "config.toml"), configToml); + fs.writeFileSync(path.join(uptoolDir, "token"), token, { mode: 0o600 }); + fs.chmodSync(path.join(uptoolDir, "token"), 0o600); + + return token; +} + +/** + * Reserve an ephemeral TCP port by binding to port 0 and reading it back, + * then closing the socket. Note: there's an inherent race between closing + * this probe socket and a later process rebinding the same port number — a + * concurrent process could grab it first. Prefer letting servers themselves + * bind port 0 and report back their address where that's possible (e.g. + * in-process http.Server via server.listen(0) + server.address().port); + * this helper is for cases where the port must be chosen ahead of spawning + * a child process (e.g. passed via config.toml before `serve` starts). + */ +export function freePort(): 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. */ +export 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(); + }); +} + +function assertCliBuilt(): void { + if (!fs.existsSync(CLI_PATH)) { + throw new Error( + `dist/cli.js not found at ${CLI_PATH}. The test suite runs the built CLI as a ` + + `child process — run "npm run build" first (or just "npm test", which builds ` + + `automatically).` + ); + } +} + +export interface Daemon { + apiPort: number; + pubPort: number; + token: string; + home: string; + stop: () => Promise; +} + +export interface StartDaemonOpts extends ConfigOverrides { + /** Extra env vars to set on the spawned process. */ + env?: NodeJS.ProcessEnv; +} + +/** + * Spawns `node dist/cli.js serve --foreground` with an isolated HOME and + * ephemeral ports, waits until both the API and public servers are actually + * accepting connections, and returns the resolved ports plus a stop() + * that kills the process and waits for exit. Never leaves the process + * running if startup fails partway through. + */ +export async function startDaemon(opts: StartDaemonOpts = {}): Promise { + assertCliBuilt(); + + const { home, cleanup } = tempHome(); + const pubPort = opts.port ?? (await freePort()); + const apiPort = opts.api_port ?? (await freePort()); + const token = writeConfig(home, { ...opts, port: pubPort, api_port: apiPort }); + + const child = child_process.spawn(process.execPath, [CLI_PATH, "serve", "--foreground"], { + env: { ...process.env, ...opts.env, HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + liveDaemons.add(child); + + let stopped = false; + const stop = async (): Promise => { + if (stopped) return; + stopped = true; + liveDaemons.delete(child); + if (!child.killed && child.exitCode === null) { + child.kill("SIGTERM"); + await new Promise((resolve) => { + const t = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + resolve(); + }, 3000); + child.once("exit", () => { + clearTimeout(t); + resolve(); + }); + }); + } + cleanup(); + }; + + try { + await waitForPort(apiPort); + await waitForPort(pubPort); + } catch (err) { + await stop(); + throw err; + } + + return { apiPort, pubPort, token, home, stop }; +} + +export interface RunCliResult { + stdout: string; + stderr: string; + code: number | null; +} + +export interface RunCliOpts { + home?: string; + input?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; +} + +/** + * Runs `node dist/cli.js ` with an isolated HOME (a fresh tempHome() + * unless one is passed in `opts.home`, e.g. to talk to a daemon started with + * startDaemon()), optional stdin input, and returns { stdout, stderr, code }. + */ +export function runCli(args: string[], opts: RunCliOpts = {}): Promise { + assertCliBuilt(); + + const home = opts.home ?? tempHome().home; + + return new Promise((resolve, reject) => { + const child = child_process.spawn(process.execPath, [CLI_PATH, ...args], { + env: { ...process.env, ...opts.env, HOME: home }, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c) => (stdout += c)); + child.stderr.on("data", (c) => (stderr += c)); + + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`runCli timed out after ${opts.timeoutMs ?? 15_000}ms: ${args.join(" ")}`)); + }, opts.timeoutMs ?? 15_000); + + child.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + if (opts.input !== undefined) child.stdin.write(opts.input); + child.stdin.end(); + + child.on("exit", (code) => { + clearTimeout(timeout); + resolve({ stdout, stderr, code }); + }); + }); +} diff --git a/test/public.test.ts b/test/public.test.ts index bfebd5f..6df5415 100644 --- a/test/public.test.ts +++ b/test/public.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } 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 { ManifestStore } from "../src/storage/index.js"; -import { createPublicServer } from "../src/server/public.js"; +import { WebSocket } from "ws"; +import { ManifestStore, type Manifest } from "../src/storage/index.js"; +import { createPublicServer, RateLimiter } from "../src/server/public.js"; +import { WsManager } from "../src/server/ws.js"; import { DEFAULT_CONFIG } from "../src/config/index.js"; // Minimal config for tests @@ -21,7 +23,8 @@ function makeRequest( server: http.Server, host: string, urlPath = "/", - extraHeaders: http.OutgoingHttpHeaders = {} + extraHeaders: http.OutgoingHttpHeaders = {}, + method = "GET" ): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> { return new Promise((resolve, reject) => { const addr = server.address() as { port: number }; @@ -30,6 +33,7 @@ function makeRequest( hostname: "127.0.0.1", port: addr.port, path: urlPath, + method, headers: { host, ...extraHeaders }, }, (res) => { @@ -63,6 +67,12 @@ describe("Public server", () => { afterEach( () => new Promise((resolve) => { + // Flush any pending debounced manifest write before deleting tmpDir — + // otherwise the 500ms debounce timer from a store()/update() call in + // this test can fire after tmpDir is gone, throwing an uncaught + // ENOENT from inside the timer (a pre-existing race in ManifestStore + // flushing; flushing eagerly here just avoids tripping it in tests). + store.flushNow(); server.close(() => { fs.rmSync(tmpDir, { recursive: true }); resolve(); @@ -143,6 +153,24 @@ describe("Public server", () => { expect(body).toContain("uptool"); }); + it("returns identical Content-Length and empty body for HEAD", async () => { + const slug = store.store("

Hello

", null, "index.html", "t.html"); + const get = await makeRequest(server, `${slug}.test.local`, "/", {}, "GET"); + const head = await makeRequest(server, `${slug}.test.local`, "/", {}, "HEAD"); + expect(head.status).toBe(200); + expect(head.headers["content-length"]).toBe(get.headers["content-length"]); + expect(head.body).toBe(""); + }); + + it("returns 405 with Allow header for unsupported methods", async () => { + const slug = store.store("

Hello

", null, "index.html", "t.html"); + const { status, headers } = await makeRequest( + server, `${slug}.test.local`, "/", {}, "POST" + ); + expect(status).toBe(405); + expect(headers["allow"]).toBe("GET, HEAD"); + }); + it("resolves named slug", async () => { store.store("

Named

", null, "index.html", "n.html", "myapp"); const { status, body } = await makeRequest(server, "myapp.test.local"); @@ -221,4 +249,337 @@ describe("Public server", () => { expect((await makeRequest(server, `${slug}.test.local`)).status).toBe(200); }); }); + + describe("live-reload websocket auth", () => { + it("rejects /__lr upgrade on a protected slug without credentials", async () => { + const wsManager = new WsManager(server, TEST_CONFIG, store); + const slug = store.store( + "

Secret

", null, "index.html", "s.html", undefined, "sekret" + ); + const addr = server.address() as { port: number }; + const ws = new WebSocket(`ws://127.0.0.1:${addr.port}/__lr`, { + headers: { host: `${slug}.test.local` }, + }); + await new Promise((resolve) => { + ws.on("unexpected-response", () => resolve()); + ws.on("error", () => resolve()); + ws.on("open", () => resolve()); + }); + expect(ws.readyState).not.toBe(WebSocket.OPEN); + wsManager.close(); + }); + + it("accepts /__lr upgrade on a protected slug with correct Basic Auth", async () => { + const wsManager = new WsManager(server, TEST_CONFIG, store); + const slug = store.store( + "

Secret

", null, "index.html", "s.html", undefined, "sekret" + ); + const addr = server.address() as { port: number }; + const auth = `Basic ${Buffer.from("u:sekret").toString("base64")}`; + const ws = new WebSocket(`ws://127.0.0.1:${addr.port}/__lr`, { + headers: { host: `${slug}.test.local`, authorization: auth }, + }); + await new Promise((resolve, reject) => { + ws.on("open", () => resolve()); + ws.on("error", reject); + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + wsManager.close(); + }); + }); + + // ------------------------------------------------------------------------- + // Cache-Control per content type + // ------------------------------------------------------------------------- + + describe("Cache-Control", () => { + it("protected deployments get 'private, no-store' regardless of content type", async () => { + const slug = store.store( + "

Secret

", null, "index.html", "s.html", undefined, "sekret" + ); + const auth = { Authorization: `Basic ${Buffer.from("u:sekret").toString("base64")}` }; + const { headers } = await makeRequest(server, `${slug}.test.local`, "/", auth); + expect(headers["cache-control"]).toBe("private, no-store"); + }); + + it("HTML gets 'no-cache'", async () => { + const slug = store.store("

hi

", null, "index.html", "t.html"); + const { headers } = await makeRequest(server, `${slug}.test.local`); + expect(headers["cache-control"]).toBe("no-cache"); + }); + + it("other assets get 'public, max-age=3600'", async () => { + const files = { + "index.html": Buffer.from("

hi

").toString("base64"), + "style.css": Buffer.from("body{}").toString("base64"), + }; + const slug = store.store(null, files, "index.html", "site"); + const { headers } = await makeRequest(server, `${slug}.test.local`, "/style.css"); + expect(headers["cache-control"]).toBe("public, max-age=3600"); + }); + }); + + // ------------------------------------------------------------------------- + // Basic Auth edge cases (beyond the "protected deployments" block above) + // ------------------------------------------------------------------------- + + describe("Basic Auth edge cases", () => { + it("malformed/undecodable Authorization header returns 401, no crash", async () => { + const slug = store.store( + "

Secret

", null, "index.html", "s.html", undefined, "sekret" + ); + const { status } = await makeRequest(server, `${slug}.test.local`, "/", { + Authorization: "NotBasic garbage!!!", + }); + expect(status).toBe(401); + + const { status: status2 } = await makeRequest(server, `${slug}.test.local`, "/", { + Authorization: "Basic", + }); + expect(status2).toBe(401); + }); + + it("a password of the wrong length returns 401 without throwing", async () => { + const slug = store.store( + "

Secret

", null, "index.html", "s.html", undefined, "sekret" + ); + const shortPass = { + Authorization: `Basic ${Buffer.from("u:short").toString("base64")}`, + }; + const longPass = { + Authorization: `Basic ${Buffer.from("u:way-too-long-password").toString("base64")}`, + }; + expect((await makeRequest(server, `${slug}.test.local`, "/", shortPass)).status).toBe(401); + expect((await makeRequest(server, `${slug}.test.local`, "/", longPass)).status).toBe(401); + }); + }); + + // ------------------------------------------------------------------------- + // RateLimiter (exported from public.ts) + // ------------------------------------------------------------------------- + + describe("RateLimiter", () => { + it("allows up to the limit then blocks", () => { + const rl = new RateLimiter(3); + expect(rl.allow("1.2.3.4")).toBe(true); + expect(rl.allow("1.2.3.4")).toBe(true); + expect(rl.allow("1.2.3.4")).toBe(true); + expect(rl.allow("1.2.3.4")).toBe(false); + }); + + it("a 429 response carries Retry-After", async () => { + const limitedConfig = { ...TEST_CONFIG, rate_limit_rpm: 1 }; + const limitedServer = createPublicServer(limitedConfig, store); + await new Promise((r) => limitedServer.listen(0, "127.0.0.1", r)); + + const slug = store.store("

hi

", null, "index.html", "t.html"); + await makeRequest(limitedServer, `${slug}.test.local`); + const second = await makeRequest(limitedServer, `${slug}.test.local`); + expect(second.status).toBe(429); + expect(second.headers["retry-after"]).toBe("60"); + + await new Promise((r) => limitedServer.close(r)); + }); + + it("limit <= 0 disables it entirely", () => { + const rl = new RateLimiter(0); + for (let i = 0; i < 1000; i++) expect(rl.allow("same-ip")).toBe(true); + const rlNeg = new RateLimiter(-5); + expect(rlNeg.allow("same-ip")).toBe(true); + }); + + it("prune() drops expired windows so the map can't grow unbounded", () => { + vi.useFakeTimers(); + try { + const rl = new RateLimiter(2); + rl.allow("1.1.1.1"); + rl.allow("2.2.2.2"); + expect((rl as unknown as { hits: Map }).hits.size).toBe(2); + + vi.advanceTimersByTime(61_000); // past the 60s fixed window + rl.prune(); + expect((rl as unknown as { hits: Map }).hits.size).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + }); + + // ------------------------------------------------------------------------- + // trust_proxy + // ------------------------------------------------------------------------- + + describe("trust_proxy", () => { + it("with trust_proxy ON, rate limiting keys off X-Forwarded-For (first entry)", async () => { + const trustingConfig = { ...TEST_CONFIG, rate_limit_rpm: 1, trust_proxy: true }; + const trustingServer = createPublicServer(trustingConfig, store); + await new Promise((r) => trustingServer.listen(0, "127.0.0.1", r)); + + const slug = store.store("

hi

", null, "index.html", "t.html"); + // Two different spoofed X-Forwarded-For IPs must be tracked separately + const a1 = await makeRequest(trustingServer, `${slug}.test.local`, "/", { + "X-Forwarded-For": "9.9.9.1, 5.5.5.5", + }); + const a2 = await makeRequest(trustingServer, `${slug}.test.local`, "/", { + "X-Forwarded-For": "9.9.9.2, 5.5.5.5", + }); + expect(a1.status).toBe(200); + expect(a2.status).toBe(200); // different forwarded IP, own quota + + // Same forwarded IP hits its own limit on the second request + const b1 = await makeRequest(trustingServer, `${slug}.test.local`, "/", { + "X-Forwarded-For": "9.9.9.3", + }); + const b2 = await makeRequest(trustingServer, `${slug}.test.local`, "/", { + "X-Forwarded-For": "9.9.9.3", + }); + expect(b1.status).toBe(200); + expect(b2.status).toBe(429); + + await new Promise((r) => trustingServer.close(r)); + }); + + it("with trust_proxy OFF, X-Forwarded-For is ignored (socket address used)", async () => { + const ignoringConfig = { ...TEST_CONFIG, rate_limit_rpm: 1, trust_proxy: false }; + const ignoringServer = createPublicServer(ignoringConfig, store); + await new Promise((r) => ignoringServer.listen(0, "127.0.0.1", r)); + + const slug = store.store("

hi

", null, "index.html", "t.html"); + // Spoofing different X-Forwarded-For values must NOT bypass the limit — + // both requests come from the same real socket (127.0.0.1 test client). + const c1 = await makeRequest(ignoringServer, `${slug}.test.local`, "/", { + "X-Forwarded-For": "1.1.1.1", + }); + const c2 = await makeRequest(ignoringServer, `${slug}.test.local`, "/", { + "X-Forwarded-For": "2.2.2.2", + }); + expect(c1.status).toBe(200); + expect(c2.status).toBe(429); + + await new Promise((r) => ignoringServer.close(r)); + }); + }); + + // ------------------------------------------------------------------------- + // Extensionless URL resolution / directory index + // ------------------------------------------------------------------------- + + describe("extensionless URL resolution", () => { + it("serves about.html for /about", async () => { + const files = { + "index.html": Buffer.from("

home

").toString("base64"), + "about.html": Buffer.from("

about

").toString("base64"), + }; + const slug = store.store(null, files, "index.html", "site"); + const { status, body } = await makeRequest(server, `${slug}.test.local`, "/about"); + expect(status).toBe(200); + expect(body).toBe("

about

"); + }); + + it("serves index.html inside a directory path", async () => { + const files = { + "index.html": Buffer.from("

home

").toString("base64"), + "blog/index.html": Buffer.from("

blog

").toString("base64"), + }; + const slug = store.store(null, files, "index.html", "site"); + const { status, body } = await makeRequest(server, `${slug}.test.local`, "/blog"); + expect(status).toBe(200); + expect(body).toBe("

blog

"); + }); + + it("404s for a directory with no index.html inside it", async () => { + const files = { + "index.html": Buffer.from("

home

").toString("base64"), + "empty/placeholder.txt": Buffer.from("x").toString("base64"), + }; + const slug = store.store(null, files, "index.html", "site"); + const { status } = await makeRequest(server, `${slug}.test.local`, "/empty"); + expect(status).toBe(404); + }); + }); + + // ------------------------------------------------------------------------- + // Expired deployment + // ------------------------------------------------------------------------- + + it("returns 404 for an expired deployment without deleting it", async () => { + const slug = store.store("

gone

", null, "index.html", "t.html"); + (store as unknown as { manifest: Manifest }).manifest[slug].expires = Date.now() - 1000; + + const { status } = await makeRequest(server, `${slug}.test.local`); + expect(status).toBe(404); + // readFile refuses to serve past expiry without deleting — entry persists + expect(store.getEntry(slug)).not.toBeNull(); + }); + + // ------------------------------------------------------------------------- + // Handler throw -> 500, not a dead daemon + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // View counting (recordHit) + // ------------------------------------------------------------------------- + + describe("view counting", () => { + it("an HTML page view increments hits by exactly one", async () => { + const slug = store.store("

hi

", null, "index.html", "t.html"); + await makeRequest(server, `${slug}.test.local`); + expect(store.getEntry(slug)!.hits).toBe(1); + }); + + it("fetching a CSS/JS/image asset does not increment", async () => { + const files = { + "index.html": Buffer.from("

hi

").toString("base64"), + "style.css": Buffer.from("body{}").toString("base64"), + "app.js": Buffer.from("console.log(1)").toString("base64"), + }; + const slug = store.store(null, files, "index.html", "site"); + await makeRequest(server, `${slug}.test.local`, "/style.css"); + await makeRequest(server, `${slug}.test.local`, "/app.js"); + expect(store.getEntry(slug)!.hits).toBeUndefined(); + }); + + it("a 404 does not increment", async () => { + const slug = store.store("

hi

", null, "index.html", "t.html"); + await makeRequest(server, `${slug}.test.local`, "/nope.html"); + expect(store.getEntry(slug)!.hits).toBeUndefined(); + }); + + it("a HEAD request does not increment", async () => { + const slug = store.store("

hi

", null, "index.html", "t.html"); + await makeRequest(server, `${slug}.test.local`, "/", {}, "HEAD"); + expect(store.getEntry(slug)!.hits).toBeUndefined(); + }); + + it("a 401 on a protected deployment does not increment, a successful authenticated view does", async () => { + const slug = store.store( + "

Secret

", null, "index.html", "s.html", undefined, "sekret" + ); + await makeRequest(server, `${slug}.test.local`); + expect(store.getEntry(slug)!.hits).toBeUndefined(); + + const auth = { Authorization: `Basic ${Buffer.from("u:sekret").toString("base64")}` }; + await makeRequest(server, `${slug}.test.local`, "/", auth); + expect(store.getEntry(slug)!.hits).toBe(1); + }); + }); + + it("a handler that throws produces a 500, not a dead daemon", async () => { + const slug = store.store("

hi

", null, "index.html", "t.html"); + const original = store.readFile.bind(store); + store.readFile = () => { + throw new Error("boom"); + }; + try { + const { status } = await makeRequest(server, `${slug}.test.local`); + expect(status).toBe(500); + } finally { + store.readFile = original; + } + + // Daemon survives — a normal request right after still works. + const { status } = await makeRequest(server, `${slug}.test.local`); + expect(status).toBe(200); + }); }); diff --git a/test/status.test.ts b/test/status.test.ts new file mode 100644 index 0000000..7d833f9 --- /dev/null +++ b/test/status.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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 { readLogTail, statusCommand } from "../src/commands/status.js"; +import { logsCommand, followLog } from "../src/commands/logs.js"; + +const CHUNK = 16 * 1024; + +describe("readLogTail", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-logtail-")); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function write(name: string, content: string): string { + const p = path.join(dir, name); + fs.writeFileSync(p, content); + return p; + } + + it("returns all lines when file is smaller than the chunk (common case)", () => { + const p = write("small.log", "one\ntwo\nthree\n"); + expect(readLogTail(p, 10)).toBe("one\ntwo\nthree"); + }); + + it("returns only the last N lines when file has more lines than requested", () => { + const p = write("many.log", "a\nb\nc\nd\ne\n"); + expect(readLogTail(p, 2)).toBe("d\ne"); + }); + + it("returns whatever exists when file has fewer lines than requested", () => { + const p = write("few.log", "only-one\n"); + expect(readLogTail(p, 10)).toBe("only-one"); + }); + + it("handles an empty file without crashing", () => { + const p = write("empty.log", ""); + expect(readLogTail(p, 10)).toBe(""); + }); + + it("handles a file with no trailing newline", () => { + const p = write("no-trailing.log", "line1\nline2"); + expect(readLogTail(p, 10)).toBe("line1\nline2"); + }); + + it("does not produce a phantom empty last line when file ends with a newline", () => { + const p = write("trailing.log", "line1\nline2\n"); + const tail = readLogTail(p, 10); + expect(tail).toBe("line1\nline2"); + expect(tail.endsWith("\n")).toBe(false); + expect(tail.split("\n").pop()).not.toBe(""); + }); + + it("reads only the tail of a file larger than 16KB and returns the genuinely last N lines", () => { + // Build a file well over CHUNK bytes, with clearly numbered lines so we + // can verify exactly which lines come back. + const lines: string[] = []; + let total = 0; + let i = 0; + while (total < CHUNK * 2) { + const l = `line-${String(i).padStart(5, "0")}`; + lines.push(l); + total += l.length + 1; + i++; + } + const p = write("big.log", lines.join("\n") + "\n"); + const tail = readLogTail(p, 5); + const expected = lines.slice(-5).join("\n"); + expect(tail).toBe(expected); + }); + + it("never returns a truncated line when the chunk boundary falls mid-line", () => { + // Build a file where the CHUNK cut point lands in the middle of the marker + // line. Before the fix, that half-line came back as if it were a real log + // line; now the partial line is discarded, so every line returned is whole. + const marker = "UNIQUE_LONG_LINE_THAT_STRADDLES_THE_CHUNK_BOUNDARY_MARKER"; + const filler = "f".repeat(CHUNK - Math.floor(marker.length / 2)); + const p = write("boundary.log", `${filler}\n${marker}\nlast\n`); + expect(fs.statSync(p).size).toBeGreaterThan(CHUNK); + + // Ask for more lines than exist, so nothing is hidden by slice(-lineCount). + const linesBack = readLogTail(p, 100).split("\n").filter((l) => l !== ""); + // Every line returned must be one that genuinely exists in the file. + for (const line of linesBack) { + expect([filler, marker, "last"]).toContain(line); + } + // The last line is always intact and present. + expect(linesBack[linesBack.length - 1]).toBe("last"); + }); + + it("FIXED: a multi-byte UTF-8 character split by the chunk boundary is dropped cleanly, not mangled", () => { + const emoji = "\u{1F389}"; // 4-byte UTF-8 + const rest = "restboundary\nlast line\n"; + // Put the emoji alone on the file's first line, so the only thing the + // partial-line discard should eat is that broken character — everything + // after the first newline must survive intact. Size the filler so the + // chunk cut point (size - CHUNK) lands 2 bytes into the 4-byte emoji. + const fixed = Buffer.byteLength(emoji) + 2 + Buffer.byteLength(rest); // emoji + 2 newlines + tail + const filler = "y".repeat(CHUNK + 2 - fixed); + const content = `${emoji}\n${filler}\n${rest}`; + const p = write("utf8-boundary.log", content); + const size = fs.statSync(p).size; + const cutOffset = size - CHUNK; + expect(cutOffset).toBeGreaterThan(0); + expect(cutOffset).toBeLessThan(Buffer.byteLength(emoji)); + + const tail = readLogTail(p, 100); + // The chunk starts mid-emoji, so the partial first line holding the split + // character is discarded wholesale rather than decoded into U+FFFD... + expect(tail).not.toContain("�"); + // ...and the discard stops at that first newline: every later line is intact. + expect(tail).toBe(`${filler}\n${rest.trimEnd()}`); + }); + + it("closes the file descriptor afterwards (no fd leak across many calls)", () => { + const p = write("fd.log", "a\nb\nc\n"); + // If fds leaked, repeating this many times would eventually exhaust the + // process's fd limit (typically 1024+) and throw EMFILE. + for (let i = 0; i < 500; i++) { + readLogTail(p, 2); + } + // Reaching here without EMFILE is the assertion; also sanity-check a + // normal fd can still be opened. + const fd = fs.openSync(p, "r"); + fs.closeSync(fd); + expect(true).toBe(true); + }); +}); + +describe("statusCommand", () => { + let realHome: string | undefined; + let tmpHome: string; + let logSpy: ReturnType; + + beforeEach(() => { + realHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-status-")); + fs.mkdirSync(path.join(tmpHome, ".uptool"), { recursive: true }); + process.env.HOME = tmpHome; + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env.HOME = realHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + logSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + function pidFile(): string { + return path.join(tmpHome, ".uptool", "uptool.pid"); + } + + it("reports stopped when there is no PID file", async () => { + await statusCommand({}); + const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("uptool: stopped"); + expect(output).not.toContain("stale"); + }); + + it("reports stale for a dead process's PID file and cleans it up", async () => { + // Find a PID almost certainly not running: a very high number is not + // guaranteed dead on all systems, so instead spawn nothing and rely on + // pid_max headroom — use a large pid unlikely to be alive. + const deadPid = 2 ** 30; + fs.writeFileSync(pidFile(), String(deadPid)); + expect(fs.existsSync(pidFile())).toBe(true); + + await statusCommand({}); + + const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("stale PID file"); + expect(output).toContain(String(deadPid)); + // The file must be cleaned up after detecting staleness. + expect(fs.existsSync(pidFile())).toBe(false); + }); + + it("--json emits the expected shape and exits non-zero when unhealthy", async () => { + // No config, no running daemon -> unhealthy. + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((() => { + // vi.spyOn with mockImplementation: execution continues past the call + // since we don't throw here. + return undefined as never; + }) as unknown) as typeof process.exit); + + await statusCommand({ json: true }); + + expect(exitSpy).toHaveBeenCalledWith(1); + const jsonCall = logSpy.mock.calls.find((c) => { + try { + JSON.parse(c[0]); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(jsonCall![0]); + expect(parsed).toMatchObject({ + running: false, + healthy: false, + pid: null, + api_responding: false, + deployments: null, + base_url: null, + port: null, + api_port: null, + }); + }); + + it("reports running (with own pid, always alive) and prints the log tail when present", async () => { + fs.writeFileSync(pidFile(), String(process.pid)); + const logFile = path.join(tmpHome, ".uptool", "server.log"); + fs.writeFileSync(logFile, "log line one\nlog line two\n"); + + await statusCommand({}); + + const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain(`uptool: running (pid ${process.pid})`); + expect(output).toContain("last 10 log lines"); + expect(output).toContain("log line one"); + expect(output).toContain("log line two"); + }); + + it("--json with a running process and a reachable API reports deployments and healthy:true", async () => { + fs.writeFileSync(pidFile(), String(process.pid)); + const server = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ files: [1, 2, 3] })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const apiPort = (server.address() as { port: number }).port; + + fs.writeFileSync( + path.join(tmpHome, ".uptool", "config.toml"), + `base_url = "test.local"\nport = 3000\napi_port = ${apiPort}\nttl = "72h"\nstorage_path = ${JSON.stringify( + path.join(tmpHome, ".uptool", "files") + )}\n` + ); + fs.writeFileSync(path.join(tmpHome, ".uptool", "token"), "tok"); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined as never) as typeof process.exit); + + try { + await statusCommand({ json: true }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + + expect(exitSpy).toHaveBeenCalledWith(0); + const jsonCall = logSpy.mock.calls.find((c) => { + try { + JSON.parse(c[0]); + return true; + } catch { + return false; + } + }); + const parsed = JSON.parse(jsonCall![0]); + expect(parsed).toMatchObject({ + running: true, + healthy: true, + pid: process.pid, + api_responding: true, + deployments: 3, + base_url: "test.local", + api_port: apiPort, + }); + }); + + it("--json with a running process but unreachable API reports deployments:null and healthy:false", async () => { + fs.writeFileSync(pidFile(), String(process.pid)); + // Reserve a port, then free it — nothing listens, so probeApi's catch + // branch (ECONNREFUSED -> null) is exercised. + const probe = http.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const apiPort = (probe.address() as { port: number }).port; + await new Promise((resolve) => probe.close(() => resolve())); + + fs.writeFileSync( + path.join(tmpHome, ".uptool", "config.toml"), + `base_url = "test.local"\nport = 3000\napi_port = ${apiPort}\nttl = "72h"\nstorage_path = ${JSON.stringify( + path.join(tmpHome, ".uptool", "files") + )}\n` + ); + fs.writeFileSync(path.join(tmpHome, ".uptool", "token"), "tok"); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined as never) as typeof process.exit); + + await statusCommand({ json: true }); + + expect(exitSpy).toHaveBeenCalledWith(1); + const jsonCall = logSpy.mock.calls.find((c) => { + try { + JSON.parse(c[0]); + return true; + } catch { + return false; + } + }); + const parsed = JSON.parse(jsonCall![0]); + expect(parsed).toMatchObject({ + running: true, + healthy: false, + pid: process.pid, + api_responding: false, + deployments: null, + }); + }); +}); + +describe("logsCommand", () => { + let tmpHome: string; + let realHome: string | undefined; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + realHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-logscmd-")); + process.env.HOME = tmpHome; + fs.mkdirSync(path.join(tmpHome, ".uptool"), { recursive: true }); + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + process.exitCode = undefined; + }); + + afterEach(() => { + process.env.HOME = realHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + function writeLog(content: string): string { + const p = path.join(tmpHome, ".uptool", "server.log"); + fs.writeFileSync(p, content); + return p; + } + + it("prints the tail of the log", () => { + writeLog("alpha\nbravo\ncharlie\n"); + logsCommand(); + expect(logSpy).toHaveBeenCalledWith("alpha\nbravo\ncharlie"); + expect(process.exitCode).toBeUndefined(); + }); + + it("respects -n", () => { + writeLog(Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") + "\n"); + logsCommand({ lines: "3" }); + expect(logSpy).toHaveBeenCalledWith("line 17\nline 18\nline 19"); + }); + + it("defaults to the last 50 lines", () => { + writeLog(Array.from({ length: 200 }, (_, i) => `l${i}`).join("\n") + "\n"); + logsCommand(); + expect((logSpy.mock.calls[0][0] as string).split("\n")).toHaveLength(50); + }); + + it("exits non-zero with a clear message when the log file is missing", () => { + logsCommand(); + expect(process.exitCode).toBe(1); + expect(errSpy.mock.calls[0][0]).toMatch(/uptool serve/); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("rejects an invalid line count", () => { + writeLog("a\n"); + logsCommand({ lines: "0" }); + expect(process.exitCode).toBe(1); + logsCommand({ lines: "abc" }); + expect(process.exitCode).toBe(1); + }); + + it("prints nothing for an empty log rather than a blank line", () => { + writeLog(""); + logsCommand(); + expect(logSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); +}); + +describe("followLog", () => { + let dir: string; + let stop: (() => void) | undefined; + let out: string[]; + let writeSpy: ReturnType; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-follow-")); + out = []; + writeSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(((chunk: string) => { + out.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + }); + + afterEach(() => { + stop?.(); + stop = undefined; + writeSpy.mockRestore(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + // fs.watchFile polls, so give it a couple of intervals to notice. + const settle = (ms = 120) => new Promise((r) => setTimeout(r, ms)); + + it("emits only newly appended bytes, not the existing content", async () => { + const p = path.join(dir, "f.log"); + fs.writeFileSync(p, "already here\n"); + stop = followLog(p, 20); + + fs.appendFileSync(p, "brand new\n"); + await settle(); + + const emitted = out.join(""); + expect(emitted).toContain("brand new"); + expect(emitted).not.toContain("already here"); + }); + + it("resumes from the start when the file is truncated mid-follow", async () => { + const p = path.join(dir, "rotate.log"); + fs.writeFileSync(p, "x".repeat(500) + "\n"); + stop = followLog(p, 20); + + // Rotation: file shrinks. Reading from the stale (larger) offset would + // emit garbage or nothing; it must restart from the new beginning. + fs.writeFileSync(p, "after rotation\n"); + await settle(); + + expect(out.join("")).toContain("after rotation"); + }); + + it("stop() releases the watcher", async () => { + const p = path.join(dir, "s.log"); + fs.writeFileSync(p, ""); + const release = followLog(p, 20); + release(); + stop = undefined; + + fs.appendFileSync(p, "ignored\n"); + await settle(); + expect(out.join("")).not.toContain("ignored"); + }); +}); diff --git a/test/storage.test.ts b/test/storage.test.ts index 7cfad67..d2a6197 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; @@ -7,6 +7,7 @@ import { stripMarkdownFences, validateBundlePath, mimeForPath, + dirSize, ManifestStore, type Manifest, } from "../src/storage/index.js"; @@ -519,6 +520,67 @@ describe("ManifestStore", () => { fs.rmSync(dir, { recursive: true }); }); + it("exposes err.code = FILE_TOO_LARGE / QUOTA_EXCEEDED, not just a message", () => { + const dir1 = tmpDir + "-code1"; + const limited1 = new ManifestStore(dir1, { ...storeOpts, max_file_size: 10 }); + let err1: NodeJS.ErrnoException | undefined; + try { + limited1.store("x".repeat(20), null, "index.html", "big.html"); + } catch (e) { + err1 = e as NodeJS.ErrnoException; + } + expect(err1?.code).toBe("FILE_TOO_LARGE"); + fs.rmSync(dir1, { recursive: true }); + + const dir2 = tmpDir + "-code2"; + const limited2 = new ManifestStore(dir2, { ...storeOpts, max_total_storage: 10 }); + let err2: NodeJS.ErrnoException | undefined; + try { + limited2.store("x".repeat(20), null, "index.html", "big.html"); + } catch (e) { + err2 = e as NodeJS.ErrnoException; + } + expect(err2?.code).toBe("QUOTA_EXCEEDED"); + fs.rmSync(dir2, { recursive: true }); + }); + + it("base64 size estimation matches true decoded byte length for 0/1/2 padding chars", () => { + // 0 padding: length divisible by 4 exactly (e.g. 3 bytes -> 4 b64 chars) + const noPad = Buffer.from("abc").toString("base64"); // "YWJj", no '=' + expect(noPad.endsWith("=")).toBe(false); + // 1 padding char: 2 bytes -> "YWI=" + const onePad = Buffer.from("ab").toString("base64"); + expect(onePad.endsWith("=") && !onePad.endsWith("==")).toBe(true); + // 2 padding chars: 1 byte -> "YQ==" + const twoPad = Buffer.from("a").toString("base64"); + expect(twoPad.endsWith("==")).toBe(true); + + for (const [b64, trueLen] of [ + [noPad, 3], + [onePad, 2], + [twoPad, 1], + ] as const) { + const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; + const estimated = Math.floor((b64.length * 3) / 4) - padding; + expect(estimated).toBe(trueLen); + expect(estimated).toBe(Buffer.from(b64, "base64").length); + } + }); + + it("limits disabled (0 = unlimited) short-circuits entirely", () => { + const dir = tmpDir + "-unlimited"; + const limited = new ManifestStore(dir, { + ttl: "72h", + max_versions: 0, + max_file_size: 0, + max_total_storage: 0, + }); + expect(() => + limited.store("z".repeat(10_000), null, "index.html", "big.html") + ).not.toThrow(); + fs.rmSync(dir, { recursive: true }); + }); + it("applies limits on update too", () => { const dir = tmpDir + "-lim6"; const limited = new ManifestStore(dir, { @@ -564,5 +626,164 @@ describe("ManifestStore", () => { const s2 = new ManifestStore(store.storageDir, storeOpts); expect(s2.list().length).toBe(1); }); + + it("flushNow cancels the pending debounced flush (no stray write later)", () => { + vi.useFakeTimers(); + try { + store.store("

A

", null, "index.html", "a.html"); + store.flushNow(); + const mtimeAfterFlush = fs.statSync( + path.join(store.storageDir, "manifest.json") + ).mtimeMs; + + // Advance past the 500ms debounce window. If the pending timer from + // store() were NOT cancelled by flushNow(), this would fire another + // (harmless but redundant) write; if it somehow raced a deleted dir + // it would throw (the bug this test exists to rule out). + vi.advanceTimersByTime(1000); + + const mtimeAfterAdvance = fs.statSync( + path.join(store.storageDir, "manifest.json") + ).mtimeMs; + expect(mtimeAfterAdvance).toBe(mtimeAfterFlush); + } finally { + vi.useRealTimers(); + } + }); + + it("a manifest written by flushNow reloads correctly in a fresh store", () => { + store.store("

A

", null, "index.html", "a.html"); + store.store("

B

", null, "index.html", "b.html", "named-b"); + store.flushNow(); + + const fresh = new ManifestStore(store.storageDir, storeOpts); + expect(fresh.list().length).toBe(2); + expect(fresh.resolveSlug("named-b")).not.toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // dirSize + // ------------------------------------------------------------------------- + + describe("dirSize", () => { + it("returns 0 for a missing directory", () => { + expect(dirSize(path.join(tmpDir, "does-not-exist"))).toBe(0); + }); + + it("recurses into nested directories", () => { + const nested = path.join(tmpDir, "a", "b", "c"); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "a", "top.txt"), "12345"); // 5 bytes + fs.writeFileSync(path.join(tmpDir, "a", "b", "mid.txt"), "1234567890"); // 10 bytes + fs.writeFileSync(path.join(nested, "leaf.txt"), "123"); // 3 bytes + + expect(dirSize(path.join(tmpDir, "a"))).toBe(18); + }); + }); + + // ------------------------------------------------------------------------- + // Version pruning deletes directories from disk + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // recordHit (view counts) + // ------------------------------------------------------------------------- + + describe("recordHit", () => { + it("increments hits and stamps last_seen", () => { + const slug = store.store("

v1

", null, "index.html", "t.html"); + expect(store.getEntry(slug)!.hits).toBeUndefined(); + + const before = Date.now(); + store.recordHit(slug); + const entry = store.getEntry(slug)!; + expect(entry.hits).toBe(1); + expect(entry.last_seen).toBeGreaterThanOrEqual(before); + + store.recordHit(slug); + expect(store.getEntry(slug)!.hits).toBe(2); + }); + + it("is a silent no-op for an unknown slug", () => { + expect(() => store.recordHit("doesnotexist")).not.toThrow(); + }); + + it("resolves a named slug", () => { + store.store("

v1

", null, "index.html", "t.html", "myapp"); + store.recordHit("myapp"); + const slug = store.resolveSlug("myapp")!; + expect(store.getEntry(slug)!.hits).toBe(1); + }); + + it("survives update() — redeploying keeps the view count", () => { + const slug = store.store("

v1

", null, "index.html", "t.html"); + store.recordHit(slug); + store.recordHit(slug); + store.update(slug, "

v2

", null, "index.html", "t.html"); + expect(store.getEntry(slug)!.hits).toBe(2); + }); + + it("survives a store reload from disk", () => { + const slug = store.store("

v1

", null, "index.html", "t.html"); + store.recordHit(slug); + store.recordHit(slug); + store.flushNow(); + + const reloaded = new ManifestStore(store.storageDir, storeOpts); + expect(reloaded.getEntry(slug)!.hits).toBe(2); + }); + + it("a legacy entry with no hits field reads as zero, not NaN", () => { + const slug = store.store("

v1

", null, "index.html", "t.html"); + // Simulate a legacy manifest entry written before hits existed + delete (store as unknown as { manifest: Manifest }).manifest[slug].hits; + + store.recordHit(slug); + expect(store.getEntry(slug)!.hits).toBe(1); + expect(Number.isNaN(store.getEntry(slug)!.hits)).toBe(false); + }); + + it("list() exposes hits/last_seen while still stripping key", () => { + const slug = store.store( + "

v1

", null, "index.html", "t.html", undefined, "sekret" + ); + store.recordHit(slug); + const listed = store.list().find((e) => e.slug === slug)!; + expect(listed.hits).toBe(1); + expect(listed.last_seen).toBeTypeOf("number"); + expect(listed.protected).toBe(true); + expect((listed as unknown as { key?: string }).key).toBeUndefined(); + }); + + it("rollback() keeps the view count — hits track the slug/URL, not a content version", () => { + const slug = store.store("

v1

", null, "index.html", "t.html"); + store.recordHit(slug); + store.update(slug, "

v2

", null, "index.html", "t.html"); + store.recordHit(slug); + expect(store.getEntry(slug)!.hits).toBe(2); + + store.rollback(slug); + expect(store.getEntry(slug)!.hits).toBe(2); + }); + }); + + it("prunes versions beyond max_versions from disk, not just the manifest", () => { + const dir = tmpDir + "-prune-disk"; + const maxStore = new ManifestStore(dir, { ttl: "72h", max_versions: 2 }); + const slug = maxStore.store("

v1

", null, "index.html", "t.html"); + maxStore.update(slug, "

v2

", null, "index.html", "t.html"); + maxStore.update(slug, "

v3

", null, "index.html", "t.html"); + maxStore.update(slug, "

v4

", null, "index.html", "t.html"); + + const entry = maxStore.getEntry(slug)!; + const versionsDir = path.join(dir, slug, ".versions"); + const onDisk = fs.readdirSync(versionsDir); + + // Disk must match the manifest exactly — no orphaned old version dirs + expect(onDisk.sort()).toEqual([...(entry.versions ?? [])].sort()); + expect(onDisk.length).toBeLessThanOrEqual(2); + + fs.rmSync(dir, { recursive: true }); }); }); diff --git a/test/ws.test.ts b/test/ws.test.ts new file mode 100644 index 0000000..c9122df --- /dev/null +++ b/test/ws.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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 { WebSocket } from "ws"; +import { ManifestStore } from "../src/storage/index.js"; +import { createPublicServer } from "../src/server/public.js"; +import { WsManager } from "../src/server/ws.js"; +import { DEFAULT_CONFIG } from "../src/config/index.js"; + +const TEST_CONFIG = { + ...DEFAULT_CONFIG, + base_url: "test.local", + port: 0, + scheme: "http", + csp: "", + live_reload: true, +}; + +/** Connect a raw `ws` client to `/__lr` for `host`, resolving once open. */ +function connectLr(port: number, host: string, extraHeaders: Record = {}): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/__lr`, { + headers: { host, ...extraHeaders }, + }); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +describe("WsManager", () => { + let tmpDir: string; + let store: ManifestStore; + let server: http.Server; + let wsManager: WsManager | undefined; + let port: number; + + beforeEach( + () => + new Promise((resolve) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "uptool-ws-")); + store = new ManifestStore(tmpDir, { ttl: "72h", max_versions: 0 }); + server = createPublicServer(TEST_CONFIG, store); + server.listen(0, "127.0.0.1", () => { + port = (server.address() as { port: number }).port; + resolve(); + }); + }) + ); + + afterEach( + () => + new Promise((resolve) => { + vi.useRealTimers(); + wsManager?.close(); + wsManager = undefined; + // See the matching note in test/public.test.ts's afterEach: flush + // the pending debounced manifest write before removing tmpDir so a + // stray timer can't fire an uncaught ENOENT after cleanup. + store.flushNow(); + server.close(() => { + fs.rmSync(tmpDir, { recursive: true }); + resolve(); + }); + }) + ); + + it("destroys the socket on upgrade to a path other than /__lr", async () => { + wsManager = new WsManager(server, TEST_CONFIG, store); + const slug = store.store("

hi

", null, "index.html", "t.html"); + + const result = await new Promise<"open" | "closed">((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/not-lr`, { + headers: { host: `${slug}.test.local` }, + }); + ws.once("open", () => resolve("open")); + ws.once("error", () => resolve("closed")); + ws.once("unexpected-response", () => resolve("closed")); + }); + expect(result).toBe("closed"); + }); + + it("destroys the socket when the Host header has no extractable slug", async () => { + wsManager = new WsManager(server, TEST_CONFIG, store); + + const result = await new Promise<"open" | "closed">((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/__lr`, { + headers: { host: "not-a-subdomain.example.com" }, + }); + ws.once("open", () => resolve("open")); + ws.once("error", () => resolve("closed")); + ws.once("unexpected-response", () => resolve("closed")); + }); + expect(result).toBe("closed"); + }); + + it("registers a valid upgrade and removes the client from the map on close", async () => { + wsManager = new WsManager(server, TEST_CONFIG, store); + const slug = store.store("

hi

", null, "index.html", "t.html"); + + const ws = await connectLr(port, `${slug}.test.local`); + const clients = (wsManager as unknown as { clients: Map> }).clients; + expect(clients.get(slug)?.size).toBe(1); + + await new Promise((resolve) => { + ws.once("close", () => resolve()); + ws.close(); + }); + // Give the server's 'close' handler a tick to run + await new Promise((r) => setImmediate(r)); + expect(clients.has(slug)).toBe(false); + }); + + it("broadcast(slug, msg) reaches only clients of that slug", async () => { + wsManager = new WsManager(server, TEST_CONFIG, store); + const slugA = store.store("

A

", null, "index.html", "a.html"); + const slugB = store.store("

B

", null, "index.html", "b.html"); + + const wsA = await connectLr(port, `${slugA}.test.local`); + const wsB = await connectLr(port, `${slugB}.test.local`); + + const gotA = new Promise((resolve) => wsA.once("message", (d) => resolve(d.toString()))); + const gotBNothing = new Promise<"silent" | "message">((resolve) => { + wsB.once("message", (d) => resolve("message")); + setTimeout(() => resolve("silent"), 300); + }); + + wsManager.broadcast(slugA, "reload"); + + expect(await gotA).toBe("reload"); + expect(await gotBNothing).toBe("silent"); // cross-slug leak would deliver here + + wsA.close(); + wsB.close(); + }); + + it("broadcasting to a slug with no clients is a no-op, not a throw", () => { + wsManager = new WsManager(server, TEST_CONFIG, store); + expect(() => wsManager!.broadcast("nobody-here", "reload")).not.toThrow(); + }); + + it("close() clears the heartbeat interval and shuts the server down cleanly", () => { + wsManager = new WsManager(server, TEST_CONFIG, store); + const heartbeat = (wsManager as unknown as { heartbeat: NodeJS.Timeout }).heartbeat; + expect(heartbeat).toBeDefined(); + wsManager.close(); + // clearInterval is idempotent-safe to call again; the important assertion + // is that no error is thrown and no handle keeps vitest alive (checked by + // the test runner exiting cleanly, not an explicit assertion here). + wsManager = undefined; + }); + + it("terminates a socket that stops responding to pings (heartbeat sweep)", async () => { + // Approach: fake timers must be installed *before* WsManager is + // constructed, since the heartbeat's setInterval is created in the + // constructor and vitest's fake clock only intercepts timers created + // after faking is enabled — advancing fake time would never fire an + // interval that already exists on the real clock. Real socket I/O + // (connect/open) isn't timer-driven for a fast localhost connection, so + // it still resolves normally while fake timers are active. + // + // We also don't rely on suppressing the client's automatic pong (e.g. + // pausing its underlying stream) to simulate an unresponsive client — + // that left a half-open socket that hung server.close() in afterEach in + // an earlier version of this test. Instead we flip the server-side + // TrackedSocket's `isAlive` flag directly, which is exactly the state + // the sweep would observe from a client that missed the previous ping, + // then let the real sweep logic (delete-from-map + ws.terminate()) run. + vi.useFakeTimers(); + try { + wsManager = new WsManager(server, TEST_CONFIG, store); + const slug = store.store("

hi

", null, "index.html", "t.html"); + const ws = await connectLr(port, `${slug}.test.local`); + + const clients = (wsManager as unknown as { clients: Map> }).clients; + const serverSideSet = clients.get(slug); + expect(serverSideSet?.size).toBe(1); + const serverSideSocket = [...serverSideSet!][0] as unknown as { isAlive: boolean }; + serverSideSocket.isAlive = false; + + vi.advanceTimersByTime(30_000); + + expect(clients.has(slug)).toBe(false); + ws.close(); + } finally { + vi.useRealTimers(); + } + }, 10_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 2614865..1996ae3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,39 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig, configDefaults } from "vitest/config"; export default defineConfig({ test: { environment: "node", // CJS project with .js import extensions — vitest resolves .js → .ts automatically globals: false, + include: ["test/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + exclude: [...configDefaults.exclude, ".claude/**"], + // Sweeps leftover /tmp/uptool-* dirs once the whole suite is done. + globalSetup: ["test/global-setup.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "html"], + include: ["src/**"], + // Pure commander wiring, no logic to cover. + exclude: ["src/cli.ts"], + // Ratchets set just under the measured baseline so they catch real + // regressions without tripping on noise. + // + // The global figure reads low because v8 only instruments this process: + // src/commands/* runs in a spawned CLI subprocess, so test/cli.test.ts + // exercises those commands for real but scores them 0%. The per-glob + // floor below is the number that actually guards the core. + thresholds: { + statements: 63, + branches: 62, + functions: 65, + lines: 62, + "src/{server,storage,config,lib}/**": { + statements: 88, + branches: 78, + functions: 85, + lines: 88, + }, + }, + }, }, }); From 945e5a1019315efd3bc2ca9e06157cf513a63cc9 Mon Sep 17 00:00:00 2001 From: pyeom Date: Tue, 4 Aug 2026 02:39:28 +0000 Subject: [PATCH 2/2] Fix identifier handling, UTF-8 logs, and test stability - Use canonical slug for deployment URLs and WebSocket broadcasts (fix routing via name) - StringDecoder for followLog: handles multi-byte UTF-8 split across reads - readLogTail: widen window for long lines (no newline in initial chunk) - ManifestStore: keep pending flush timer, don't reset on every hit - Version IDs: use monotonic timestamp to avoid collisions - Test: improve daemon error reporting, fix fd/env cleanup, stabilize watchers --- package.json | 2 +- src/commands/deploy.ts | 8 +++--- src/commands/logs.ts | 11 ++++++-- src/commands/status.ts | 40 +++++++++++++++++------------ src/server/ws.ts | 11 +++++--- src/storage/index.ts | 11 ++++++-- test/api-client.test.ts | 4 ++- test/api.test.ts | 11 +++++--- test/cli.test.ts | 9 +++++-- test/config.test.ts | 4 ++- test/global-setup.ts | 2 +- test/helpers.ts | 14 ++++++++-- test/public.test.ts | 4 --- test/status.test.ts | 57 ++++++++++++++++++++++++++++++----------- test/ws.test.ts | 2 +- 15 files changed, 132 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index f774338..58e365f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "typecheck": "tsc --noEmit", "test": "npm run build && vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test:coverage": "npm run build && vitest run --coverage", "prepublishOnly": "npm run build && npm test" }, "dependencies": { diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 73c2861..e1e5ccb 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -195,10 +195,10 @@ export async function deployCommand( body ); if (result.error) throw new Error(result.error); - const slug = result.slug ?? (opts.update as string); - // Prefer the name-based URL when a name was assigned (matches list.ts) - // — the deployment is reachable at name., not slug.. - const urlSlug = !opts.update && opts.name ? opts.name : slug; + // Stable identifier: whatever the caller addressed the deployment by + // (--update value, or the name just assigned) — it stays valid across + // redeploys, unlike result.slug. Falls back to the random slug. + const urlSlug = opts.update ?? opts.name ?? (result.slug as string); const url = publicUrl(config, urlSlug); console.log(`✓ ${url}${expiry}`); if (key) console.log(` key: ${key} (Basic Auth password — any username)`); diff --git a/src/commands/logs.ts b/src/commands/logs.ts index f150a81..ae57365 100644 --- a/src/commands/logs.ts +++ b/src/commands/logs.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import { StringDecoder } from "node:string_decoder"; import { logPath } from "../config/index.js"; import { readLogTail } from "./status.js"; @@ -41,11 +42,17 @@ export function logsCommand(opts: { follow?: boolean; lines?: string } = {}): vo */ export function followLog(file: string, intervalMs = 1000): () => void { let offset = fs.statSync(file).size; + // Persistent decoder: a multi-byte character split across two reads would + // otherwise decode to U+FFFD on both sides. + let decoder = new StringDecoder("utf8"); fs.watchFile(file, { interval: intervalMs }, (curr) => { // Truncated or rotated: the file we were reading is gone or reset, so // reading from the old offset would emit garbage. Restart from the top. - if (curr.size < offset) offset = 0; + if (curr.size < offset) { + offset = 0; + decoder = new StringDecoder("utf8"); + } if (curr.size === offset) return; const fd = fs.openSync(file, "r"); @@ -54,7 +61,7 @@ export function followLog(file: string, intervalMs = 1000): () => void { const buf = Buffer.alloc(len); const read = fs.readSync(fd, buf, 0, len, offset); offset += read; - process.stdout.write(buf.subarray(0, read).toString("utf8")); + process.stdout.write(decoder.write(buf.subarray(0, read))); } finally { fs.closeSync(fd); } diff --git a/src/commands/status.ts b/src/commands/status.ts index d9cda21..350f02e 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -88,29 +88,37 @@ export async function statusCommand(opts: { json?: boolean } = {}): Promise 0) { - // The chunk didn't start at byte 0, so it may start mid-line — and if - // it starts mid-character, decoding to utf8 above already turned the - // split bytes into U+FFFD. Either way, that first (partial) line is - // garbage: drop everything up to and including its newline. - const nl = text.indexOf("\n"); - text = nl === -1 ? "" : text.slice(nl + 1); + let readLen = 0; + for (;;) { + readLen = Math.min(size, readLen + CHUNK); + const startOffset = size - readLen; + const buf = Buffer.alloc(readLen); + fs.readSync(fd, buf, 0, readLen, startOffset); + let text = buf.toString("utf8"); + if (startOffset > 0) { + // The chunk didn't start at byte 0, so it may start mid-line — and if + // it starts mid-character, decoding to utf8 above already turned the + // split bytes into U+FFFD. Either way, that first (partial) line is + // garbage: drop everything up to and including its newline. No newline + // at all means the window is still inside one long line — widen it. + const nl = text.indexOf("\n"); + if (nl === -1) continue; + text = text.slice(nl + 1); + } + const trimmed = text.trim(); + const lines = trimmed === "" ? [] : trimmed.split("\n"); + if (lines.length >= lineCount || readLen >= size) { + return lines.slice(-lineCount).join("\n"); + } } - const trimmed = text.trim(); - const lines = trimmed === "" ? [] : trimmed.split("\n"); - return lines.slice(-lineCount).join("\n"); } finally { fs.closeSync(fd); } diff --git a/src/server/ws.ts b/src/server/ws.ts index 9aa5791..6adb618 100644 --- a/src/server/ws.ts +++ b/src/server/ws.ts @@ -51,9 +51,14 @@ export class WsManager { return; } + // Key by the canonical slug: broadcasts come from the store's "updated" + // event, which always emits the canonical slug — a client that connected + // via a name would never be reached under the host-derived key. + const key = resolved ?? slug; + this.wss.handleUpgrade(req, socket, head, (ws: TrackedSocket) => { - if (!this.clients.has(slug)) this.clients.set(slug, new Set()); - const clientSet = this.clients.get(slug)!; + if (!this.clients.has(key)) this.clients.set(key, new Set()); + const clientSet = this.clients.get(key)!; clientSet.add(ws); ws.isAlive = true; @@ -63,7 +68,7 @@ export class WsManager { ws.on("close", () => { clientSet.delete(ws); - if (clientSet.size === 0) this.clients.delete(slug); + if (clientSet.size === 0) this.clients.delete(key); }); ws.on("error", () => { diff --git a/src/storage/index.ts b/src/storage/index.ts index b9c4c80..c445db6 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -251,7 +251,11 @@ export class ManifestStore extends EventEmitter { // ------------------------------------------------------------------------- private scheduleFlush(): void { - if (this.flushTimer) clearTimeout(this.flushTimer); + // Keep an already-pending timer rather than pushing it back: the flush + // writes the whole manifest, so a pending one already covers this change, + // and sustained traffic (recordHit per page view) would otherwise reset + // the deadline forever and never persist. + if (this.flushTimer) return; this.flushTimer = setTimeout(() => { this.flushTimer = null; // This runs detached on a timer, so a throw here is an uncaught exception @@ -692,7 +696,10 @@ export class ManifestStore extends EventEmitter { const entry = this.manifest[slug]; const versionsDir = path.join(slugDir, ".versions"); - const ts = Date.now().toString(); + // Two updates within the same millisecond must not share a version id — + // that would collide on disk and duplicate the manifest entry. + const newest = Number(entry.versions?.[0] ?? 0); + const ts = String(Math.max(Date.now(), newest + 1)); const versionDir = path.join(versionsDir, ts); fs.mkdirSync(versionDir, { recursive: true }); diff --git a/test/api-client.test.ts b/test/api-client.test.ts index d9018f2..f1efa8d 100644 --- a/test/api-client.test.ts +++ b/test/api-client.test.ts @@ -23,7 +23,9 @@ beforeEach(() => { }); afterEach(() => { - process.env.HOME = realHome; + // Assigning undefined would store the literal string "undefined". + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; fs.rmSync(tmpHome, { recursive: true, force: true }); }); diff --git a/test/api.test.ts b/test/api.test.ts index 752ec29..0483f47 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -205,7 +205,7 @@ describe("API server", () => { } finally { limitedStore.flushNow(); await new Promise((resolve) => limitedServer.close(() => resolve())); - fs.rmSync(tmpDir2, { recursive: true }); + fs.rmSync(tmpDir2, { recursive: true, force: true }); } }); @@ -390,7 +390,12 @@ describe("API server", () => { let raw = ""; res.on("data", (c) => (raw += c)); res.on("end", () => { - expect(res.statusCode).toBe(200); + // Reject rather than throw here: a failed expect inside this + // callback would leave the Promise pending until the timeout. + if (res.statusCode !== 200) { + reject(new Error(`status ${res.statusCode}: ${raw}`)); + return; + } resolve(JSON.parse(raw) as { slug: string }); }); } @@ -409,7 +414,7 @@ describe("API server", () => { } finally { bigStore.flushNow(); await new Promise((resolve) => bigServer.close(() => resolve())); - fs.rmSync(tmpDir2, { recursive: true }); + fs.rmSync(tmpDir2, { recursive: true, force: true }); } }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 97f0798..7351b65 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -359,10 +359,15 @@ describe("cli.test.ts", () => { it("never leaks the access key of a protected deployment", async () => { const f = writeHtmlFile(scratch, "secret.html", "

secret

"); - await runCli(["deploy", f, "--protect", "hunter2"], { home: daemon.home }); + const deploy = await runCli(["deploy", f, "--protect", "hunter2"], { home: daemon.home }); + expect(deploy.code).toBe(0); + const slug = new URL(deploy.stdout.match(/https?:\/\/\S+/)![0]).hostname.split(".")[0]; + const res = await runCli(["list", "--json"], { home: daemon.home }); expect(res.stdout).not.toContain("hunter2"); - expect(JSON.parse(res.stdout).some((e: { protected: boolean }) => e.protected)).toBe(true); + // Assert on this test's own entry — the daemon.home is shared. + const entry = JSON.parse(res.stdout).find((e: { slug: string }) => e.slug === slug); + expect(entry).toMatchObject({ slug, protected: true }); }, 20_000); }); diff --git a/test/config.test.ts b/test/config.test.ts index b483c95..095871e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -113,7 +113,9 @@ describe("config file I/O (isolated HOME)", () => { }); afterEach(() => { - process.env.HOME = realHome; + // Assigning undefined would store the literal string "undefined". + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; fs.rmSync(tmpHome, { recursive: true, force: true }); }); diff --git a/test/global-setup.ts b/test/global-setup.ts index 62cafde..945cd43 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -17,7 +17,7 @@ export function teardown(): void { const tmp = os.tmpdir(); let removed = 0; for (const name of fs.readdirSync(tmp)) { - if (!/^uptool-[a-z-]+-/.test(name)) continue; + if (!/^uptool-[a-z0-9-]+-/.test(name)) continue; try { fs.rmSync(path.join(tmp, name), { recursive: true, force: true }); removed++; diff --git a/test/helpers.ts b/test/helpers.ts index adf4ac2..dba4569 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -191,6 +191,14 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise { }); liveDaemons.add(child); + // Drain both pipes: an unread pipe fills at ~64KB and blocks the daemon. + // Keep only the recent tail, for the startup-failure message. + const TAIL = 8 * 1024; + let out = ""; + let err = ""; + child.stdout.on("data", (c: Buffer) => (out = (out + c).slice(-TAIL))); + child.stderr.on("data", (c: Buffer) => (err = (err + c).slice(-TAIL))); + let stopped = false; const stop = async (): Promise => { if (stopped) return; @@ -219,9 +227,11 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise { try { await waitForPort(apiPort); await waitForPort(pubPort); - } catch (err) { + } catch (e) { await stop(); - throw err; + throw new Error( + `daemon failed to start: ${(e as Error).message}\n--- stdout ---\n${out}\n--- stderr ---\n${err}` + ); } return { apiPort, pubPort, token, home, stop }; diff --git a/test/public.test.ts b/test/public.test.ts index 6df5415..2a2bd2e 100644 --- a/test/public.test.ts +++ b/test/public.test.ts @@ -513,10 +513,6 @@ describe("Public server", () => { expect(store.getEntry(slug)).not.toBeNull(); }); - // ------------------------------------------------------------------------- - // Handler throw -> 500, not a dead daemon - // ------------------------------------------------------------------------- - // ------------------------------------------------------------------------- // View counting (recordHit) // ------------------------------------------------------------------------- diff --git a/test/status.test.ts b/test/status.test.ts index 7d833f9..ffdc05b 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -111,12 +111,27 @@ describe("readLogTail", () => { expect(cutOffset).toBeGreaterThan(0); expect(cutOffset).toBeLessThan(Buffer.byteLength(emoji)); + // Asking for few enough lines to be satisfied by the first chunk: the + // chunk starts mid-emoji, so the partial first line holding the split + // character is discarded wholesale rather than decoded into U+FFFD. + const shortTail = readLogTail(p, 2); + expect(shortTail).not.toContain("�"); + expect(shortTail).toBe(rest.trimEnd()); + + // Asking for more lines than the first chunk holds widens the window back + // to byte 0, so the emoji line comes back intact. const tail = readLogTail(p, 100); - // The chunk starts mid-emoji, so the partial first line holding the split - // character is discarded wholesale rather than decoded into U+FFFD... expect(tail).not.toContain("�"); - // ...and the discard stops at that first newline: every later line is intact. - expect(tail).toBe(`${filler}\n${rest.trimEnd()}`); + expect(tail).toBe(`${emoji}\n${filler}\n${rest.trimEnd()}`); + }); + + it("returns the last line of a single-line file larger than 16KB", () => { + // One 17 KiB line with no newline before it: a single chunk read from the + // end contains no newline at all, and must not be discarded as partial. + const line = "z".repeat(17 * 1024); + const p = write("one-long-line.log", `${line}\n`); + expect(fs.statSync(p).size).toBeGreaterThan(CHUNK); + expect(readLogTail(p, 10)).toBe(line); }); it("closes the file descriptor afterwards (no fd leak across many calls)", () => { @@ -414,18 +429,33 @@ describe("followLog", () => { // fs.watchFile polls, so give it a couple of intervals to notice. const settle = (ms = 120) => new Promise((r) => setTimeout(r, ms)); + // Poll until the watcher has emitted `text`, instead of betting on a fixed + // delay — stat polling latency varies by platform and filesystem. + // + // `poke` re-applies the file change on every iteration. fs.watchFile takes + // its baseline stat asynchronously, so a write landing between followLog() + // and that baseline is invisible to the watcher forever — under load that + // made these tests flaky. Repeating the (idempotent) write guarantees at + // least one change lands after the baseline. + async function waitFor(text: string, poke: () => void, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (out.join("").includes(text)) return; + poke(); + await settle(20); + } + throw new Error(`timed out waiting for ${JSON.stringify(text)}; got: ${out.join("")}`); + } + it("emits only newly appended bytes, not the existing content", async () => { const p = path.join(dir, "f.log"); fs.writeFileSync(p, "already here\n"); stop = followLog(p, 20); - fs.appendFileSync(p, "brand new\n"); - await settle(); + await waitFor("brand new", () => fs.appendFileSync(p, "brand new\n")); - const emitted = out.join(""); - expect(emitted).toContain("brand new"); - expect(emitted).not.toContain("already here"); - }); + expect(out.join("")).not.toContain("already here"); + }, 25_000); it("resumes from the start when the file is truncated mid-follow", async () => { const p = path.join(dir, "rotate.log"); @@ -434,11 +464,8 @@ describe("followLog", () => { // Rotation: file shrinks. Reading from the stale (larger) offset would // emit garbage or nothing; it must restart from the new beginning. - fs.writeFileSync(p, "after rotation\n"); - await settle(); - - expect(out.join("")).toContain("after rotation"); - }); + await waitFor("after rotation", () => fs.writeFileSync(p, "after rotation\n")); + }, 25_000); it("stop() releases the watcher", async () => { const p = path.join(dir, "s.log"); diff --git a/test/ws.test.ts b/test/ws.test.ts index c9122df..104e5f3 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -60,7 +60,7 @@ describe("WsManager", () => { // stray timer can't fire an uncaught ENOENT after cleanup. store.flushNow(); server.close(() => { - fs.rmSync(tmpDir, { recursive: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); resolve(); }); })