From daaecb74520018e0f3cc31ae0dfffb4fcd63cf36 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 19:21:53 -0500 Subject: [PATCH] Fix the published package: compile the CLI, bundle onvif (0.1.1) Installing 0.1.0 failed twice, both my mistakes. - Node refuses to strip types under node_modules, so shipping .ts for the CLI could never work from an installed package. tsconfig.cli.json now compiles those modules to dist/ as ES modules, and the helper scripts are plain .mjs. - serverExternalPackages made Turbopack emit require("onvif-"), a name that resolves nowhere, because onvif comes from a git URL. Bundling it fixes that; checked from an installed tarball, where /api/discover answered 200 and a real camera's info and snapshot came back through the packaged server. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++++ .vrek/log.ndjson | 4 ++++ cli/onvif-dashboard.mjs | 10 +++++----- next.config.ts | 5 ++++- package.json | 19 +++++++++--------- scripts/bundle-standalone.mjs | 5 ++++- .../{create-admin.mts => create-admin.mjs} | 18 ++++++++--------- scripts/create-admin.test.ts | 8 ++++---- ...tall-mediamtx.mts => install-mediamtx.mjs} | 2 +- tsconfig.cli.json | 20 +++++++++++++++++++ 10 files changed, 64 insertions(+), 31 deletions(-) rename scripts/{create-admin.mts => create-admin.mjs} (80%) rename scripts/{install-mediamtx.mts => install-mediamtx.mjs} (90%) create mode 100644 tsconfig.cli.json diff --git a/.gitignore b/.gitignore index 0d5a612..57bfd82 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ next-env.d.ts # vrek: the SQLite database is local state; commit the export (.vrek/log.ndjson) instead /.vrek/*.db /.vrek/*.db-* +# Built CLI modules (npm run build:cli) +/dist/ +# Packed tarballs +/*.tgz diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 37d50fc..3673883 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -1089,3 +1089,7 @@ {"id":"evt-svss3f90kf71","type":"node.status_changed","subject":"iss-ej4ahga","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T00:08:56.449Z","parents":["evt-qxcfkcrdwhsr"],"hash":"86783b1bae9e8acbdeed1191d27e175226cba155011fad66fd9a4befa53edc9d"} {"id":"evt-s93xtm5nv20z","type":"node.created","subject":"mea-mtq4xsm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 4 of 5 issues done","body":"Counted from vrek on 2026-09-19: package identity, the paths module, the first-run experience and the standalone build with a start command are closed with evidence. Left: iss-9zcawn7, publishing and installing on a machine that has never held this repo, which the user has to do since publishing needs their npm account.","status":"recorded","owner":null,"attrs":{"value":4,"applies_at":"2026-09-19"}},"at":"2026-09-20T00:08:58.895Z","parents":["evt-svss3f90kf71"],"hash":"b7a4892d809e426538d084c9da3c2af3d0e91b6f21cd064b1d2b772f2fcffa52"} {"id":"evt-w855xpb3jfwm","type":"edge.added","subject":"mea-mtq4xsm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-mtq4xsm","to":"gol-k56hc69"},"at":"2026-09-20T00:08:58.896Z","parents":["evt-s93xtm5nv20z"],"hash":"c5fa46275ff117df73f142897cbfb085a3fefd06e939b5809d64447ea56486d1"} +{"id":"evt-5qz5x14089hy","type":"node.created","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"A published npm package can't ship TypeScript for the CLI, and externalizing a git-URL dependency breaks the built server","body":"Both found by the user installing onvif-dashboard 0.1.0 on a real machine on 2026-09-19, and both were my errors. (1) Node refuses to strip types under node_modules: ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for src/lib/first-run.ts. Running .ts directly works from a checkout and never works from an installed package, so the CLI's modules are now compiled to dist/ by tsconfig.cli.json (ES2022 modules, with dist/package.json marking them as ESM), and the helper scripts became plain .mjs. (2) With serverExternalPackages: [\"onvif\"], Turbopack emitted require(\"onvif-29d15b1bafe0601e\") in the server chunks, a name that resolves nowhere, because the dependency comes from a git URL rather than the registry; the installed app then failed with \"Failed to load external module\". Bundling onvif instead fixes it, and was checked from an installed tarball: /api/discover answered 200 (so dgram works bundled) and a real camera's info and snapshot came back through the packaged server. Also worth knowing for the Debian goal: npm does publish nested node_modules inside .next/standalone, so the packed app carries its own dependencies.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-9zcawn7","note":"User's install output on their server, then local reproduction with npm pack plus install into a temporary project"}],"as_of":"2026-09-20"}},"at":"2026-09-20T00:21:38.491Z","parents":["evt-w855xpb3jfwm"],"hash":"c28dc8d61823f0c69e3e12cdc6802b28f2c45fcb5e6d42035385ed66abf15f71"} +{"id":"evt-dqc3aswb5n83","type":"edge.added","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-53mh3ad","to":"iss-9zcawn7"},"at":"2026-09-20T00:21:38.493Z","parents":["evt-5qz5x14089hy"],"hash":"7827762965307b09d8be5fe9d83a7720c2efa1998bd66a62020ced96c5e2e081"} +{"id":"evt-9yg21vwz860k","type":"edge.added","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-53mh3ad","to":"gol-k56hc69"},"at":"2026-09-20T00:21:38.494Z","parents":["evt-dqc3aswb5n83"],"hash":"7398dd055f23e646e167823526004382e417186b7464ca8b984739848aace69c"} +{"id":"evt-6zk4dghxrwx7","type":"edge.added","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-53mh3ad","to":"gol-9jctv92"},"at":"2026-09-20T00:21:38.495Z","parents":["evt-9yg21vwz860k"],"hash":"69f9257312d727fc3f3681910a8da37cb69f451ae65e190595510652f8180337"} diff --git a/cli/onvif-dashboard.mjs b/cli/onvif-dashboard.mjs index e2cddc1..bf3fbb8 100755 --- a/cli/onvif-dashboard.mjs +++ b/cli/onvif-dashboard.mjs @@ -6,9 +6,9 @@ import { access } from "node:fs/promises"; import { createInterface } from "node:readline/promises"; import { parseArgs } from "node:util"; import path from "node:path"; -import { applyEnvironment, firstRun, SECRETS_FILE } from "../src/lib/first-run.ts"; -import { installMediamtx } from "../src/lib/mediamtx-install.ts"; -import { adminFilePath, readAdminFile } from "../src/lib/admin-file.ts"; +import { applyEnvironment, firstRun, SECRETS_FILE } from "../dist/lib/first-run.js"; +import { installMediamtx } from "../dist/lib/mediamtx-install.js"; +import { adminFilePath, readAdminFile } from "../dist/lib/admin-file.js"; const HELP = `onvif-dashboard — watch and manage ONVIF cameras on your own network @@ -91,8 +91,8 @@ async function start() { /** Creates the admin login, prompting for it; the script it runs has the interactive bits. */ async function admin(args) { await applyEnvironment(); - const script = path.join(import.meta.dirname, "..", "scripts", "create-admin.mts"); - const child = spawn(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", script, ...args], { + const script = path.join(import.meta.dirname, "..", "scripts", "create-admin.mjs"); + const child = spawn(process.execPath, [script, ...args], { stdio: "inherit", env: process.env, }); diff --git a/next.config.ts b/next.config.ts index fc2e41b..d9c2e07 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,7 +34,10 @@ const nextConfig: NextConfig = { // next/image is unused, and leaving it on pulls sharp's platform binaries (tens of MB). images: { unoptimized: true }, // onvif uses Node's dgram/os modules; load it with native require instead of bundling. - serverExternalPackages: ["onvif"], + // onvif is bundled rather than listed in serverExternalPackages. Externalizing it made + // Turbopack emit require("onvif-") — a name that exists nowhere — because the + // dependency comes from a git URL, which broke the published package (vrek iss-9zcawn7). + // Bundled, discovery (dgram) and camera reads work from an installed copy. ...(turbopackRoot && { turbopack: { root: turbopackRoot } }), }; diff --git a/package.json b/package.json index bb6d4da..e16aa48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "onvif-dashboard", - "version": "0.1.0", + "version": "0.1.1", "description": "Web dashboard for ONVIF cameras (Hikvision, Annke): discovery, live video over WebRTC, motion recording and settings, all on your own network.", "keywords": [ "onvif", @@ -27,25 +27,24 @@ ".next/static", "public", "cli", - "src/lib/paths.ts", - "src/lib/first-run.ts", - "src/lib/mediamtx-install.ts", - "src/lib/admin-file.ts", - "scripts/create-admin.mts", - "scripts/install-mediamtx.mts", + "dist", + "scripts/create-admin.mjs", + "scripts/install-mediamtx.mjs", "LICENSE", "README.md" ], "scripts": { "dev": "next dev", - "build": "next build && node scripts/bundle-standalone.mjs", + "build": "next build && npm run build:cli && node scripts/bundle-standalone.mjs", "start": "next start", "lint": "eslint", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", - "admin:create": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/create-admin.mts", - "video:install": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/install-mediamtx.mts" + "admin:create": "node scripts/create-admin.mjs", + "video:install": "node scripts/install-mediamtx.mjs", + "build:cli": "tsc --project tsconfig.cli.json", + "prepack": "npm run build" }, "dependencies": { "@tanstack/react-query": "^5.103.1", diff --git a/scripts/bundle-standalone.mjs b/scripts/bundle-standalone.mjs index 2f323ff..07e7f4d 100644 --- a/scripts/bundle-standalone.mjs +++ b/scripts/bundle-standalone.mjs @@ -1,7 +1,7 @@ // After next build, the standalone server needs the static files beside it; Next leaves // that copy to us (Next docs, config/output.md). Also drops in the CLI's own modules, so // the published package can run from anywhere (vrek iss-ej4ahga). -import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises"; +import { access, cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; const root = process.cwd(); @@ -40,6 +40,9 @@ for (const name of prune) { await rm(target, { recursive: true, force: true }); } +// The compiled CLI modules are ES modules; without this Node would read them as CommonJS. +await writeFile(path.join(root, "dist", "package.json"), '{\n "type": "module"\n}\n'); + console.log( `Bundled the standalone server with its static files` + (freed ? `, and dropped ${(freed / 1024 / 1024).toFixed(0)} MB it doesn't need.` : "."), diff --git a/scripts/create-admin.mts b/scripts/create-admin.mjs similarity index 80% rename from scripts/create-admin.mts rename to scripts/create-admin.mjs index 159109d..de5d76f 100644 --- a/scripts/create-admin.mts +++ b/scripts/create-admin.mjs @@ -2,10 +2,10 @@ // interface never has to run unsecured. No dependencies beyond the app's own; runs on // Node 24+ directly: // -// npm run admin:create # prompts for username and password -// npm run admin:create -- --username admin # prompts for password only -// npm run admin:create -- --force # replace an existing admin (resets password) -// printf '%s\n' "$PW" | npm run admin:create -- --username admin # non-interactive +// onvif-dashboard admin # prompts for username and password +// onvif-dashboard admin -- --username admin # prompts for password only +// onvif-dashboard admin -- --force # replace an existing admin (resets password) +// printf '%s\n' "$PW" | onvif-dashboard admin -- --username admin # non-interactive // // Writes ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json, owner-only (0600). import { createInterface } from "node:readline/promises"; @@ -18,7 +18,7 @@ import { readAdminFile, usernameSchema, writeAdminFile, -} from "../src/lib/admin-file.ts"; +} from "../dist/lib/admin-file.js"; const { values } = parseArgs({ options: { @@ -29,7 +29,7 @@ const { values } = parseArgs({ }); if (values.help) { - console.log("Usage: npm run admin:create -- [--username NAME] [--force]"); + console.log("Usage: onvif-dashboard admin -- [--username NAME] [--force]"); process.exit(0); } @@ -48,7 +48,7 @@ const rl = createInterface({ input: process.stdin, output, terminal: interactive // Piped input can arrive before the questions are asked, so read it line by line. const lines = interactive ? null : rl[Symbol.asyncIterator](); -async function ask(question: string, { secret = false } = {}): Promise { +async function ask(question, { secret = false } = {}) { if (lines) { const next = await lines.next(); return next.done ? "" : String(next.value); @@ -62,14 +62,14 @@ async function ask(question: string, { secret = false } = {}): Promise { return answer; } -function fail(message: string): never { +function fail(message) { console.error(`Error: ${message}`); rl.close(); process.exit(1); } async function main() { - const existing = await readAdminFile(file).catch((err: Error) => fail(err.message)); + const existing = await readAdminFile(file).catch((err) => fail(err.message)); if (existing && !values.force) { fail(`an admin ("${existing.username}") already exists in ${file}. Re-run with --force to replace it.`); } diff --git a/scripts/create-admin.test.ts b/scripts/create-admin.test.ts index c069e2d..92e3b17 100644 --- a/scripts/create-admin.test.ts +++ b/scripts/create-admin.test.ts @@ -8,7 +8,7 @@ import { verifyPassword } from "../src/lib/admin-file"; /** Runs the CLI as a real process with piped (non-interactive) stdin. */ const run = promisify(execFile); -const SCRIPT = path.join(import.meta.dirname, "create-admin.mts"); +const SCRIPT = path.join(import.meta.dirname, "create-admin.mjs"); const PASSWORD = "correct horse battery"; let dir: string; @@ -22,7 +22,7 @@ afterEach(() => rm(dir, { recursive: true, force: true })); async function cli(args: string[], stdin: string) { const child = run( process.execPath, - ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", SCRIPT, ...args], + [SCRIPT, ...args], { env: { ...process.env, ADMIN_AUTH_FILE: file } }, ); child.child.stdin!.end(stdin); @@ -34,7 +34,7 @@ async function cli(args: string[], stdin: string) { const readAdmin = async () => JSON.parse(await readFile(file, "utf8")); -describe("npm run admin:create", () => { +describe("onvif-dashboard admin", () => { it("creates an owner-only admin file with a hash of the piped password", async () => { const res = await cli(["--username", "admin"], `${PASSWORD}\n`); expect(res).toMatchObject({ code: 0, stderr: "" }); @@ -89,6 +89,6 @@ describe("npm run admin:create", () => { it("prints usage with --help", async () => { const res = await cli(["--help"], ""); expect(res).toMatchObject({ code: 0 }); - expect(res.stdout).toContain("Usage: npm run admin:create"); + expect(res.stdout).toContain("Usage: onvif-dashboard admin"); }); }); diff --git a/scripts/install-mediamtx.mts b/scripts/install-mediamtx.mjs similarity index 90% rename from scripts/install-mediamtx.mts rename to scripts/install-mediamtx.mjs index 1c28213..9d1cd6b 100644 --- a/scripts/install-mediamtx.mts +++ b/scripts/install-mediamtx.mjs @@ -3,7 +3,7 @@ // written. Safe to re-run: an installed matching build is left alone. // // npm run video:install -import { installMediamtx } from "../src/lib/mediamtx-install.ts"; +import { installMediamtx } from "../dist/lib/mediamtx-install.js"; try { const result = await installMediamtx(); diff --git a/tsconfig.cli.json b/tsconfig.cli.json new file mode 100644 index 0000000..5ee7257 --- /dev/null +++ b/tsconfig.cli.json @@ -0,0 +1,20 @@ +{ + // The CLI and its helper scripts run as plain Node, not through Next, so the few modules + // they use are compiled to JavaScript: Node refuses to strip types under node_modules, + // which is exactly where a published package lives (vrek iss-9zcawn7). + "compilerOptions": { + "target": "es2023", + "module": "es2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noEmit": false, + "declaration": false, + "sourceMap": false + }, + "include": ["src/lib/paths.ts", "src/lib/first-run.ts", "src/lib/mediamtx-install.ts", "src/lib/admin-file.ts"] +}