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:
parent
30b1f36e1b
commit
daaecb7452
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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-<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 } }),
|
||||
};
|
||||
|
||||
|
||||
19
package.json
19
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",
|
||||
|
||||
@ -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.` : "."),
|
||||
|
||||
@ -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<string> {
|
||||
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<string> {
|
||||
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.`);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
20
tsconfig.cli.json
Normal file
20
tsconfig.cli.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user