wiki-viewer 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,6 +77,14 @@ Options:
77
77
  -H, --host <host> Host to bind to (default: localhost)
78
78
  --https Enable HTTPS (self-signed cert, required on remote)
79
79
  -h, --help Show this help message
80
+
81
+ Commands:
82
+ service install [dir] [options] Install as a user service (persists across reboot)
83
+ service uninstall Remove the user service
84
+ service status Show service status
85
+ service logs Tail service logs
86
+ service restart Restart the service
87
+ update Update to the latest version and restart the service
80
88
  ```
81
89
 
82
90
  Examples:
@@ -92,6 +100,35 @@ npx wiki-viewer ~/notes -H 0.0.0.0
92
100
  npx wiki-viewer ~/notes --https -p 8443
93
101
  ```
94
102
 
103
+ ### Run as a service (reboot persistence)
104
+
105
+ Install wiki-viewer as a user service so it starts at boot and restarts on failure. Linux uses `systemd --user`, macOS uses a launchd LaunchAgent. No root needed.
106
+
107
+ ```bash
108
+ # Install with the run config you want (dir, host, port, https)
109
+ wiki-viewer service install ~/notes -H 0.0.0.0 -p 3003 --https
110
+
111
+ # Manage it
112
+ wiki-viewer service status
113
+ wiki-viewer service logs
114
+ wiki-viewer service restart
115
+ wiki-viewer service uninstall
116
+ ```
117
+
118
+ The run config is saved to `~/.wiki-viewer/config.json`. Edit that file and run `wiki-viewer service restart` to change settings without reinstalling. To change just the served directory at runtime, you can also click **Change** in the sidebar.
119
+
120
+ On Linux, install enables lingering (`loginctl enable-linger`) so the service runs without an active login session and survives reboot. If that step needs privileges, the installer prints the command to run manually.
121
+
122
+ > Ad-hoc runs like `wiki-viewer ~/docs` ignore the saved config. Only the service (and `wiki-viewer service run`) reads `config.json`.
123
+
124
+ ### Update
125
+
126
+ ```bash
127
+ wiki-viewer update
128
+ ```
129
+
130
+ Updates the global install to the latest version (npm/pnpm/yarn auto-detected) and restarts the service if one is installed.
131
+
95
132
  ---
96
133
 
97
134
  ## Auth and multi-user mode
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, execSync } from "node:child_process";
2
+ import { spawn, execSync, execFileSync } from "node:child_process";
3
3
  import { createServer as createHttpsServer } from "node:https";
4
4
  import { createServer as createHttpServer, request as httpRequest } from "node:http";
5
5
  import { createServer as createNetServer } from "node:net";
6
- import { readFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import os from "node:os";
9
9
  import { fileURLToPath } from "node:url";
@@ -11,9 +11,18 @@ import { fileURLToPath } from "node:url";
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
12
  const appRoot = path.resolve(__dirname, "..");
13
13
  const serverJs = path.join(appRoot, ".next", "standalone", "server.js");
14
+ const selfScript = fileURLToPath(import.meta.url);
15
+
16
+ const configDir = path.join(os.homedir(), ".wiki-viewer");
17
+ const configPath = path.join(configDir, "config.json");
18
+ const logDir = path.join(configDir, "logs");
19
+
20
+ const SERVICE_NAME = "wiki-viewer";
21
+ const LAUNCHD_LABEL = "com.wiki-viewer";
14
22
 
15
23
  function printUsage() {
16
24
  console.error("Usage: wiki-viewer [directory] [options]");
25
+ console.error(" wiki-viewer <command> [args]");
17
26
  console.error("");
18
27
  console.error(" directory Directory to serve (optional — pick in browser if omitted)");
19
28
  console.error("");
@@ -22,44 +31,99 @@ function printUsage() {
22
31
  console.error(" -H, --host <host> Host to bind to (default: localhost)");
23
32
  console.error(" --https Enable HTTPS (self-signed cert, enables service workers)");
24
33
  console.error("");
34
+ console.error("Commands:");
35
+ console.error(" service install [dir] [options] Install as a user service (persists across reboot)");
36
+ console.error(" service uninstall Remove the user service");
37
+ console.error(" service status Show service status");
38
+ console.error(" service logs Tail service logs");
39
+ console.error(" service run Run from saved config (used internally by the service)");
40
+ console.error(" update Update wiki-viewer to the latest version and restart");
41
+ console.error("");
25
42
  console.error("Examples:");
26
43
  console.error(" wiki-viewer ~/notes");
27
44
  console.error(" wiki-viewer ~/notes --https");
28
45
  console.error(" wiki-viewer ~/notes -p 8080 -H 0.0.0.0");
46
+ console.error(" wiki-viewer service install ~/notes -H 0.0.0.0 -p 3003 --https");
47
+ console.error(" wiki-viewer update");
29
48
  }
30
49
 
31
- if (!existsSync(serverJs)) {
32
- console.error("Error: pre-built server not found at", serverJs);
33
- console.error("This is a bug – please report it at https://github.com/anh-chu/wiki-viewer/issues");
34
- process.exit(1);
50
+ // ── arg parsing ──────────────────────────────────────────────────────────────
51
+
52
+ function parseServeArgs(args) {
53
+ let port = process.env.PORT;
54
+ let host = process.env.HOSTNAME;
55
+ let useHttps;
56
+ let userSpecifiedPort = false;
57
+ let rootDir;
58
+
59
+ for (let i = 0; i < args.length; i++) {
60
+ const a = args[i];
61
+ if (a === "-p" || a === "--port") { port = args[++i] ?? port; userSpecifiedPort = true; }
62
+ else if (a === "-H" || a === "--host") host = args[++i] ?? host;
63
+ else if (a === "--https") useHttps = true;
64
+ else if (!a.startsWith("-") && rootDir === undefined) rootDir = a;
65
+ }
66
+
67
+ return { rootDir, port, host, useHttps, userSpecifiedPort };
35
68
  }
36
69
 
37
- const args = process.argv.slice(2);
70
+ // ── config file ────────────────────────────────────────────────────────────
38
71
 
39
- if (args.includes("--help") || args.includes("-h")) {
40
- printUsage();
41
- process.exit(0);
72
+ function loadConfig() {
73
+ if (!existsSync(configPath)) return {};
74
+ try {
75
+ return JSON.parse(readFileSync(configPath, "utf8"));
76
+ } catch {
77
+ console.error(`Warning: could not parse ${configPath}, ignoring it`);
78
+ return {};
79
+ }
42
80
  }
43
81
 
44
- const rootDir = args.find((a) => !a.startsWith("-"));
45
- let port = process.env.PORT ?? "3000";
46
- let host = process.env.HOSTNAME ?? "localhost";
47
- let useHttps = false;
48
- let userSpecifiedPort = false;
82
+ function saveConfig(cfg) {
83
+ mkdirSync(configDir, { recursive: true });
84
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
85
+ }
49
86
 
50
- for (let i = 0; i < args.length; i++) {
51
- const a = args[i];
52
- if (a === "-p" || a === "--port") { port = args[++i] ?? port; userSpecifiedPort = true; }
53
- else if (a === "-H" || a === "--host") host = args[++i] ?? host;
54
- else if (a === "--https") useHttps = true;
87
+ // Ad-hoc serve: pure CLI flags + built-in defaults. Does NOT read the config
88
+ // file, so an installed service never silently alters a one-off invocation.
89
+ function resolveServeOptions(args) {
90
+ const cli = parseServeArgs(args);
91
+ return {
92
+ rootDir: cli.rootDir ? path.resolve(cli.rootDir) : null,
93
+ port: String(cli.port ?? "3000"),
94
+ host: cli.host ?? "localhost",
95
+ useHttps: Boolean(cli.useHttps),
96
+ userSpecifiedPort: cli.userSpecifiedPort,
97
+ };
55
98
  }
56
99
 
57
- const resolvedRoot = rootDir ? path.resolve(rootDir) : null;
100
+ // Service run: config file is the source of truth. CLI flags (if any) still win
101
+ // so the unit/plist could pass overrides, but normally there are none.
102
+ // Precedence: explicit CLI flags > config file > built-in defaults.
103
+ function resolveRunOptions(args) {
104
+ const cli = parseServeArgs(args);
105
+ const cfg = loadConfig();
106
+
107
+ const rootDir = cli.rootDir ?? cfg.rootDir ?? null;
108
+ const port = cli.port ?? cfg.port ?? "3000";
109
+ const host = cli.host ?? cfg.host ?? "localhost";
110
+ const useHttps = cli.useHttps ?? cfg.https ?? false;
111
+ // A config-pinned port is explicit too (don't auto-bump to next free port).
112
+ const userSpecifiedPort = cli.userSpecifiedPort || cfg.port != null;
113
+
114
+ return {
115
+ rootDir: rootDir ? path.resolve(rootDir) : null,
116
+ port: String(port),
117
+ host,
118
+ useHttps: Boolean(useHttps),
119
+ userSpecifiedPort,
120
+ };
121
+ }
58
122
 
59
123
  // ── HTTPS cert generation ──────────────────────────────────────────────────
60
124
 
61
- function ensureCerts() {
62
- const dir = path.join(os.homedir(), ".wiki-viewer", "certs");
125
+ function ensureCerts(host) {
126
+ const dir = path.join(configDir, "certs");
63
127
  mkdirSync(dir, { recursive: true });
64
128
  const keyPath = path.join(dir, "key.pem");
65
129
  const certPath = path.join(dir, "cert.pem");
@@ -70,14 +134,14 @@ function ensureCerts() {
70
134
  try {
71
135
  execSync("mkcert -version", { stdio: "ignore" });
72
136
  execSync(`mkcert -install 2>/dev/null; mkcert -key-file "${keyPath}" -cert-file "${certPath}" localhost 127.0.0.1 "${host}"`, { stdio: "pipe" });
73
- console.log("🔒 Trusted cert via mkcert");
137
+ console.log("�� Trusted cert via mkcert");
74
138
  } catch {
75
139
  try {
76
140
  execSync(
77
141
  `openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 825 -nodes -subj "/CN=localhost"`,
78
142
  { stdio: "ignore" },
79
143
  );
80
- console.log("🔒 Self-signed cert (browser will warn once — click through)");
144
+ console.log("�� Self-signed cert (browser will warn once — click through)");
81
145
  } catch {
82
146
  console.error("Error: --https requires mkcert or openssl");
83
147
  process.exit(1);
@@ -103,8 +167,6 @@ async function findNextAvailablePort(startPort, h) {
103
167
  return String(p);
104
168
  }
105
169
 
106
- // ── free port helper ───────────────────────────────────────────────────────
107
-
108
170
  function freePort() {
109
171
  return new Promise((resolve) => {
110
172
  const s = createNetServer();
@@ -115,8 +177,6 @@ function freePort() {
115
177
  });
116
178
  }
117
179
 
118
- // ── network address helper ─────────────────────────────────────────────────
119
-
120
180
  function getNetworkAddress() {
121
181
  for (const ifaces of Object.values(os.networkInterfaces())) {
122
182
  for (const iface of ifaces ?? []) {
@@ -128,11 +188,20 @@ function getNetworkAddress() {
128
188
 
129
189
  // ── start ──────────────────────────────────────────────────────────────────
130
190
 
131
- async function start() {
191
+ async function start(opts) {
192
+ const { rootDir: resolvedRoot, useHttps } = opts;
193
+ let { port, host, userSpecifiedPort } = opts;
194
+
195
+ if (!existsSync(serverJs)) {
196
+ console.error("Error: pre-built server not found at", serverJs);
197
+ console.error("This is a bug – please report it at https://github.com/anh-chu/wiki-viewer/issues");
198
+ process.exit(1);
199
+ }
200
+
132
201
  if (resolvedRoot) {
133
- console.log(`📁 ${resolvedRoot}`);
202
+ console.log(`�� ${resolvedRoot}`);
134
203
  } else {
135
- console.log("📂 No directory specified — open the browser to choose one");
204
+ console.log("�� No directory specified — open the browser to choose one");
136
205
  }
137
206
 
138
207
  // Auto-select next free port when user didn't specify one
@@ -148,7 +217,9 @@ async function start() {
148
217
  // When HTTPS is requested, run the standalone server on a random internal
149
218
  // HTTP port and stand up an HTTPS reverse-proxy on the user-facing port.
150
219
  const internalPort = useHttps ? String(await freePort()) : port;
151
- const internalHost = "127.0.0.1";
220
+ // In HTTPS mode the standalone server sits behind the proxy on loopback.
221
+ // Otherwise it must bind to the user-requested host directly.
222
+ const internalHost = useHttps ? "127.0.0.1" : host;
152
223
 
153
224
  const child = spawn(process.execPath, [serverJs], {
154
225
  cwd: path.join(appRoot, ".next", "standalone"),
@@ -164,7 +235,7 @@ async function start() {
164
235
  child.on("exit", (code) => process.exit(code ?? 0));
165
236
 
166
237
  if (useHttps) {
167
- const { key, cert } = ensureCerts();
238
+ const { key, cert } = ensureCerts(host);
168
239
 
169
240
  // Simple HTTPS → HTTP proxy
170
241
  const proxy = createHttpsServer({ key, cert }, (req, res) => {
@@ -208,4 +279,278 @@ async function start() {
208
279
  }
209
280
  }
210
281
 
211
- start();
282
+ // ── service: shared ──────────────────────────────────────────────────────────
283
+
284
+ function platform() {
285
+ if (process.platform === "linux") return "linux";
286
+ if (process.platform === "darwin") return "macos";
287
+ return null;
288
+ }
289
+
290
+ function requireSupportedPlatform() {
291
+ const p = platform();
292
+ if (!p) {
293
+ console.error(`Error: service management is only supported on Linux (systemd) and macOS (launchd), not ${process.platform}.`);
294
+ process.exit(1);
295
+ }
296
+ return p;
297
+ }
298
+
299
+ function run(cmd, args, opts = {}) {
300
+ return execFileSync(cmd, args, { stdio: "inherit", ...opts });
301
+ }
302
+
303
+ function runQuiet(cmd, args) {
304
+ try {
305
+ return execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }).toString();
306
+ } catch (e) {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ // ── service: install ──────────────────────────────────────────────────────────
312
+
313
+ function serviceInstall(args) {
314
+ const p = requireSupportedPlatform();
315
+
316
+ // Capture the run config from flags (falling back to existing config), persist it.
317
+ const cli = parseServeArgs(args);
318
+ const existing = loadConfig();
319
+ const cfg = {
320
+ rootDir: cli.rootDir != null ? path.resolve(cli.rootDir) : existing.rootDir ?? null,
321
+ host: cli.host ?? existing.host ?? "localhost",
322
+ port: cli.port ?? existing.port ?? "3000",
323
+ https: cli.useHttps ?? existing.https ?? false,
324
+ };
325
+ saveConfig(cfg);
326
+ console.log(`Saved config to ${configPath}`);
327
+ console.log(` dir: ${cfg.rootDir ?? "(choose in browser)"}`);
328
+ console.log(` host: ${cfg.host}`);
329
+ console.log(` port: ${cfg.port}`);
330
+ console.log(` https: ${cfg.https}`);
331
+ console.log("");
332
+
333
+ if (p === "linux") installSystemd();
334
+ else installLaunchd();
335
+ }
336
+
337
+ function installSystemd() {
338
+ const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
339
+ mkdirSync(unitDir, { recursive: true });
340
+ const unitPath = path.join(unitDir, `${SERVICE_NAME}.service`);
341
+
342
+ const unit = `[Unit]
343
+ Description=wiki-viewer local file viewer
344
+ After=network.target
345
+
346
+ [Service]
347
+ Type=simple
348
+ ExecStart=${process.execPath} ${selfScript} service run
349
+ Restart=on-failure
350
+ RestartSec=3
351
+ Environment=NODE_ENV=production
352
+
353
+ [Install]
354
+ WantedBy=default.target
355
+ `;
356
+ writeFileSync(unitPath, unit);
357
+ console.log(`Wrote unit ${unitPath}`);
358
+
359
+ // Enable lingering so the service survives logout and starts at boot.
360
+ const user = os.userInfo().username;
361
+ try {
362
+ execFileSync("loginctl", ["enable-linger", user], { stdio: "ignore" });
363
+ console.log(`Enabled linger for ${user} (starts at boot)`);
364
+ } catch {
365
+ console.log(`Note: could not enable linger automatically. For boot persistence run:`);
366
+ console.log(` sudo loginctl enable-linger ${user}`);
367
+ }
368
+
369
+ run("systemctl", ["--user", "daemon-reload"]);
370
+ run("systemctl", ["--user", "enable", "--now", `${SERVICE_NAME}.service`]);
371
+ console.log("\nService installed and started.");
372
+ console.log(" Status: wiki-viewer service status");
373
+ console.log(" Logs: wiki-viewer service logs");
374
+ }
375
+
376
+ function installLaunchd() {
377
+ const agentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
378
+ mkdirSync(agentsDir, { recursive: true });
379
+ mkdirSync(logDir, { recursive: true });
380
+ const plistPath = path.join(agentsDir, `${LAUNCHD_LABEL}.plist`);
381
+ const outLog = path.join(logDir, "out.log");
382
+ const errLog = path.join(logDir, "err.log");
383
+
384
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
385
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
386
+ <plist version="1.0">
387
+ <dict>
388
+ <key>Label</key>
389
+ <string>${LAUNCHD_LABEL}</string>
390
+ <key>ProgramArguments</key>
391
+ <array>
392
+ <string>${process.execPath}</string>
393
+ <string>${selfScript}</string>
394
+ <string>service</string>
395
+ <string>run</string>
396
+ </array>
397
+ <key>RunAtLoad</key>
398
+ <true/>
399
+ <key>KeepAlive</key>
400
+ <true/>
401
+ <key>StandardOutPath</key>
402
+ <string>${outLog}</string>
403
+ <key>StandardErrorPath</key>
404
+ <string>${errLog}</string>
405
+ </dict>
406
+ </plist>
407
+ `;
408
+ writeFileSync(plistPath, plist);
409
+ console.log(`Wrote plist ${plistPath}`);
410
+
411
+ // Reload if already loaded, then load with -w to persist across reboot.
412
+ runQuiet("launchctl", ["unload", plistPath]);
413
+ run("launchctl", ["load", "-w", plistPath]);
414
+ console.log("\nService installed and started.");
415
+ console.log(" Status: wiki-viewer service status");
416
+ console.log(" Logs: wiki-viewer service logs");
417
+ }
418
+
419
+ // ── service: uninstall ──────────────────────────────────────────────────────
420
+
421
+ function serviceUninstall() {
422
+ const p = requireSupportedPlatform();
423
+ if (p === "linux") {
424
+ runQuiet("systemctl", ["--user", "disable", "--now", `${SERVICE_NAME}.service`]);
425
+ const unitPath = path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
426
+ if (existsSync(unitPath)) { rmSync(unitPath); console.log(`Removed ${unitPath}`); }
427
+ runQuiet("systemctl", ["--user", "daemon-reload"]);
428
+ } else {
429
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
430
+ runQuiet("launchctl", ["unload", "-w", plistPath]);
431
+ if (existsSync(plistPath)) { rmSync(plistPath); console.log(`Removed ${plistPath}`); }
432
+ }
433
+ console.log("Service uninstalled.");
434
+ }
435
+
436
+ // ── service: status / logs ──────────────────────────────────────────────────
437
+
438
+ function serviceStatus() {
439
+ const p = requireSupportedPlatform();
440
+ if (p === "linux") {
441
+ try { run("systemctl", ["--user", "status", `${SERVICE_NAME}.service`, "--no-pager"]); }
442
+ catch { /* systemctl exits non-zero when inactive; output already shown */ }
443
+ } else {
444
+ const out = runQuiet("launchctl", ["list"]);
445
+ if (out) {
446
+ const line = out.split("\n").find((l) => l.includes(LAUNCHD_LABEL));
447
+ console.log(line ? line.trim() : `${LAUNCHD_LABEL}: not loaded`);
448
+ }
449
+ }
450
+ }
451
+
452
+ function serviceLogs() {
453
+ const p = requireSupportedPlatform();
454
+ if (p === "linux") {
455
+ run("journalctl", ["--user", "-u", `${SERVICE_NAME}.service`, "-n", "100", "-f"]);
456
+ } else {
457
+ const outLog = path.join(logDir, "out.log");
458
+ const errLog = path.join(logDir, "err.log");
459
+ run("tail", ["-n", "100", "-f", outLog, errLog]);
460
+ }
461
+ }
462
+
463
+ function serviceIsInstalled() {
464
+ const p = platform();
465
+ if (p === "linux") {
466
+ return existsSync(path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`));
467
+ }
468
+ if (p === "macos") {
469
+ return existsSync(path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`));
470
+ }
471
+ return false;
472
+ }
473
+
474
+ function serviceRestart() {
475
+ const p = platform();
476
+ if (p === "linux") {
477
+ run("systemctl", ["--user", "restart", `${SERVICE_NAME}.service`]);
478
+ } else if (p === "macos") {
479
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
480
+ runQuiet("launchctl", ["unload", plistPath]);
481
+ run("launchctl", ["load", "-w", plistPath]);
482
+ }
483
+ }
484
+
485
+ // ── update ────────────────────────────────────────────────────────────────
486
+
487
+ function detectPackageManager() {
488
+ // Prefer the manager whose global root contains this install.
489
+ const ua = process.env.npm_config_user_agent ?? "";
490
+ if (ua.startsWith("pnpm")) return "pnpm";
491
+ if (ua.startsWith("yarn")) return "yarn";
492
+ if (selfScript.includes(`${path.sep}pnpm${path.sep}`)) return "pnpm";
493
+ return "npm";
494
+ }
495
+
496
+ function update() {
497
+ const pkg = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8"));
498
+ const name = pkg.name;
499
+ console.log(`Current ${name}: v${pkg.version}`);
500
+
501
+ const pm = detectPackageManager();
502
+ const cmd = pm === "pnpm" ? ["pnpm", ["add", "-g", `${name}@latest`]]
503
+ : pm === "yarn" ? ["yarn", ["global", "add", `${name}@latest`]]
504
+ : ["npm", ["install", "-g", `${name}@latest`]];
505
+
506
+ console.log(`Updating via ${pm}…`);
507
+ try {
508
+ run(cmd[0], cmd[1]);
509
+ } catch {
510
+ console.error(`Error: update failed. Try manually: ${cmd[0]} ${cmd[1].join(" ")}`);
511
+ process.exit(1);
512
+ }
513
+
514
+ if (serviceIsInstalled()) {
515
+ console.log("Restarting service…");
516
+ try { serviceRestart(); console.log("Service restarted."); }
517
+ catch { console.log("Note: could not restart service automatically. Run: wiki-viewer service restart"); }
518
+ }
519
+ console.log("Update complete.");
520
+ }
521
+
522
+ // ── dispatch ──────────────────────────────────────────────────────────────
523
+
524
+ const argv = process.argv.slice(2);
525
+
526
+ if (argv.includes("--help") || argv.includes("-h")) {
527
+ printUsage();
528
+ process.exit(0);
529
+ }
530
+
531
+ const [cmd, ...rest] = argv;
532
+
533
+ switch (cmd) {
534
+ case "service": {
535
+ const [sub, ...subArgs] = rest;
536
+ switch (sub) {
537
+ case "install": serviceInstall(subArgs); break;
538
+ case "uninstall": serviceUninstall(); break;
539
+ case "status": serviceStatus(); break;
540
+ case "logs": serviceLogs(); break;
541
+ case "restart": serviceRestart(); break;
542
+ case "run": start(resolveRunOptions(subArgs)); break;
543
+ default:
544
+ console.error(`Unknown service command: ${sub ?? "(none)"}`);
545
+ console.error("Try: install | uninstall | status | logs | restart | run");
546
+ process.exit(1);
547
+ }
548
+ break;
549
+ }
550
+ case "update":
551
+ update();
552
+ break;
553
+ default:
554
+ // No recognized command → ad-hoc serve (directory + flags only).
555
+ start(resolveServeOptions(argv));
556
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-viewer",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Local file viewer and wiki — view markdown, HTML, PDFs, notebooks, office docs, and more from any directory",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/README.md CHANGED
@@ -77,6 +77,14 @@ Options:
77
77
  -H, --host <host> Host to bind to (default: localhost)
78
78
  --https Enable HTTPS (self-signed cert, required on remote)
79
79
  -h, --help Show this help message
80
+
81
+ Commands:
82
+ service install [dir] [options] Install as a user service (persists across reboot)
83
+ service uninstall Remove the user service
84
+ service status Show service status
85
+ service logs Tail service logs
86
+ service restart Restart the service
87
+ update Update to the latest version and restart the service
80
88
  ```
81
89
 
82
90
  Examples:
@@ -92,6 +100,35 @@ npx wiki-viewer ~/notes -H 0.0.0.0
92
100
  npx wiki-viewer ~/notes --https -p 8443
93
101
  ```
94
102
 
103
+ ### Run as a service (reboot persistence)
104
+
105
+ Install wiki-viewer as a user service so it starts at boot and restarts on failure. Linux uses `systemd --user`, macOS uses a launchd LaunchAgent. No root needed.
106
+
107
+ ```bash
108
+ # Install with the run config you want (dir, host, port, https)
109
+ wiki-viewer service install ~/notes -H 0.0.0.0 -p 3003 --https
110
+
111
+ # Manage it
112
+ wiki-viewer service status
113
+ wiki-viewer service logs
114
+ wiki-viewer service restart
115
+ wiki-viewer service uninstall
116
+ ```
117
+
118
+ The run config is saved to `~/.wiki-viewer/config.json`. Edit that file and run `wiki-viewer service restart` to change settings without reinstalling. To change just the served directory at runtime, you can also click **Change** in the sidebar.
119
+
120
+ On Linux, install enables lingering (`loginctl enable-linger`) so the service runs without an active login session and survives reboot. If that step needs privileges, the installer prints the command to run manually.
121
+
122
+ > Ad-hoc runs like `wiki-viewer ~/docs` ignore the saved config. Only the service (and `wiki-viewer service run`) reads `config.json`.
123
+
124
+ ### Update
125
+
126
+ ```bash
127
+ wiki-viewer update
128
+ ```
129
+
130
+ Updates the global install to the latest version (npm/pnpm/yarn auto-detected) and restarts the service if one is installed.
131
+
95
132
  ---
96
133
 
97
134
  ## Auth and multi-user mode
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, execSync } from "node:child_process";
2
+ import { spawn, execSync, execFileSync } from "node:child_process";
3
3
  import { createServer as createHttpsServer } from "node:https";
4
4
  import { createServer as createHttpServer, request as httpRequest } from "node:http";
5
5
  import { createServer as createNetServer } from "node:net";
6
- import { readFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import os from "node:os";
9
9
  import { fileURLToPath } from "node:url";
@@ -11,9 +11,18 @@ import { fileURLToPath } from "node:url";
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
12
  const appRoot = path.resolve(__dirname, "..");
13
13
  const serverJs = path.join(appRoot, ".next", "standalone", "server.js");
14
+ const selfScript = fileURLToPath(import.meta.url);
15
+
16
+ const configDir = path.join(os.homedir(), ".wiki-viewer");
17
+ const configPath = path.join(configDir, "config.json");
18
+ const logDir = path.join(configDir, "logs");
19
+
20
+ const SERVICE_NAME = "wiki-viewer";
21
+ const LAUNCHD_LABEL = "com.wiki-viewer";
14
22
 
15
23
  function printUsage() {
16
24
  console.error("Usage: wiki-viewer [directory] [options]");
25
+ console.error(" wiki-viewer <command> [args]");
17
26
  console.error("");
18
27
  console.error(" directory Directory to serve (optional — pick in browser if omitted)");
19
28
  console.error("");
@@ -22,44 +31,99 @@ function printUsage() {
22
31
  console.error(" -H, --host <host> Host to bind to (default: localhost)");
23
32
  console.error(" --https Enable HTTPS (self-signed cert, enables service workers)");
24
33
  console.error("");
34
+ console.error("Commands:");
35
+ console.error(" service install [dir] [options] Install as a user service (persists across reboot)");
36
+ console.error(" service uninstall Remove the user service");
37
+ console.error(" service status Show service status");
38
+ console.error(" service logs Tail service logs");
39
+ console.error(" service run Run from saved config (used internally by the service)");
40
+ console.error(" update Update wiki-viewer to the latest version and restart");
41
+ console.error("");
25
42
  console.error("Examples:");
26
43
  console.error(" wiki-viewer ~/notes");
27
44
  console.error(" wiki-viewer ~/notes --https");
28
45
  console.error(" wiki-viewer ~/notes -p 8080 -H 0.0.0.0");
46
+ console.error(" wiki-viewer service install ~/notes -H 0.0.0.0 -p 3003 --https");
47
+ console.error(" wiki-viewer update");
29
48
  }
30
49
 
31
- if (!existsSync(serverJs)) {
32
- console.error("Error: pre-built server not found at", serverJs);
33
- console.error("This is a bug – please report it at https://github.com/anh-chu/wiki-viewer/issues");
34
- process.exit(1);
50
+ // ── arg parsing ──────────────────────────────────────────────────────────────
51
+
52
+ function parseServeArgs(args) {
53
+ let port = process.env.PORT;
54
+ let host = process.env.HOSTNAME;
55
+ let useHttps;
56
+ let userSpecifiedPort = false;
57
+ let rootDir;
58
+
59
+ for (let i = 0; i < args.length; i++) {
60
+ const a = args[i];
61
+ if (a === "-p" || a === "--port") { port = args[++i] ?? port; userSpecifiedPort = true; }
62
+ else if (a === "-H" || a === "--host") host = args[++i] ?? host;
63
+ else if (a === "--https") useHttps = true;
64
+ else if (!a.startsWith("-") && rootDir === undefined) rootDir = a;
65
+ }
66
+
67
+ return { rootDir, port, host, useHttps, userSpecifiedPort };
35
68
  }
36
69
 
37
- const args = process.argv.slice(2);
70
+ // ── config file ────────────────────────────────────────────────────────────
38
71
 
39
- if (args.includes("--help") || args.includes("-h")) {
40
- printUsage();
41
- process.exit(0);
72
+ function loadConfig() {
73
+ if (!existsSync(configPath)) return {};
74
+ try {
75
+ return JSON.parse(readFileSync(configPath, "utf8"));
76
+ } catch {
77
+ console.error(`Warning: could not parse ${configPath}, ignoring it`);
78
+ return {};
79
+ }
42
80
  }
43
81
 
44
- const rootDir = args.find((a) => !a.startsWith("-"));
45
- let port = process.env.PORT ?? "3000";
46
- let host = process.env.HOSTNAME ?? "localhost";
47
- let useHttps = false;
48
- let userSpecifiedPort = false;
82
+ function saveConfig(cfg) {
83
+ mkdirSync(configDir, { recursive: true });
84
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
85
+ }
49
86
 
50
- for (let i = 0; i < args.length; i++) {
51
- const a = args[i];
52
- if (a === "-p" || a === "--port") { port = args[++i] ?? port; userSpecifiedPort = true; }
53
- else if (a === "-H" || a === "--host") host = args[++i] ?? host;
54
- else if (a === "--https") useHttps = true;
87
+ // Ad-hoc serve: pure CLI flags + built-in defaults. Does NOT read the config
88
+ // file, so an installed service never silently alters a one-off invocation.
89
+ function resolveServeOptions(args) {
90
+ const cli = parseServeArgs(args);
91
+ return {
92
+ rootDir: cli.rootDir ? path.resolve(cli.rootDir) : null,
93
+ port: String(cli.port ?? "3000"),
94
+ host: cli.host ?? "localhost",
95
+ useHttps: Boolean(cli.useHttps),
96
+ userSpecifiedPort: cli.userSpecifiedPort,
97
+ };
55
98
  }
56
99
 
57
- const resolvedRoot = rootDir ? path.resolve(rootDir) : null;
100
+ // Service run: config file is the source of truth. CLI flags (if any) still win
101
+ // so the unit/plist could pass overrides, but normally there are none.
102
+ // Precedence: explicit CLI flags > config file > built-in defaults.
103
+ function resolveRunOptions(args) {
104
+ const cli = parseServeArgs(args);
105
+ const cfg = loadConfig();
106
+
107
+ const rootDir = cli.rootDir ?? cfg.rootDir ?? null;
108
+ const port = cli.port ?? cfg.port ?? "3000";
109
+ const host = cli.host ?? cfg.host ?? "localhost";
110
+ const useHttps = cli.useHttps ?? cfg.https ?? false;
111
+ // A config-pinned port is explicit too (don't auto-bump to next free port).
112
+ const userSpecifiedPort = cli.userSpecifiedPort || cfg.port != null;
113
+
114
+ return {
115
+ rootDir: rootDir ? path.resolve(rootDir) : null,
116
+ port: String(port),
117
+ host,
118
+ useHttps: Boolean(useHttps),
119
+ userSpecifiedPort,
120
+ };
121
+ }
58
122
 
59
123
  // ── HTTPS cert generation ──────────────────────────────────────────────────
60
124
 
61
- function ensureCerts() {
62
- const dir = path.join(os.homedir(), ".wiki-viewer", "certs");
125
+ function ensureCerts(host) {
126
+ const dir = path.join(configDir, "certs");
63
127
  mkdirSync(dir, { recursive: true });
64
128
  const keyPath = path.join(dir, "key.pem");
65
129
  const certPath = path.join(dir, "cert.pem");
@@ -70,14 +134,14 @@ function ensureCerts() {
70
134
  try {
71
135
  execSync("mkcert -version", { stdio: "ignore" });
72
136
  execSync(`mkcert -install 2>/dev/null; mkcert -key-file "${keyPath}" -cert-file "${certPath}" localhost 127.0.0.1 "${host}"`, { stdio: "pipe" });
73
- console.log("🔒 Trusted cert via mkcert");
137
+ console.log("�� Trusted cert via mkcert");
74
138
  } catch {
75
139
  try {
76
140
  execSync(
77
141
  `openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 825 -nodes -subj "/CN=localhost"`,
78
142
  { stdio: "ignore" },
79
143
  );
80
- console.log("🔒 Self-signed cert (browser will warn once — click through)");
144
+ console.log("�� Self-signed cert (browser will warn once — click through)");
81
145
  } catch {
82
146
  console.error("Error: --https requires mkcert or openssl");
83
147
  process.exit(1);
@@ -103,8 +167,6 @@ async function findNextAvailablePort(startPort, h) {
103
167
  return String(p);
104
168
  }
105
169
 
106
- // ── free port helper ───────────────────────────────────────────────────────
107
-
108
170
  function freePort() {
109
171
  return new Promise((resolve) => {
110
172
  const s = createNetServer();
@@ -115,8 +177,6 @@ function freePort() {
115
177
  });
116
178
  }
117
179
 
118
- // ── network address helper ─────────────────────────────────────────────────
119
-
120
180
  function getNetworkAddress() {
121
181
  for (const ifaces of Object.values(os.networkInterfaces())) {
122
182
  for (const iface of ifaces ?? []) {
@@ -128,11 +188,20 @@ function getNetworkAddress() {
128
188
 
129
189
  // ── start ──────────────────────────────────────────────────────────────────
130
190
 
131
- async function start() {
191
+ async function start(opts) {
192
+ const { rootDir: resolvedRoot, useHttps } = opts;
193
+ let { port, host, userSpecifiedPort } = opts;
194
+
195
+ if (!existsSync(serverJs)) {
196
+ console.error("Error: pre-built server not found at", serverJs);
197
+ console.error("This is a bug – please report it at https://github.com/anh-chu/wiki-viewer/issues");
198
+ process.exit(1);
199
+ }
200
+
132
201
  if (resolvedRoot) {
133
- console.log(`📁 ${resolvedRoot}`);
202
+ console.log(`�� ${resolvedRoot}`);
134
203
  } else {
135
- console.log("📂 No directory specified — open the browser to choose one");
204
+ console.log("�� No directory specified — open the browser to choose one");
136
205
  }
137
206
 
138
207
  // Auto-select next free port when user didn't specify one
@@ -148,7 +217,9 @@ async function start() {
148
217
  // When HTTPS is requested, run the standalone server on a random internal
149
218
  // HTTP port and stand up an HTTPS reverse-proxy on the user-facing port.
150
219
  const internalPort = useHttps ? String(await freePort()) : port;
151
- const internalHost = "127.0.0.1";
220
+ // In HTTPS mode the standalone server sits behind the proxy on loopback.
221
+ // Otherwise it must bind to the user-requested host directly.
222
+ const internalHost = useHttps ? "127.0.0.1" : host;
152
223
 
153
224
  const child = spawn(process.execPath, [serverJs], {
154
225
  cwd: path.join(appRoot, ".next", "standalone"),
@@ -164,7 +235,7 @@ async function start() {
164
235
  child.on("exit", (code) => process.exit(code ?? 0));
165
236
 
166
237
  if (useHttps) {
167
- const { key, cert } = ensureCerts();
238
+ const { key, cert } = ensureCerts(host);
168
239
 
169
240
  // Simple HTTPS → HTTP proxy
170
241
  const proxy = createHttpsServer({ key, cert }, (req, res) => {
@@ -208,4 +279,278 @@ async function start() {
208
279
  }
209
280
  }
210
281
 
211
- start();
282
+ // ── service: shared ──────────────────────────────────────────────────────────
283
+
284
+ function platform() {
285
+ if (process.platform === "linux") return "linux";
286
+ if (process.platform === "darwin") return "macos";
287
+ return null;
288
+ }
289
+
290
+ function requireSupportedPlatform() {
291
+ const p = platform();
292
+ if (!p) {
293
+ console.error(`Error: service management is only supported on Linux (systemd) and macOS (launchd), not ${process.platform}.`);
294
+ process.exit(1);
295
+ }
296
+ return p;
297
+ }
298
+
299
+ function run(cmd, args, opts = {}) {
300
+ return execFileSync(cmd, args, { stdio: "inherit", ...opts });
301
+ }
302
+
303
+ function runQuiet(cmd, args) {
304
+ try {
305
+ return execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }).toString();
306
+ } catch (e) {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ // ── service: install ──────────────────────────────────────────────────────────
312
+
313
+ function serviceInstall(args) {
314
+ const p = requireSupportedPlatform();
315
+
316
+ // Capture the run config from flags (falling back to existing config), persist it.
317
+ const cli = parseServeArgs(args);
318
+ const existing = loadConfig();
319
+ const cfg = {
320
+ rootDir: cli.rootDir != null ? path.resolve(cli.rootDir) : existing.rootDir ?? null,
321
+ host: cli.host ?? existing.host ?? "localhost",
322
+ port: cli.port ?? existing.port ?? "3000",
323
+ https: cli.useHttps ?? existing.https ?? false,
324
+ };
325
+ saveConfig(cfg);
326
+ console.log(`Saved config to ${configPath}`);
327
+ console.log(` dir: ${cfg.rootDir ?? "(choose in browser)"}`);
328
+ console.log(` host: ${cfg.host}`);
329
+ console.log(` port: ${cfg.port}`);
330
+ console.log(` https: ${cfg.https}`);
331
+ console.log("");
332
+
333
+ if (p === "linux") installSystemd();
334
+ else installLaunchd();
335
+ }
336
+
337
+ function installSystemd() {
338
+ const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
339
+ mkdirSync(unitDir, { recursive: true });
340
+ const unitPath = path.join(unitDir, `${SERVICE_NAME}.service`);
341
+
342
+ const unit = `[Unit]
343
+ Description=wiki-viewer local file viewer
344
+ After=network.target
345
+
346
+ [Service]
347
+ Type=simple
348
+ ExecStart=${process.execPath} ${selfScript} service run
349
+ Restart=on-failure
350
+ RestartSec=3
351
+ Environment=NODE_ENV=production
352
+
353
+ [Install]
354
+ WantedBy=default.target
355
+ `;
356
+ writeFileSync(unitPath, unit);
357
+ console.log(`Wrote unit ${unitPath}`);
358
+
359
+ // Enable lingering so the service survives logout and starts at boot.
360
+ const user = os.userInfo().username;
361
+ try {
362
+ execFileSync("loginctl", ["enable-linger", user], { stdio: "ignore" });
363
+ console.log(`Enabled linger for ${user} (starts at boot)`);
364
+ } catch {
365
+ console.log(`Note: could not enable linger automatically. For boot persistence run:`);
366
+ console.log(` sudo loginctl enable-linger ${user}`);
367
+ }
368
+
369
+ run("systemctl", ["--user", "daemon-reload"]);
370
+ run("systemctl", ["--user", "enable", "--now", `${SERVICE_NAME}.service`]);
371
+ console.log("\nService installed and started.");
372
+ console.log(" Status: wiki-viewer service status");
373
+ console.log(" Logs: wiki-viewer service logs");
374
+ }
375
+
376
+ function installLaunchd() {
377
+ const agentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
378
+ mkdirSync(agentsDir, { recursive: true });
379
+ mkdirSync(logDir, { recursive: true });
380
+ const plistPath = path.join(agentsDir, `${LAUNCHD_LABEL}.plist`);
381
+ const outLog = path.join(logDir, "out.log");
382
+ const errLog = path.join(logDir, "err.log");
383
+
384
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
385
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
386
+ <plist version="1.0">
387
+ <dict>
388
+ <key>Label</key>
389
+ <string>${LAUNCHD_LABEL}</string>
390
+ <key>ProgramArguments</key>
391
+ <array>
392
+ <string>${process.execPath}</string>
393
+ <string>${selfScript}</string>
394
+ <string>service</string>
395
+ <string>run</string>
396
+ </array>
397
+ <key>RunAtLoad</key>
398
+ <true/>
399
+ <key>KeepAlive</key>
400
+ <true/>
401
+ <key>StandardOutPath</key>
402
+ <string>${outLog}</string>
403
+ <key>StandardErrorPath</key>
404
+ <string>${errLog}</string>
405
+ </dict>
406
+ </plist>
407
+ `;
408
+ writeFileSync(plistPath, plist);
409
+ console.log(`Wrote plist ${plistPath}`);
410
+
411
+ // Reload if already loaded, then load with -w to persist across reboot.
412
+ runQuiet("launchctl", ["unload", plistPath]);
413
+ run("launchctl", ["load", "-w", plistPath]);
414
+ console.log("\nService installed and started.");
415
+ console.log(" Status: wiki-viewer service status");
416
+ console.log(" Logs: wiki-viewer service logs");
417
+ }
418
+
419
+ // ── service: uninstall ──────────────────────────────────────────────────────
420
+
421
+ function serviceUninstall() {
422
+ const p = requireSupportedPlatform();
423
+ if (p === "linux") {
424
+ runQuiet("systemctl", ["--user", "disable", "--now", `${SERVICE_NAME}.service`]);
425
+ const unitPath = path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
426
+ if (existsSync(unitPath)) { rmSync(unitPath); console.log(`Removed ${unitPath}`); }
427
+ runQuiet("systemctl", ["--user", "daemon-reload"]);
428
+ } else {
429
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
430
+ runQuiet("launchctl", ["unload", "-w", plistPath]);
431
+ if (existsSync(plistPath)) { rmSync(plistPath); console.log(`Removed ${plistPath}`); }
432
+ }
433
+ console.log("Service uninstalled.");
434
+ }
435
+
436
+ // ── service: status / logs ──────────────────────────────────────────────────
437
+
438
+ function serviceStatus() {
439
+ const p = requireSupportedPlatform();
440
+ if (p === "linux") {
441
+ try { run("systemctl", ["--user", "status", `${SERVICE_NAME}.service`, "--no-pager"]); }
442
+ catch { /* systemctl exits non-zero when inactive; output already shown */ }
443
+ } else {
444
+ const out = runQuiet("launchctl", ["list"]);
445
+ if (out) {
446
+ const line = out.split("\n").find((l) => l.includes(LAUNCHD_LABEL));
447
+ console.log(line ? line.trim() : `${LAUNCHD_LABEL}: not loaded`);
448
+ }
449
+ }
450
+ }
451
+
452
+ function serviceLogs() {
453
+ const p = requireSupportedPlatform();
454
+ if (p === "linux") {
455
+ run("journalctl", ["--user", "-u", `${SERVICE_NAME}.service`, "-n", "100", "-f"]);
456
+ } else {
457
+ const outLog = path.join(logDir, "out.log");
458
+ const errLog = path.join(logDir, "err.log");
459
+ run("tail", ["-n", "100", "-f", outLog, errLog]);
460
+ }
461
+ }
462
+
463
+ function serviceIsInstalled() {
464
+ const p = platform();
465
+ if (p === "linux") {
466
+ return existsSync(path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`));
467
+ }
468
+ if (p === "macos") {
469
+ return existsSync(path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`));
470
+ }
471
+ return false;
472
+ }
473
+
474
+ function serviceRestart() {
475
+ const p = platform();
476
+ if (p === "linux") {
477
+ run("systemctl", ["--user", "restart", `${SERVICE_NAME}.service`]);
478
+ } else if (p === "macos") {
479
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
480
+ runQuiet("launchctl", ["unload", plistPath]);
481
+ run("launchctl", ["load", "-w", plistPath]);
482
+ }
483
+ }
484
+
485
+ // ── update ────────────────────────────────────────────────────────────────
486
+
487
+ function detectPackageManager() {
488
+ // Prefer the manager whose global root contains this install.
489
+ const ua = process.env.npm_config_user_agent ?? "";
490
+ if (ua.startsWith("pnpm")) return "pnpm";
491
+ if (ua.startsWith("yarn")) return "yarn";
492
+ if (selfScript.includes(`${path.sep}pnpm${path.sep}`)) return "pnpm";
493
+ return "npm";
494
+ }
495
+
496
+ function update() {
497
+ const pkg = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8"));
498
+ const name = pkg.name;
499
+ console.log(`Current ${name}: v${pkg.version}`);
500
+
501
+ const pm = detectPackageManager();
502
+ const cmd = pm === "pnpm" ? ["pnpm", ["add", "-g", `${name}@latest`]]
503
+ : pm === "yarn" ? ["yarn", ["global", "add", `${name}@latest`]]
504
+ : ["npm", ["install", "-g", `${name}@latest`]];
505
+
506
+ console.log(`Updating via ${pm}…`);
507
+ try {
508
+ run(cmd[0], cmd[1]);
509
+ } catch {
510
+ console.error(`Error: update failed. Try manually: ${cmd[0]} ${cmd[1].join(" ")}`);
511
+ process.exit(1);
512
+ }
513
+
514
+ if (serviceIsInstalled()) {
515
+ console.log("Restarting service…");
516
+ try { serviceRestart(); console.log("Service restarted."); }
517
+ catch { console.log("Note: could not restart service automatically. Run: wiki-viewer service restart"); }
518
+ }
519
+ console.log("Update complete.");
520
+ }
521
+
522
+ // ── dispatch ──────────────────────────────────────────────────────────────
523
+
524
+ const argv = process.argv.slice(2);
525
+
526
+ if (argv.includes("--help") || argv.includes("-h")) {
527
+ printUsage();
528
+ process.exit(0);
529
+ }
530
+
531
+ const [cmd, ...rest] = argv;
532
+
533
+ switch (cmd) {
534
+ case "service": {
535
+ const [sub, ...subArgs] = rest;
536
+ switch (sub) {
537
+ case "install": serviceInstall(subArgs); break;
538
+ case "uninstall": serviceUninstall(); break;
539
+ case "status": serviceStatus(); break;
540
+ case "logs": serviceLogs(); break;
541
+ case "restart": serviceRestart(); break;
542
+ case "run": start(resolveRunOptions(subArgs)); break;
543
+ default:
544
+ console.error(`Unknown service command: ${sub ?? "(none)"}`);
545
+ console.error("Try: install | uninstall | status | logs | restart | run");
546
+ process.exit(1);
547
+ }
548
+ break;
549
+ }
550
+ case "update":
551
+ update();
552
+ break;
553
+ default:
554
+ // No recognized command → ad-hoc serve (directory + flags only).
555
+ start(resolveServeOptions(argv));
556
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-viewer",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Local file viewer and wiki — view markdown, HTML, PDFs, notebooks, office docs, and more from any directory",
5
5
  "license": "MIT",
6
6
  "type": "module",