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-<hash>"),
  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) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 19:21:53 -05:00
parent 30b1f36e1b
commit daaecb7452
10 changed files with 64 additions and 31 deletions

4
.gitignore vendored
View File

@ -51,3 +51,7 @@ next-env.d.ts
# vrek: the SQLite database is local state; commit the export (.vrek/log.ndjson) instead # vrek: the SQLite database is local state; commit the export (.vrek/log.ndjson) instead
/.vrek/*.db /.vrek/*.db
/.vrek/*.db-* /.vrek/*.db-*
# Built CLI modules (npm run build:cli)
/dist/
# Packed tarballs
/*.tgz

View File

@ -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-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-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-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"}

View File

@ -6,9 +6,9 @@ import { access } from "node:fs/promises";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import path from "node:path"; import path from "node:path";
import { applyEnvironment, firstRun, SECRETS_FILE } from "../src/lib/first-run.ts"; import { applyEnvironment, firstRun, SECRETS_FILE } from "../dist/lib/first-run.js";
import { installMediamtx } from "../src/lib/mediamtx-install.ts"; import { installMediamtx } from "../dist/lib/mediamtx-install.js";
import { adminFilePath, readAdminFile } from "../src/lib/admin-file.ts"; import { adminFilePath, readAdminFile } from "../dist/lib/admin-file.js";
const HELP = `onvif-dashboard — watch and manage ONVIF cameras on your own network 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. */ /** Creates the admin login, prompting for it; the script it runs has the interactive bits. */
async function admin(args) { async function admin(args) {
await applyEnvironment(); await applyEnvironment();
const script = path.join(import.meta.dirname, "..", "scripts", "create-admin.mts"); const script = path.join(import.meta.dirname, "..", "scripts", "create-admin.mjs");
const child = spawn(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", script, ...args], { const child = spawn(process.execPath, [script, ...args], {
stdio: "inherit", stdio: "inherit",
env: process.env, env: process.env,
}); });

View File

@ -34,7 +34,10 @@ const nextConfig: NextConfig = {
// next/image is unused, and leaving it on pulls sharp's platform binaries (tens of MB). // next/image is unused, and leaving it on pulls sharp's platform binaries (tens of MB).
images: { unoptimized: true }, images: { unoptimized: true },
// onvif uses Node's dgram/os modules; load it with native require instead of bundling. // 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-<hash>") — 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 } }), ...(turbopackRoot && { turbopack: { root: turbopackRoot } }),
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "onvif-dashboard", "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.", "description": "Web dashboard for ONVIF cameras (Hikvision, Annke): discovery, live video over WebRTC, motion recording and settings, all on your own network.",
"keywords": [ "keywords": [
"onvif", "onvif",
@ -27,25 +27,24 @@
".next/static", ".next/static",
"public", "public",
"cli", "cli",
"src/lib/paths.ts", "dist",
"src/lib/first-run.ts", "scripts/create-admin.mjs",
"src/lib/mediamtx-install.ts", "scripts/install-mediamtx.mjs",
"src/lib/admin-file.ts",
"scripts/create-admin.mts",
"scripts/install-mediamtx.mts",
"LICENSE", "LICENSE",
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"dev": "next dev", "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", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"admin:create": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/create-admin.mts", "admin:create": "node scripts/create-admin.mjs",
"video:install": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/install-mediamtx.mts" "video:install": "node scripts/install-mediamtx.mjs",
"build:cli": "tsc --project tsconfig.cli.json",
"prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.103.1", "@tanstack/react-query": "^5.103.1",

View File

@ -1,7 +1,7 @@
// After next build, the standalone server needs the static files beside it; Next leaves // 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 // 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). // 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"; import path from "node:path";
const root = process.cwd(); const root = process.cwd();
@ -40,6 +40,9 @@ for (const name of prune) {
await rm(target, { recursive: true, force: true }); 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( console.log(
`Bundled the standalone server with its static files` + `Bundled the standalone server with its static files` +
(freed ? `, and dropped ${(freed / 1024 / 1024).toFixed(0)} MB it doesn't need.` : "."), (freed ? `, and dropped ${(freed / 1024 / 1024).toFixed(0)} MB it doesn't need.` : "."),

View File

@ -2,10 +2,10 @@
// interface never has to run unsecured. No dependencies beyond the app's own; runs on // interface never has to run unsecured. No dependencies beyond the app's own; runs on
// Node 24+ directly: // Node 24+ directly:
// //
// npm run admin:create # prompts for username and password // onvif-dashboard admin # prompts for username and password
// npm run admin:create -- --username admin # prompts for password only // onvif-dashboard admin -- --username admin # prompts for password only
// npm run admin:create -- --force # replace an existing admin (resets password) // onvif-dashboard admin -- --force # replace an existing admin (resets password)
// printf '%s\n' "$PW" | npm run admin:create -- --username admin # non-interactive // printf '%s\n' "$PW" | onvif-dashboard admin -- --username admin # non-interactive
// //
// Writes ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json, owner-only (0600). // Writes ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json, owner-only (0600).
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
@ -18,7 +18,7 @@ import {
readAdminFile, readAdminFile,
usernameSchema, usernameSchema,
writeAdminFile, writeAdminFile,
} from "../src/lib/admin-file.ts"; } from "../dist/lib/admin-file.js";
const { values } = parseArgs({ const { values } = parseArgs({
options: { options: {
@ -29,7 +29,7 @@ const { values } = parseArgs({
}); });
if (values.help) { 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); 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. // Piped input can arrive before the questions are asked, so read it line by line.
const lines = interactive ? null : rl[Symbol.asyncIterator](); const lines = interactive ? null : rl[Symbol.asyncIterator]();
async function ask(question: string, { secret = false } = {}): Promise<string> { async function ask(question, { secret = false } = {}) {
if (lines) { if (lines) {
const next = await lines.next(); const next = await lines.next();
return next.done ? "" : String(next.value); return next.done ? "" : String(next.value);
@ -62,14 +62,14 @@ async function ask(question: string, { secret = false } = {}): Promise<string> {
return answer; return answer;
} }
function fail(message: string): never { function fail(message) {
console.error(`Error: ${message}`); console.error(`Error: ${message}`);
rl.close(); rl.close();
process.exit(1); process.exit(1);
} }
async function main() { 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) { if (existing && !values.force) {
fail(`an admin ("${existing.username}") already exists in ${file}. Re-run with --force to replace it.`); fail(`an admin ("${existing.username}") already exists in ${file}. Re-run with --force to replace it.`);
} }

View File

@ -8,7 +8,7 @@ import { verifyPassword } from "../src/lib/admin-file";
/** Runs the CLI as a real process with piped (non-interactive) stdin. */ /** Runs the CLI as a real process with piped (non-interactive) stdin. */
const run = promisify(execFile); 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"; const PASSWORD = "correct horse battery";
let dir: string; let dir: string;
@ -22,7 +22,7 @@ afterEach(() => rm(dir, { recursive: true, force: true }));
async function cli(args: string[], stdin: string) { async function cli(args: string[], stdin: string) {
const child = run( const child = run(
process.execPath, process.execPath,
["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", SCRIPT, ...args], [SCRIPT, ...args],
{ env: { ...process.env, ADMIN_AUTH_FILE: file } }, { env: { ...process.env, ADMIN_AUTH_FILE: file } },
); );
child.child.stdin!.end(stdin); 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")); 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 () => { it("creates an owner-only admin file with a hash of the piped password", async () => {
const res = await cli(["--username", "admin"], `${PASSWORD}\n`); const res = await cli(["--username", "admin"], `${PASSWORD}\n`);
expect(res).toMatchObject({ code: 0, stderr: "" }); expect(res).toMatchObject({ code: 0, stderr: "" });
@ -89,6 +89,6 @@ describe("npm run admin:create", () => {
it("prints usage with --help", async () => { it("prints usage with --help", async () => {
const res = await cli(["--help"], ""); const res = await cli(["--help"], "");
expect(res).toMatchObject({ code: 0 }); expect(res).toMatchObject({ code: 0 });
expect(res.stdout).toContain("Usage: npm run admin:create"); expect(res.stdout).toContain("Usage: onvif-dashboard admin");
}); });
}); });

View File

@ -3,7 +3,7 @@
// written. Safe to re-run: an installed matching build is left alone. // written. Safe to re-run: an installed matching build is left alone.
// //
// npm run video:install // npm run video:install
import { installMediamtx } from "../src/lib/mediamtx-install.ts"; import { installMediamtx } from "../dist/lib/mediamtx-install.js";
try { try {
const result = await installMediamtx(); const result = await installMediamtx();

20
tsconfig.cli.json Normal file
View File

@ -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"]
}