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.
- package/.next/standalone/README.md +37 -0
- package/.next/standalone/bin/wiki-viewer.js +380 -35
- package/.next/standalone/package.json +1 -1
- package/README.md +37 -0
- package/bin/wiki-viewer.js +380 -35
- package/package.json +1 -1
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
process.
|
|
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
|
-
|
|
70
|
+
// ── config file ────────────────────────────────────────────────────────────
|
|
38
71
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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("
|
|
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(
|
|
202
|
+
console.log(`�� ${resolvedRoot}`);
|
|
134
203
|
} else {
|
|
135
|
-
console.log("
|
|
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
|
-
|
|
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
|
-
|
|
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/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
|
package/bin/wiki-viewer.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
process.
|
|
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
|
-
|
|
70
|
+
// ── config file ────────────────────────────────────────────────────────────
|
|
38
71
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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("
|
|
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(
|
|
202
|
+
console.log(`�� ${resolvedRoot}`);
|
|
134
203
|
} else {
|
|
135
|
-
console.log("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|