home-hosted 0.7.10 → 0.7.12

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/AGENTS.md CHANGED
@@ -24,6 +24,7 @@ seed entry, or a path that names one.
24
24
  ```sh
25
25
  pnpm run up|down|restart|status # detached; `down` asks /_hh/shutdown, signals are the fallback
26
26
  pnpm run start # up --foreground (systemd, docker, a foreground shell)
27
+ pnpm exec tsx src/cli.ts logs # the panel's own console output (--lines, --follow, --json)
27
28
  pnpm dev # tsx-watch panel :6000 + the stock UI's Vite :6001 (proxies /api), state in .dev-state/
28
29
  pnpm dev --ui noc-console # the same, for another UI; --port/--ui-port override
29
30
  pnpm run build # dist/cli.js + the stock UI (uis/stock/dist)
@@ -53,6 +54,19 @@ UI and every UI zip, lets changelogen write the changelog, bump `package.json`,
53
54
  trusted publishing (OIDC, no token). npm only offers a trusted publisher for a package that already
54
55
  exists, so the first release has to be published by hand.
55
56
 
57
+ Four workflows, and the split is deliberate:
58
+
59
+ | workflow | runs on | what it proves |
60
+ | --- | --- | --- |
61
+ | `quickcheck.yml` / `test.yml` | every push and PR | lint, types, the whole suite with coverage — Linux only |
62
+ | `cross-platform.yml` | the release gate, or by hand | the same suite on macOS and Windows |
63
+ | `release.yml` | dispatched with a version | the platform gate, then tag, publish, release assets |
64
+
65
+ The platform gate is not a per-push job on purpose: it is the expensive half, and it is what makes a
66
+ platform-specific break fail before shipping rather than on someone's machine. It also runs on a
67
+ `dry-run`, because that is exactly what a rehearsal needs to exercise. Every job carries a
68
+ `timeout-minutes`, so a wedged runner is reported instead of sitting out GitHub's 6-hour default.
69
+
56
70
  ### Which version to dispatch
57
71
 
58
72
  Below 1.0 the **minor is the breaking channel**: a fix or a non-breaking feature is a **patch**
package/README.md CHANGED
@@ -344,6 +344,7 @@ restarts itself), and how hand-edits are validated: [SERVERS.md](./docs/SERVERS.
344
344
  | `home-hosted status` | pid, URL, health, uptime, state and log paths (`--json` for scripts) |
345
345
  | `home-hosted start <id>` | start one server in the default workspace — and anything it `dependsOn` (`--workspace <id>`) |
346
346
  | `home-hosted stop <id>` | stop one server, nothing else (`--workspace <id>`) |
347
+ | `home-hosted logs` | read the panel's own console output — the file `up` redirects it into (`--lines`, `--follow`, `--json`) |
347
348
  | `home-hosted set-password` | set the panel password without opening a browser |
348
349
  | `home-hosted set-token` | set the API token scripts and agents use (`--generate`, `--clear`) |
349
350
  | `home-hosted migrate` | relocate a pre-workspaces state directory and stamp every config for this release (`--dry-run`, `--yes`) |
@@ -362,6 +363,7 @@ up, restart -c/--config -p/--port --host --open --no-autostart --foregroun
362
363
 
363
364
  down (no flags)
364
365
  status --json
366
+ logs --lines <n> --follow --json
365
367
  start, stop <id> [-w/--workspace <id>] (both need the panel up)
366
368
  init --dir --name --pm --no-install -y/--yes
367
369
  set-password --clear
@@ -507,6 +509,24 @@ specific:
507
509
 
508
510
  ## ❓ FAQ
509
511
 
512
+ <details>
513
+ <summary><b>The panel is up but something is wrong — where do I look?</b></summary>
514
+
515
+ `home-hosted logs` prints the panel's own console output, which is where it reports what it is
516
+ doing and anything it could not finish:
517
+
518
+ ```bash
519
+ home-hosted logs # the last 50 lines
520
+ home-hosted logs --follow # keep printing, like tail -f
521
+ home-hosted logs --lines all # the whole thing, including the rotation
522
+ ```
523
+
524
+ It is the same file `status` names, and reading it needs no session or API token — a panel that
525
+ is answering badly is exactly when you cannot get one. `up` prints the tail automatically when
526
+ the panel fails to start.
527
+
528
+ </details>
529
+
510
530
  <details>
511
531
  <summary><b>Is it a systemd replacement?</b></summary>
512
532
 
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { defineCommand, runCommand } from "citty";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline";
7
7
  import { exec, execFile, spawn, spawnSync } from "node:child_process";
8
+ import { Buffer as Buffer$1 } from "node:buffer";
8
9
  import os from "node:os";
9
10
  import { describeRoute, openAPIRouteHandler, resolver, validator } from "hono-openapi";
10
11
  import { createFactory } from "hono/factory";
@@ -13,7 +14,6 @@ import { isDevelopment } from "std-env";
13
14
  import { DetailedError } from "@namesmt/utils";
14
15
  import { type } from "arktype";
15
16
  import crypto, { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
16
- import { Buffer as Buffer$1 } from "node:buffer";
17
17
  import { streamSSE } from "hono/streaming";
18
18
  import { autoRetry } from "@grammyjs/auto-retry";
19
19
  import { Bot } from "grammy";
@@ -306,6 +306,112 @@ var init_runtime = __esmMin((() => {
306
306
  NANNY_COMMAND = "__nanny";
307
307
  }));
308
308
  //#endregion
309
+ //#region src/helpers/daemon-log.ts
310
+ var daemon_log_exports = /* @__PURE__ */ __exportAll({
311
+ followLog: () => followLog,
312
+ readLog: () => readLog,
313
+ rotateLog: () => rotateLog,
314
+ tailLog: () => tailLog
315
+ });
316
+ function rotateLog(file) {
317
+ try {
318
+ if (fs.statSync(file).size < LOG_ROTATE_BYTES) return;
319
+ fs.rmSync(`${file}.1`, { force: true });
320
+ fs.renameSync(file, `${file}.1`);
321
+ } catch {}
322
+ }
323
+ /**
324
+ * The last `lines` lines, or `''` when there is no log to read.
325
+ *
326
+ * Split on the trailing newline rather than after it: a log file ends with one, so a plain
327
+ * `split('\n').slice(-lines)` counts that empty last element as a line and quietly returns
328
+ * one fewer than asked for.
329
+ */
330
+ function tailLog(file, lines = 15) {
331
+ return withoutTrailingBlank(readLogLines(file)).slice(-lines).join("\n");
332
+ }
333
+ function readLogLines(file) {
334
+ try {
335
+ return fs.readFileSync(file, "utf8").split("\n");
336
+ } catch {
337
+ return [];
338
+ }
339
+ }
340
+ /** A file ending in a newline splits into a final empty element; it is not a line. */
341
+ function withoutTrailingBlank(lines) {
342
+ return lines.at(-1) === "" ? lines.slice(0, -1) : lines;
343
+ }
344
+ /**
345
+ * One rotation boundary's worth of lines, oldest first.
346
+ *
347
+ * The current file plus its `.1` ancestor, so `logs` shows what happened just before a
348
+ * rotation rather than starting blank. `lines <= 0` means the whole thing.
349
+ */
350
+ function readLog(file, lines) {
351
+ const parts = [];
352
+ for (const candidate of [`${file}.1`, file]) try {
353
+ parts.push(fs.readFileSync(candidate, "utf8"));
354
+ } catch {}
355
+ const all = withoutTrailingBlank(parts.join("").split("\n"));
356
+ return lines > 0 ? all.slice(-lines) : all;
357
+ }
358
+ /**
359
+ * Prints the last `lines` of the log, then keeps printing whatever is appended.
360
+ *
361
+ * Polled rather than watched on purpose. `config-watch` documents why: an editor's save
362
+ * replaces the inode, and `fs.watch` is undependable on some filesystems. A foreground
363
+ * command also has to notice the rotation `up` performs, which shows up simply as the file
364
+ * getting *smaller* — and a poll sees that without any special case.
365
+ *
366
+ * Resolves when the caller's signal arrives, so the command exits the way a pager would.
367
+ */
368
+ async function followLog(file, lines, write, pollMs = 250) {
369
+ const initial = readLog(file, lines);
370
+ if (initial.length > 0) write(`${initial.join("\n")}\n`);
371
+ let offset = sizeOf(file);
372
+ let stopped = false;
373
+ const stop = () => {
374
+ stopped = true;
375
+ };
376
+ process.once("SIGINT", stop);
377
+ process.once("SIGTERM", stop);
378
+ try {
379
+ for (;;) {
380
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
381
+ if (stopped) break;
382
+ const size = sizeOf(file);
383
+ if (size < offset) offset = 0;
384
+ if (size === offset) continue;
385
+ try {
386
+ const handle = fs.openSync(file, "r");
387
+ try {
388
+ const length = size - offset;
389
+ const buffer = Buffer$1.alloc(length);
390
+ fs.readSync(handle, buffer, 0, length, offset);
391
+ write(buffer.toString("utf8").replace(/\n+$/, "\n"));
392
+ } finally {
393
+ fs.closeSync(handle);
394
+ }
395
+ offset = size;
396
+ } catch {}
397
+ }
398
+ } finally {
399
+ process.removeListener("SIGINT", stop);
400
+ process.removeListener("SIGTERM", stop);
401
+ }
402
+ }
403
+ function sizeOf(file) {
404
+ try {
405
+ return fs.statSync(file).size;
406
+ } catch {
407
+ return 0;
408
+ }
409
+ }
410
+ var LOG_ROTATE_BYTES;
411
+ var init_daemon_log = __esmMin((() => {
412
+ LOG_ROTATE_BYTES = 5242880;
413
+ }));
414
+ //#endregion
309
415
  //#region src/helpers/version.ts
310
416
  var version_exports = /* @__PURE__ */ __exportAll({
311
417
  appVersion: () => appVersion,
@@ -6018,7 +6124,7 @@ function createLogsRoute(deps) {
6018
6124
  });
6019
6125
  }
6020
6126
  var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$2, downloadQuery, historyQuery;
6021
- var init_logs = __esmMin((() => {
6127
+ var init_logs$1 = __esmMin((() => {
6022
6128
  init_factory();
6023
6129
  init_openapi$1();
6024
6130
  init_validator();
@@ -8372,20 +8478,7 @@ var init_proxy$1 = __esmMin((() => {
8372
8478
  return;
8373
8479
  }
8374
8480
  await this.assertPortsFree(this.config.httpPort, this.config.httpsPort);
8375
- const result = await this.request("POST", "/load", rendered).catch((error) => {
8376
- this.lastError = `the engine did not accept the configuration: ${error instanceof Error ? error.message : String(error)}`;
8377
- throw new DetailedError(this.lastError, {
8378
- statusCode: 502,
8379
- code: "ENGINE_UNREACHABLE"
8380
- });
8381
- });
8382
- if (result.status >= 400) {
8383
- this.lastError = engineMessage(result.body) ?? `the engine rejected the configuration (HTTP ${result.status})`;
8384
- throw new DetailedError(this.lastError, {
8385
- statusCode: 400,
8386
- code: "PROXY_CONFIG_REJECTED"
8387
- });
8388
- }
8481
+ await this.postConfiguration(rendered);
8389
8482
  this.rememberConfig(text);
8390
8483
  this.rememberApplied(this.config.httpPort, this.config.httpsPort);
8391
8484
  const swept = this.sweepUnroutedCertificates();
@@ -8431,22 +8524,34 @@ var init_proxy$1 = __esmMin((() => {
8431
8524
  }
8432
8525
  return removed;
8433
8526
  }
8434
- /** Hands a configuration to the running engine without touching the file on disk. */
8435
- async loadIntoEngine(rendered) {
8527
+ /**
8528
+ * Posts one rendered configuration to the engine's admin endpoint and reports a refusal.
8529
+ *
8530
+ * The three call sites used to spell this out themselves, and one of them forgot to record
8531
+ * `lastError` — which is what `status()` shows as the proxy's state, so a configuration the
8532
+ * engine refused could leave the panel reporting the proxy as healthy. Keeping both the
8533
+ * message and the state in one place is the point.
8534
+ */
8535
+ async postConfiguration(rendered) {
8436
8536
  const result = await this.request("POST", "/load", rendered).catch((error) => {
8437
- throw new DetailedError(`the engine did not accept the configuration: ${error instanceof Error ? error.message : String(error)}`, {
8537
+ this.lastError = `the engine did not accept the configuration: ${error instanceof Error ? error.message : String(error)}`;
8538
+ throw new DetailedError(this.lastError, {
8438
8539
  statusCode: 502,
8439
8540
  code: "ENGINE_UNREACHABLE"
8440
8541
  });
8441
8542
  });
8442
8543
  if (result.status >= 400) {
8443
- const message = engineMessage(result.body) ?? `the engine rejected the configuration (HTTP ${result.status})`;
8444
- throw new DetailedError(message, {
8544
+ this.lastError = engineMessage(result.body) ?? `the engine rejected the configuration (HTTP ${result.status})`;
8545
+ throw new DetailedError(this.lastError, {
8445
8546
  statusCode: 400,
8446
8547
  code: "PROXY_CONFIG_REJECTED"
8447
8548
  });
8448
8549
  }
8449
8550
  }
8551
+ /** Hands a configuration to the running engine without touching the file on disk. */
8552
+ async loadIntoEngine(rendered) {
8553
+ await this.postConfiguration(rendered);
8554
+ }
8450
8555
  /** Advances `previous.json` only for a configuration the engine accepted. */
8451
8556
  rememberConfig(text) {
8452
8557
  if (fs.existsSync(this.options.configPath)) writeFileAtomic(this.options.previousConfigPath, fs.readFileSync(this.options.configPath, "utf8"), { mode: 384 });
@@ -8511,14 +8616,7 @@ var init_proxy$1 = __esmMin((() => {
8511
8616
  });
8512
8617
  const current = fs.existsSync(this.options.configPath) ? fs.readFileSync(this.options.configPath, "utf8") : null;
8513
8618
  const previous = fs.readFileSync(this.options.previousConfigPath, "utf8");
8514
- const result = await this.request("POST", "/load", JSON.parse(previous));
8515
- if (result.status >= 400) {
8516
- this.lastError = engineMessage(result.body) ?? `the engine rejected the configuration (HTTP ${result.status})`;
8517
- throw new DetailedError(this.lastError, {
8518
- statusCode: 400,
8519
- code: "PROXY_CONFIG_REJECTED"
8520
- });
8521
- }
8619
+ await this.postConfiguration(JSON.parse(previous));
8522
8620
  writeFileAtomic(this.options.configPath, previous, { mode: 384 });
8523
8621
  if (current === null) fs.rmSync(this.options.previousConfigPath, { force: true });
8524
8622
  else writeFileAtomic(this.options.previousConfigPath, current, { mode: 384 });
@@ -10314,7 +10412,7 @@ var init_app = __esmMin((() => {
10314
10412
  init_ddns$1();
10315
10413
  init_events();
10316
10414
  init_health();
10317
- init_logs();
10415
+ init_logs$1();
10318
10416
  init_metrics();
10319
10417
  init_notifications$1();
10320
10418
  init_proxy();
@@ -14991,6 +15089,7 @@ function stateSignature(state) {
14991
15089
  state.host.alerts.join(","),
14992
15090
  state.backups.files.length,
14993
15091
  state.backups.entries.length,
15092
+ `${state.ui.custom}:${state.ui.meta?.name ?? ""}:${state.ui.meta?.version ?? ""}`,
14994
15093
  state.proxy === void 0 ? "" : [
14995
15094
  state.proxy.config.enabled,
14996
15095
  state.proxy.status.state,
@@ -16446,21 +16545,6 @@ function toUpFlags(args) {
16446
16545
  printConfig: args.printConfig === true
16447
16546
  };
16448
16547
  }
16449
- /** One rotation is enough for a console log. */
16450
- function rotateLog(file) {
16451
- try {
16452
- if (fs.statSync(file).size < LOG_ROTATE_BYTES) return;
16453
- fs.rmSync(`${file}.1`, { force: true });
16454
- fs.renameSync(file, `${file}.1`);
16455
- } catch {}
16456
- }
16457
- function tailLog(file, lines = 15) {
16458
- try {
16459
- return fs.readFileSync(file, "utf8").split("\n").slice(-lines).join("\n").trimEnd();
16460
- } catch {
16461
- return "";
16462
- }
16463
- }
16464
16548
  async function runUp(flags, entry) {
16465
16549
  const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
16466
16550
  if (flags.foreground || flags.printConfig) {
@@ -16545,14 +16629,14 @@ function upCommand(entry) {
16545
16629
  }
16546
16630
  });
16547
16631
  }
16548
- var DEFAULT_PORT$1, LOG_ROTATE_BYTES, upArgs;
16632
+ var DEFAULT_PORT$1, upArgs;
16549
16633
  var init_up = __esmMin((() => {
16550
16634
  init_args();
16551
16635
  init_io();
16636
+ init_daemon_log();
16552
16637
  init_runtime();
16553
16638
  init_version();
16554
16639
  DEFAULT_PORT$1 = 3999;
16555
- LOG_ROTATE_BYTES = 5242880;
16556
16640
  upArgs = {
16557
16641
  config: {
16558
16642
  type: "string",
@@ -16798,6 +16882,82 @@ var init_status = __esmMin((() => {
16798
16882
  });
16799
16883
  }));
16800
16884
  //#endregion
16885
+ //#region src/cli/logs.ts
16886
+ var logs_exports = /* @__PURE__ */ __exportAll({
16887
+ logsArgs: () => logsArgs,
16888
+ logsCommand: () => logsCommand,
16889
+ runLogs: () => runLogs
16890
+ });
16891
+ /** `all` or a negative count means the whole log; a bad number is not worth a hard failure. */
16892
+ function parseLines(raw) {
16893
+ if (raw === void 0) return DEFAULT_LINES;
16894
+ if (raw.trim().toLowerCase() === "all") return 0;
16895
+ const parsed = Number.parseInt(raw, 10);
16896
+ if (!Number.isFinite(parsed)) return DEFAULT_LINES;
16897
+ return parsed < 0 ? 0 : parsed;
16898
+ }
16899
+ async function runLogs(input) {
16900
+ const { daemonLogPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
16901
+ const { readLog } = await Promise.resolve().then(() => (init_daemon_log(), daemon_log_exports));
16902
+ const lines = parseLines(input.lines);
16903
+ if (input.follow === true) {
16904
+ const { followLog } = await Promise.resolve().then(() => (init_daemon_log(), daemon_log_exports));
16905
+ await followLog(daemonLogPath, lines === 0 ? DEFAULT_LINES : lines, (chunk) => process.stdout.write(chunk));
16906
+ return;
16907
+ }
16908
+ const output = readLog(daemonLogPath, lines);
16909
+ if (input.json === true) {
16910
+ process.stdout.write(`${JSON.stringify({
16911
+ path: daemonLogPath,
16912
+ lines: output
16913
+ }, null, 2)}\n`);
16914
+ return;
16915
+ }
16916
+ if (output.length === 0) {
16917
+ process.stdout.write(`${dim(`no output yet — ${daemonLogPath}`)}\n`);
16918
+ return;
16919
+ }
16920
+ process.stdout.write(`${bold(daemonLogPath)}\n`);
16921
+ for (const line of output) process.stdout.write(`${line}\n`);
16922
+ }
16923
+ var logsArgs, DEFAULT_LINES, logsCommand;
16924
+ var init_logs = __esmMin((() => {
16925
+ init_io();
16926
+ logsArgs = {
16927
+ lines: {
16928
+ type: "string",
16929
+ description: "how many lines to show (default 50, `all` for everything)"
16930
+ },
16931
+ follow: {
16932
+ type: "boolean",
16933
+ description: "keep printing as the panel writes (like tail -f)"
16934
+ },
16935
+ json: {
16936
+ type: "boolean",
16937
+ description: "print machine-readable JSON"
16938
+ }
16939
+ };
16940
+ DEFAULT_LINES = 50;
16941
+ logsCommand = defineCommand({
16942
+ meta: {
16943
+ name: "logs",
16944
+ description: "show the panel's own console output"
16945
+ },
16946
+ args: logsArgs,
16947
+ async run({ args }) {
16948
+ try {
16949
+ await runLogs({
16950
+ lines: args.lines,
16951
+ follow: args.follow,
16952
+ json: args.json
16953
+ });
16954
+ } catch (error) {
16955
+ fail(error instanceof Error ? error.message : String(error));
16956
+ }
16957
+ }
16958
+ });
16959
+ }));
16960
+ //#endregion
16801
16961
  //#region src/cli/server.ts
16802
16962
  var server_exports = /* @__PURE__ */ __exportAll({
16803
16963
  runServerAction: () => runServerAction,
@@ -17973,6 +18133,7 @@ var SYNOPSIS = {
17973
18133
  "down": "home-hosted down",
17974
18134
  "restart": "home-hosted restart [options]",
17975
18135
  "status": "home-hosted status [--json]",
18136
+ "logs": "home-hosted logs [--lines <n>] [--follow] [--json]",
17976
18137
  "start": "home-hosted start <id> [--workspace <id>]",
17977
18138
  "stop": "home-hosted stop <id> [--workspace <id>]",
17978
18139
  "set-password": "home-hosted set-password",
@@ -17988,6 +18149,7 @@ var SUMMARIES = {
17988
18149
  "down": "stop it, and everything it supervises",
17989
18150
  "restart": "down, then up",
17990
18151
  "status": "is it running, where, and how to reach it",
18152
+ "logs": "show the panel's own console output",
17991
18153
  "start": "start one server, leaving the panel up",
17992
18154
  "stop": "stop one server, leaving the panel up",
17993
18155
  "set-password": "set the panel password without the API",
@@ -18016,6 +18178,14 @@ var STATUS_SECTION = {
18016
18178
  heading: "Options for status",
18017
18179
  lines: [["--json", "print machine-readable JSON"]]
18018
18180
  };
18181
+ var LOGS_SECTION = {
18182
+ heading: "Options for logs",
18183
+ lines: [
18184
+ ["--lines <n>", "how many lines to show (default 50, `all` for everything)"],
18185
+ ["--follow", "keep printing as the panel writes (like tail -f)"],
18186
+ ["--json", "print machine-readable JSON"]
18187
+ ]
18188
+ };
18019
18189
  var SERVER_SECTION = {
18020
18190
  heading: "Arguments for start/stop",
18021
18191
  lines: [["<id>", "the server id from servers.config.json"]]
@@ -18138,6 +18308,7 @@ var USAGE = [
18138
18308
  var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
18139
18309
  var SECTIONS = {
18140
18310
  "status": STATUS_SECTION,
18311
+ "logs": LOGS_SECTION,
18141
18312
  "start": SERVER_SECTION,
18142
18313
  "stop": SERVER_SECTION,
18143
18314
  "set-password": SET_PASSWORD_SECTION,
@@ -18186,6 +18357,7 @@ var COMMANDS = {
18186
18357
  "down": () => Promise.resolve().then(() => (init_down(), down_exports)).then((module) => module.downCommand),
18187
18358
  "restart": () => Promise.resolve().then(() => (init_restart(), restart_exports)).then((module) => module.restartCommand(CLI_ENTRY)),
18188
18359
  "status": () => Promise.resolve().then(() => (init_status(), status_exports)).then((module) => module.statusCommand),
18360
+ "logs": () => Promise.resolve().then(() => (init_logs(), logs_exports)).then((module) => module.logsCommand),
18189
18361
  "start": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.startCommand),
18190
18362
  "stop": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.stopCommand),
18191
18363
  "set-password": () => Promise.resolve().then(() => (init_set_password(), set_password_exports)).then((module) => module.setPasswordCommand),