@edspencer/paddock 0.79.1 → 0.81.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.
Files changed (116) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +7 -0
  3. package/packages/server/dist/app.js +19 -15
  4. package/packages/server/dist/auth.d.ts +3 -2
  5. package/packages/server/dist/auth.js +11 -0
  6. package/packages/server/dist/background-live.d.ts +17 -0
  7. package/packages/server/dist/background-live.js +24 -0
  8. package/packages/server/dist/busy.d.ts +96 -0
  9. package/packages/server/dist/busy.js +88 -0
  10. package/packages/server/dist/cli/args.d.ts +63 -7
  11. package/packages/server/dist/cli/args.js +227 -20
  12. package/packages/server/dist/cli/busy-command.d.ts +58 -0
  13. package/packages/server/dist/cli/busy-command.js +108 -0
  14. package/packages/server/dist/cli/busy.d.ts +69 -0
  15. package/packages/server/dist/cli/busy.js +150 -0
  16. package/packages/server/dist/cli/exit-codes.d.ts +49 -0
  17. package/packages/server/dist/cli/exit-codes.js +48 -0
  18. package/packages/server/dist/cli/output.d.ts +52 -0
  19. package/packages/server/dist/cli/output.js +84 -0
  20. package/packages/server/dist/cli/paddock.js +98 -37
  21. package/packages/server/dist/cli/service/backend.d.ts +48 -4
  22. package/packages/server/dist/cli/service/index.d.ts +68 -2
  23. package/packages/server/dist/cli/service/index.js +321 -60
  24. package/packages/server/dist/cli/service/launchd.d.ts +23 -1
  25. package/packages/server/dist/cli/service/launchd.js +145 -25
  26. package/packages/server/dist/cli/service/logs.d.ts +39 -0
  27. package/packages/server/dist/cli/service/logs.js +115 -0
  28. package/packages/server/dist/cli/service/path-env.d.ts +27 -0
  29. package/packages/server/dist/cli/service/path-env.js +120 -0
  30. package/packages/server/dist/cli/service/ready.d.ts +22 -0
  31. package/packages/server/dist/cli/service/ready.js +30 -0
  32. package/packages/server/dist/cli/service/spec.d.ts +45 -6
  33. package/packages/server/dist/cli/service/spec.js +34 -7
  34. package/packages/server/dist/cli/service/systemd.d.ts +47 -4
  35. package/packages/server/dist/cli/service/systemd.js +127 -22
  36. package/packages/server/dist/herdctl.d.ts +19 -0
  37. package/packages/server/dist/herdctl.js +26 -0
  38. package/packages/server/dist/management-mcp-server.d.ts +21 -3
  39. package/packages/server/dist/management-mcp-server.js +4 -13
  40. package/packages/server/dist/routes/meta.js +56 -3
  41. package/packages/server/dist/runtime-file.d.ts +26 -0
  42. package/packages/server/dist/runtime-file.js +91 -0
  43. package/packages/server/dist/service-log.d.ts +29 -0
  44. package/packages/server/dist/service-log.js +104 -0
  45. package/packages/server/dist/start.d.ts +33 -0
  46. package/packages/server/dist/start.js +126 -8
  47. package/packages/server/dist/version.d.ts +3 -0
  48. package/packages/server/dist/version.js +32 -0
  49. package/packages/server/dist/ws-context.d.ts +9 -0
  50. package/packages/server/dist/ws-turn.js +1 -0
  51. package/packages/server/package.json +1 -1
  52. package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-D8oIJ8HC.js} +1 -1
  53. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +2 -0
  54. package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-o0KCr3aB.js} +1 -1
  55. package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-RSJz7oL3.js} +1 -1
  56. package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-39mafH8b.js} +1 -1
  57. package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-Bq7GVguY.js} +1 -1
  58. package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BoBIru1g.js} +3 -3
  59. package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-mJ4gqbr6.js} +1 -1
  60. package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-C0kwFyYp.js} +1 -1
  61. package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-MkhXlB8z.js} +1 -1
  62. package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-DExlwAwO.js} +1 -1
  63. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-CZly3W79.js} +1 -1
  64. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-AgES73i3.js} +1 -1
  65. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-N9mD8Pfk.js} +1 -1
  66. package/packages/web/dist/assets/channel-ExsAG7qT.js +2 -0
  67. package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-Dsx_kfUl.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-BQjqcjPj.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-CCxpYQpY.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-DpFRdnUa.js} +1 -1
  71. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-D2BAlzoO.js} +1 -1
  72. package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-BFaMRzUF.js} +1 -1
  73. package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-B9NGJ9xZ.js} +1 -1
  74. package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-CF1IHyMS.js} +1 -1
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +2 -0
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +2 -0
  77. package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-XjElsvQ7.js} +1 -1
  78. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-CF972Flx.js} +1 -1
  79. package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-DsneI8YE.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-BHyrVzIk.js} +1 -1
  81. package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-Dj4q8xEi.js} +1 -1
  82. package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-BHe3Puv_.js} +1 -1
  83. package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-wzt6G2FE.js} +1 -1
  84. package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-CZMbMRJq.js} +1 -1
  85. package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-BwTIA8rI.js} +1 -1
  86. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-Dtu14BL1.js} +1 -1
  87. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-T34ql9j0.js} +1 -1
  88. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-DQLOeCXK.js} +1 -1
  89. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-Bk11zea6.js} +1 -1
  90. package/packages/web/dist/assets/{index-C4tsPSvG.js → index-CvaWErFv.js} +2 -2
  91. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-CsE83lu0.js} +1 -1
  92. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-DNVgA93n.js} +1 -1
  93. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-CLpSNjM3.js} +1 -1
  94. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BRzuXcKr.js} +1 -1
  95. package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BxOawONt.js} +1 -1
  96. package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-Dvs1QCcz.js} +4 -4
  97. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-Dmt7pYwF.js} +1 -1
  98. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-BHiZaJD-.js} +1 -1
  99. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-U0A1noRE.js} +1 -1
  100. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-CZLsr6yi.js} +1 -1
  101. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-BTlLGILU.js} +1 -1
  102. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DpSS_Gb1.js} +1 -1
  103. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-D9srE-tV.js} +1 -1
  104. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +2 -0
  105. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-BYQ5CKab.js} +1 -1
  106. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-B4W2CtSV.js} +1 -1
  107. package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY--XUV7GvD.js} +1 -1
  108. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-Bl2VIEGY.js} +1 -1
  109. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-Bj2H9Rcf.js} +1 -1
  110. package/packages/web/dist/index.html +1 -1
  111. package/packages/web/dist/sw.js +1 -1
  112. package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
  113. package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
  114. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
  115. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
  116. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
@@ -12,6 +12,23 @@ export interface RunResult {
12
12
  */
13
13
  export type Runner = (cmd: string, args: string[]) => RunResult;
14
14
  export declare const spawnRunner: Runner;
15
+ /**
16
+ * Whether the user lingers — Linux only (SPEC §7).
17
+ *
18
+ * `loginctl show-user <user> -p Linger` is the question, and all three answers
19
+ * are distinct and useful: on (nothing to say), off (the service dies at
20
+ * logout, and the fix needs `sudo`), and *unknown* — no `loginctl`, no logind,
21
+ * a container. Collapsing unknown into "off" would print a `sudo` instruction
22
+ * to someone whose machine has no `loginctl` to run it with.
23
+ */
24
+ export interface LingerState {
25
+ /** False where the platform has no such concept (darwin). */
26
+ applies: boolean;
27
+ /** On, off, or undefined when the check could not answer. */
28
+ enabled?: boolean;
29
+ /** The user the check asked about, for the printed command. */
30
+ user?: string;
31
+ }
15
32
  export interface ServiceState {
16
33
  /** Is a unit file installed for us? */
17
34
  registered: boolean;
@@ -20,6 +37,28 @@ export interface ServiceState {
20
37
  pid?: string;
21
38
  /** The Paddock arguments recorded in the installed unit, interpreter stripped. */
22
39
  argv: string[];
40
+ /**
41
+ * The interpreter and script the installed unit actually names.
42
+ *
43
+ * Reported separately from {@link argv} because `status` has to be able to
44
+ * say "the Node it runs is not there any more" (SPEC §7), which is only
45
+ * answerable from the paths in the FILE. Under nvm, fnm or volta, removing a
46
+ * Node version leaves a unit naming an interpreter that no longer exists —
47
+ * and the symptom is Paddock being absent with an empty log, because it never
48
+ * got as far as running. Undefined for a unit we could not parse.
49
+ */
50
+ nodePath?: string;
51
+ cliPath?: string;
52
+ /**
53
+ * The log file the installed unit names, if it names one.
54
+ *
55
+ * Read back rather than recomputed so `status` and `logs` report the file the
56
+ * service is really appending to. A unit written before the logs moved to
57
+ * `<dataDir>/logs/service.log` names the old pair under `<dataDir>/service/`,
58
+ * and telling its owner to read a file nothing writes to is worse than
59
+ * telling them nothing.
60
+ */
61
+ logPath?: string;
23
62
  /**
24
63
  * Does the unit ON DISK come back after a graceful stop (#872)?
25
64
  *
@@ -64,8 +103,13 @@ export interface ServiceBackend {
64
103
  start(): void;
65
104
  stop(): void;
66
105
  restart(): void;
67
- /** How to read the logs, as printed lines. */
68
- logsHint(spec: ServiceSpec): string;
69
- /** The `enable-linger` warning, or undefined where it does not apply. */
70
- lingerNote(): string | undefined;
106
+ /**
107
+ * Does this user linger? (SPEC §7.) Asked of the OS, not assumed.
108
+ *
109
+ * The note used to be printed unconditionally on Linux, which meant the
110
+ * people who had already run `enable-linger` were told to run it at every
111
+ * install and every status — so the one line that mattered to the people who
112
+ * had NOT was indistinguishable from boilerplate.
113
+ */
114
+ linger(): LingerState;
71
115
  }
@@ -1,7 +1,7 @@
1
1
  import { type CliOptions, type ServiceAction } from "../args.js";
2
2
  import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
3
  import { type Runner } from "./backend.js";
4
- import { type Readiness } from "./ready.js";
4
+ import { type HealthAnswer, type Readiness } from "./ready.js";
5
5
  export interface ServiceContext {
6
6
  /** `process.platform`. */
7
7
  platform: NodeJS.Platform;
@@ -26,8 +26,74 @@ export interface ServiceContext {
26
26
  * message — twenty seconds per case, and green for the wrong reason.
27
27
  */
28
28
  checkReady?: (url: string) => Promise<Readiness>;
29
+ /**
30
+ * "What does `/api/health` say?", injected for the same reason `checkReady`
31
+ * is — `status` has to be assertable without a server to ask.
32
+ */
33
+ probeHealth?: (url: string) => Promise<HealthAnswer | undefined>;
34
+ /**
35
+ * The busy check `stop` and `restart` run first (SPEC §1, §4): null to go
36
+ * ahead, or 75 having printed the refusal.
37
+ *
38
+ * Injected for the third time for the third version of the same reason: the
39
+ * real one reads `<home>/runtime.json` and fetches a URL, so a test of "does
40
+ * stop refuse" would otherwise need a server to be busy at it.
41
+ */
42
+ guardBusy?: (action: "stop" | "restart", home: string, opts: {
43
+ force?: boolean;
44
+ json?: boolean;
45
+ }) => Promise<number | null>;
46
+ }
47
+ /**
48
+ * The version of the Paddock the unit points at.
49
+ *
50
+ * Found by walking up from the `cli.js` in the unit to the nearest
51
+ * `package.json`, which is `packages/server/package.json` in both the repo and
52
+ * the published tarball — the same hop the CLI itself makes to answer
53
+ * `--version`. Undefined when the unit names a path that is no longer there,
54
+ * which is a state `status` reports rather than guesses around.
55
+ */
56
+ export declare function installedVersionOf(cliPath: string | undefined): string | undefined;
57
+ /**
58
+ * What `service status` knows, before it is either printed or serialised.
59
+ *
60
+ * One shape for both renderings, so `--json` cannot quietly disagree with the
61
+ * prose above it — which is the usual way a `--json` flag rots.
62
+ */
63
+ export interface StatusReport {
64
+ installed: boolean;
65
+ running: boolean;
66
+ /** Did `/api/health` answer? Only then is the service actually usable. */
67
+ answering: boolean;
68
+ /** The version the unit points at, and the version that answered. */
69
+ installedVersion: string | null;
70
+ runningVersion: string | null;
71
+ port: number | null;
72
+ host: string | null;
73
+ url: string | null;
74
+ pid: number | null;
75
+ /** The Paddock arguments the unit records, interpreter and script stripped. */
76
+ args: string[];
77
+ /** The plist / unit file — present whether or not it exists. */
78
+ unit: string;
79
+ manager: "launchd" | "systemd";
80
+ /** The one file both streams append to. */
81
+ log: string | null;
82
+ dataDir: string | null;
83
+ node: string | null;
84
+ cli: string | null;
85
+ /** Linux only: null where the platform has no such concept. */
86
+ linger: boolean | null;
87
+ /** A stale unit, a missing Node — things that are wrong but not fatal. */
88
+ warnings: string[];
29
89
  }
30
- export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<void>;
90
+ /**
91
+ * Dispatch, returning the exit code rather than setting one (SPEC §2).
92
+ *
93
+ * The code is returned up to `paddock.ts`, which assigns `process.exitCode` —
94
+ * never `process.exit()`, which truncates piped output mid-write.
95
+ */
96
+ export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<number>;
31
97
  /** Exported for the entrypoint's context assembly, and for tests. */
32
98
  export { serviceDir, isNpxPath };
33
99
  export type { ServiceSpec };
@@ -26,11 +26,14 @@ import fs from "node:fs";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
28
28
  import { CliError } from "../args.js";
29
- import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, } from "./spec.js";
29
+ import { EXIT } from "../exit-codes.js";
30
+ import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, serviceLogPath, } from "./spec.js";
30
31
  import { spawnRunner, } from "./backend.js";
31
32
  import { createLaunchdBackend } from "./launchd.js";
32
33
  import { createSystemdBackend } from "./systemd.js";
33
- import { waitForReady, READY_TIMEOUT_LABEL } from "./ready.js";
34
+ import { missingPaths } from "./path-env.js";
35
+ import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
36
+ import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
34
37
  /**
35
38
  * Pick a backend, or explain why there isn't one.
36
39
  *
@@ -87,15 +90,13 @@ const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots
87
90
  * signalled rather than crashed. Naming the fix here is the only path from that
88
91
  * symptom back to a working service.
89
92
  */
90
- function staleUnitNote(backend) {
93
+ function staleUnitWarning(backend) {
91
94
  const key = backend.platform === "darwin"
92
95
  ? "KeepAlive is conditional on a non-zero exit"
93
96
  : "Restart=on-failure";
94
- return (` ⚠ This unit predates a fix (#872): ${key}, so a graceful stop —\n` +
95
- " the kind sleep, logout or a stray `kill` sends — puts Paddock down\n" +
96
- " until you start it again by hand. Re-run to update the unit:\n" +
97
- "\n" +
98
- " paddock service install");
97
+ return (`This unit predates a fix (#872): ${key}, so a graceful stop — the kind ` +
98
+ "sleep, logout or a stray `kill` sends — puts Paddock down until you start " +
99
+ "it again by hand. Re-run `paddock service install` to update the unit.");
99
100
  }
100
101
  /**
101
102
  * Say what is about to happen, before the part that looks like a hang (#861).
@@ -121,6 +122,17 @@ function installProgress(backend) {
121
122
  " macOS may take a few seconds to approve the background item."
122
123
  : `\n Installing ${kind} and starting Paddock…`;
123
124
  }
125
+ /** The `enable-linger` line, printed only to the people it applies to (SPEC §7). */
126
+ function lingerNote(linger) {
127
+ if (!linger.applies || linger.enabled === true)
128
+ return undefined;
129
+ const head = linger.enabled === false
130
+ ? " Lingering is off, so this service stops when you log out — including"
131
+ : " If you log out (this check could not reach loginctl), a user service";
132
+ return (`${head}\n` +
133
+ " out of an SSH session. To keep Paddock up, once:\n" +
134
+ ` sudo loginctl enable-linger ${linger.user ?? "$USER"}`);
135
+ }
124
136
  async function install(opts, ctx, backend) {
125
137
  if (isNpxPath(ctx.packageRoot))
126
138
  throw new CliError(NPX_REFUSAL);
@@ -137,7 +149,10 @@ async function install(opts, ctx, backend) {
137
149
  console.log(` Waiting for ${url} to answer…`);
138
150
  const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
139
151
  const kind = backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service";
140
- const linger = backend.lingerNote();
152
+ // Asked of the OS now, rather than printed at everyone (SPEC §7): the people
153
+ // who have already enabled lingering were being told to enable it at every
154
+ // install, which is how the line stopped being read by the people it is for.
155
+ const linger = lingerNote(backend.linger());
141
156
  console.log([
142
157
  "",
143
158
  readiness === "ready"
@@ -149,7 +164,7 @@ async function install(opts, ctx, backend) {
149
164
  ...(readiness === "ready" ? [] : [` URL: ${url}`]),
150
165
  ` Data: ${dataDir}`,
151
166
  ` Unit: ${backend.unitPath}`,
152
- backend.logsHint(spec),
167
+ ` Logs: ${spec.logPath} (paddock service logs -f)`,
153
168
  "",
154
169
  AT_LOGIN,
155
170
  ...(linger !== undefined ? ["", linger] : []),
@@ -159,66 +174,228 @@ async function install(opts, ctx, backend) {
159
174
  " paddock service uninstall stop it and remove the unit",
160
175
  "",
161
176
  ].join("\n"));
177
+ // "It is installed but did not answer" is a failure, and used to exit 0 — so
178
+ // an Ansible task or a CI step could not tell it from a working install.
179
+ return readiness === "ready" ? EXIT.ok : EXIT.failure;
162
180
  }
163
181
  function uninstall(ctx, backend) {
164
182
  const existed = backend.uninstall();
165
183
  console.log(existed
166
184
  ? `\n Paddock's ${backend.platform === "darwin" ? "LaunchAgent" : "user service"} is stopped and removed.\n ${backend.unitPath}\n\n Your data is untouched — it was never in there.\n`
167
185
  : `\n No Paddock service was installed (${backend.unitPath}).\n Nothing to do.\n`);
168
- // Leave the service directory: it holds logs, and the last thing anyone wants
169
- // from `uninstall` is for the record of why they are uninstalling to vanish.
186
+ // Leave the service directory and the logs: the last thing anyone wants from
187
+ // `uninstall` is for the record of why they are uninstalling to vanish (and
188
+ // SPEC §1 requires it — `uninstall` keeps the data home and the logs).
170
189
  void ctx;
190
+ // 0 either way: "there was nothing to remove" is the state the caller asked
191
+ // for. `uninstall` is idempotent on purpose, so a teardown script can run it
192
+ // without first asking whether it has anything to do.
193
+ return EXIT.ok;
194
+ }
195
+ /**
196
+ * The data dir the INSTALLED unit uses, read out of its own argument vector.
197
+ *
198
+ * Today's `--data-dir` flag is deliberately ignored (#824): every reporting
199
+ * surface here describes the unit on disk, and letting a flag through printed
200
+ * the unit's data dir beside a log path under a directory the service never
201
+ * writes to. `--data-dir` is documented as install-only (SERVICE_USAGE).
202
+ */
203
+ function dataDirOf(state, ctx) {
204
+ const i = state.argv.indexOf("--data-dir");
205
+ return i >= 0 && state.argv[i + 1] !== undefined
206
+ ? state.argv[i + 1]
207
+ : defaultDataDir(ctx.homeDir);
208
+ }
209
+ /** The URL the installed unit serves, read back out of the unit. */
210
+ function urlOf(state) {
211
+ return `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
212
+ }
213
+ /**
214
+ * The log file to read: the one the unit names, else where we would write it.
215
+ *
216
+ * A unit written before the logs moved to `<dataDir>/logs/service.log` names
217
+ * the old `<dataDir>/service/paddock.log`, and that is the file its service is
218
+ * still appending to — so that is the file `logs` has to read. The fallback
219
+ * covers a unit we could not parse.
220
+ */
221
+ function logPathOf(state, dataDir) {
222
+ return state.logPath ?? serviceLogPath(dataDir);
223
+ }
224
+ /**
225
+ * The version of the Paddock the unit points at.
226
+ *
227
+ * Found by walking up from the `cli.js` in the unit to the nearest
228
+ * `package.json`, which is `packages/server/package.json` in both the repo and
229
+ * the published tarball — the same hop the CLI itself makes to answer
230
+ * `--version`. Undefined when the unit names a path that is no longer there,
231
+ * which is a state `status` reports rather than guesses around.
232
+ */
233
+ export function installedVersionOf(cliPath) {
234
+ if (cliPath === undefined)
235
+ return undefined;
236
+ let dir = path.dirname(cliPath);
237
+ for (;;) {
238
+ const manifest = path.join(dir, "package.json");
239
+ if (fs.existsSync(manifest)) {
240
+ try {
241
+ const raw = JSON.parse(fs.readFileSync(manifest, "utf8"));
242
+ return raw.version;
243
+ }
244
+ catch {
245
+ return undefined;
246
+ }
247
+ }
248
+ const parent = path.dirname(dir);
249
+ if (parent === dir)
250
+ return undefined;
251
+ dir = parent;
252
+ }
171
253
  }
172
- function status(opts, ctx, backend) {
254
+ async function buildStatus(ctx, backend) {
173
255
  const state = backend.status();
256
+ const base = {
257
+ installed: state.registered,
258
+ args: state.argv,
259
+ manager: backend.platform === "darwin" ? "launchd" : "systemd",
260
+ unit: backend.unitPath,
261
+ };
262
+ const linger = backend.linger();
263
+ const lingerValue = linger.applies ? (linger.enabled ?? null) : null;
174
264
  if (!state.registered) {
265
+ return {
266
+ ...base,
267
+ running: false,
268
+ answering: false,
269
+ installedVersion: null,
270
+ runningVersion: null,
271
+ port: null,
272
+ host: null,
273
+ url: null,
274
+ pid: null,
275
+ log: null,
276
+ dataDir: null,
277
+ node: null,
278
+ cli: null,
279
+ linger: lingerValue,
280
+ warnings: [],
281
+ };
282
+ }
283
+ const dataDir = dataDirOf(state, ctx);
284
+ const url = urlOf(state);
285
+ const health = await (ctx.probeHealth ?? ((u) => fetchHealth(u)))(url);
286
+ const installedVersion = installedVersionOf(state.cliPath);
287
+ const warnings = [];
288
+ if (!state.keepsAlive)
289
+ warnings.push(staleUnitWarning(backend));
290
+ for (const gone of missingPaths([state.nodePath, state.cliPath])) {
291
+ warnings.push(`The unit runs ${gone}, which is not there any more. ` +
292
+ "That happens when a Node version is removed (nvm, fnm, volta) or the " +
293
+ "package is reinstalled elsewhere. Re-run `paddock service install`.");
294
+ }
295
+ if (installedVersion !== undefined &&
296
+ health?.version !== undefined &&
297
+ installedVersion !== health.version) {
298
+ warnings.push(`It is running ${health.version}, but ${installedVersion} is installed. ` +
299
+ "The package was upgraded under a service that has not restarted: " +
300
+ "`paddock service restart`.");
301
+ }
302
+ if (linger.applies && linger.enabled === false) {
303
+ warnings.push("Lingering is off, so this service stops when you log out — including out " +
304
+ `of an SSH session. To keep it up: sudo loginctl enable-linger ${linger.user ?? "$USER"}`);
305
+ }
306
+ return {
307
+ ...base,
308
+ running: state.running,
309
+ answering: health?.ok === true,
310
+ installedVersion: installedVersion ?? null,
311
+ runningVersion: health?.version ?? null,
312
+ port: Number(portFromArgv(state.argv)),
313
+ host: hostFromArgv(state.argv),
314
+ url,
315
+ pid: state.pid !== undefined ? Number(state.pid) : null,
316
+ log: logPathOf(state, dataDir),
317
+ dataDir,
318
+ node: state.nodePath ?? null,
319
+ cli: state.cliPath ?? null,
320
+ linger: lingerValue,
321
+ warnings,
322
+ };
323
+ }
324
+ /**
325
+ * `status` (SPEC §1, §2).
326
+ *
327
+ * The exit code is the headline and the reason this command is worth scripting:
328
+ *
329
+ * - **4** — no unit. Nothing to start, nothing to ask.
330
+ * - **3** — installed, but not running or not answering. "Not answering"
331
+ * counts, because a supervisor reports a crash-looping job as `running` for
332
+ * most of the window between restarts, and a service nothing can reach is not
333
+ * up whatever the supervisor thinks.
334
+ * - **0** — installed, running and answering.
335
+ *
336
+ * Those are the numbers `systemctl status` and LSB init scripts already use, so
337
+ * an Ansible task or a shell `if` written against either keeps working.
338
+ */
339
+ async function status(opts, ctx, backend) {
340
+ const report = await buildStatus(ctx, backend);
341
+ if (opts.json === true) {
342
+ // Exactly one JSON document on stdout, and nothing else (SPEC §1). Every
343
+ // other line this command can print goes to stderr under --json or is not
344
+ // printed at all.
345
+ console.log(JSON.stringify(report, null, 2));
346
+ return statusExit(report);
347
+ }
348
+ if (!report.installed) {
175
349
  console.log([
176
350
  "",
177
351
  " Paddock is not installed as a service.",
178
- ` Looked for: ${backend.unitPath}`,
352
+ ` Looked for: ${report.unit}`,
179
353
  "",
180
354
  " paddock service install register it and start it now",
181
355
  "",
182
356
  ].join("\n"));
183
- return;
357
+ return EXIT.notInstalled;
184
358
  }
185
- // Everything below is read back out of the INSTALLED unit, not recomputed
186
- // from today's flags — the installed port is the one the URL has to name.
187
- const url = `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
188
- const dataFromUnit = state.argv.indexOf("--data-dir");
189
- const dataDir = dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined
190
- ? state.argv[dataFromUnit + 1]
191
- : defaultDataDir(ctx.homeDir);
192
- // Drop `--data-dir` before building the log-path spec (#824). `resolveDataDir`
193
- // gives `opts.dataDir` precedence over `ctx.envDataDir`, which is right for
194
- // `install` (see the comment there) and wrong here: this whole block reports
195
- // the INSTALLED unit, so letting today's flag through printed the unit's data
196
- // dir beside a `Logs:` path under a directory the service never writes to. On
197
- // macOS that log path is real and load-bearing, not just cosmetic.
198
- //
199
- // `--data-dir` is documented as install-only (SERVICE_USAGE), so ignoring it
200
- // here matches both the help text and the stated intent of this block.
201
- const statusOpts = { ...opts };
202
- delete statusOpts.dataDir;
203
- const { spec } = specFor(statusOpts, { ...ctx, envDataDir: dataDir });
204
- const linger = backend.lingerNote();
359
+ const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
360
+ ? ` Version: ${report.runningVersion} running, ${report.installedVersion ?? "unknown"} installed`
361
+ : ` Version: ${report.runningVersion ?? report.installedVersion ?? "unknown"}`;
205
362
  console.log([
206
363
  "",
207
- state.running
208
- ? ` Paddock is running${state.pid !== undefined ? ` (pid ${state.pid})` : ""}.`
209
- : " Paddock is registered as a service, but not running right now.",
364
+ report.running
365
+ ? report.answering
366
+ ? ` Paddock is running${report.pid !== null ? ` (pid ${String(report.pid)})` : ""} and answering.`
367
+ : ` Paddock is running${report.pid !== null ? ` (pid ${String(report.pid)})` : ""}, but ${report.url ?? "its URL"} is not answering.`
368
+ : report.answering
369
+ ? // Found in QA, and worth its own line: the supervisor does not have
370
+ // the job, yet something IS serving that URL — usually a `paddock
371
+ // serve` in a terminal on the same port. "Not running" on its own
372
+ // reads as "nothing there", and then `service start` fails on a
373
+ // port clash for reasons the status screen had the answer to.
374
+ ` The service is not running, but something IS answering at ${report.url ?? "its URL"}\n` +
375
+ " — most likely a `paddock serve` in a terminal. The service cannot take\n" +
376
+ " that port while it is held."
377
+ : " Paddock is registered as a service, but not running right now.",
210
378
  "",
211
- ` URL: ${url}`,
212
- ` Data: ${dataDir}`,
213
- ` Unit: ${backend.unitPath}`,
214
- ` Args: paddock ${state.argv.join(" ")}`,
215
- backend.logsHint(spec),
379
+ ` URL: ${report.url ?? "unknown"}`,
380
+ versions,
381
+ ` Data: ${report.dataDir ?? "unknown"}`,
382
+ ` Unit: ${report.unit}`,
383
+ ` Args: paddock ${report.args.join(" ")}`,
384
+ ` Logs: ${report.log ?? "unknown"} (paddock service logs -f)`,
216
385
  "",
217
386
  AT_LOGIN,
218
- ...(state.keepsAlive ? [] : ["", staleUnitNote(backend)]),
219
- ...(linger !== undefined && !state.running ? ["", linger] : []),
387
+ ...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
220
388
  "",
221
389
  ].join("\n"));
390
+ return statusExit(report);
391
+ }
392
+ /** The exit code a report means (SPEC §2). */
393
+ function statusExit(report) {
394
+ if (!report.installed)
395
+ return EXIT.notInstalled;
396
+ if (!report.running || !report.answering)
397
+ return EXIT.notRunning;
398
+ return EXIT.ok;
222
399
  }
223
400
  /**
224
401
  * The URL an installed unit is actually serving, or undefined if none is.
@@ -255,16 +432,24 @@ function notInstalled(backend) {
255
432
  * `ThrottleInterval`, and `launchctl print` says `running` for most of that
256
433
  * window). The wait is what makes the last line true.
257
434
  */
258
- async function startOrRestart(action, ctx, backend) {
435
+ async function startOrRestart(action, opts, ctx, backend) {
259
436
  const before = backend.status();
260
437
  if (!before.registered) {
261
438
  console.log(notInstalled(backend));
262
- return;
439
+ return EXIT.notInstalled;
263
440
  }
264
441
  const url = installedUrl(before);
265
442
  if (action === "start" && before.running) {
266
443
  console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
267
- return;
444
+ return EXIT.ok;
445
+ }
446
+ // A restart stops the instance first, so it asks the same question `stop`
447
+ // asks (SPEC §1): refuse with 75 unless `--force`. `start` does not — there
448
+ // is nothing running for it to cut short, and asking would be theatre.
449
+ if (action === "restart") {
450
+ const refused = await busyCheck(action, opts, ctx, before);
451
+ if (refused !== null)
452
+ return refused;
268
453
  }
269
454
  console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
270
455
  if (action === "start")
@@ -279,21 +464,54 @@ async function startOrRestart(action, ctx, backend) {
279
464
  ? ` ✓ Paddock is running${pid(after)} at ${url}`
280
465
  : ` Paddock was ${action === "start" ? "started" : "restarted"}, but ${url} did not\n` +
281
466
  ` answer within ${READY_TIMEOUT_LABEL}. It may still be coming up — check:`,
282
- ...(readiness === "ready" ? [] : ["", " paddock service status", backend.logsHint(specForLogs(ctx, after))]),
467
+ ...(readiness === "ready"
468
+ ? []
469
+ : [
470
+ "",
471
+ " paddock service status",
472
+ ` paddock service logs (${logPathOf(after, dataDirOf(after, ctx))})`,
473
+ ]),
283
474
  "",
284
475
  ].join("\n"));
476
+ // A start that never answered is a failure, not a success with a caveat: the
477
+ // caller — often an Ansible task — has no other way to find out.
478
+ return readiness === "ready" ? EXIT.ok : EXIT.failure;
479
+ }
480
+ /**
481
+ * The busy check, asked of the instance the UNIT describes (SPEC §1, §4).
482
+ *
483
+ * `home` is `dataDirOf(state, ctx)` — the unit's own data dir, not whatever
484
+ * `--data-dir` this invocation carries. Same rule as `status` and `logs`
485
+ * (#824): the instance about to be stopped is the one the unit points at, and
486
+ * reading `runtime.json` from somewhere else would ask a different Paddock
487
+ * whether this one is busy.
488
+ *
489
+ * Returns null to go ahead, or the code to return (75).
490
+ */
491
+ async function busyCheck(action, opts, ctx, state) {
492
+ const guard = ctx.guardBusy ?? (await import("../busy-command.js")).guardServiceBusy;
493
+ return guard(action, dataDirOf(state, ctx), {
494
+ ...(opts.force === true ? { force: true } : {}),
495
+ ...(opts.json === true ? { json: true } : {}),
496
+ });
285
497
  }
286
498
  /** `stop` (#873): put it down, leave it installed. */
287
- function stop(backend) {
499
+ async function stop(opts, ctx, backend) {
288
500
  const state = backend.status();
289
501
  if (!state.registered) {
290
502
  console.log(notInstalled(backend));
291
- return;
503
+ return EXIT.notInstalled;
292
504
  }
293
505
  if (!state.running) {
294
506
  console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
295
- return;
507
+ return EXIT.ok;
296
508
  }
509
+ // Refuse with 75 if it is in the middle of something, unless --force (SPEC
510
+ // §4). Deliberately AFTER the two cases above: an unit that is not there, or
511
+ // a service already stopped, has no instance to ask and no work to cut short.
512
+ const refused = await busyCheck("stop", opts, ctx, state);
513
+ if (refused !== null)
514
+ return refused;
297
515
  backend.stop();
298
516
  console.log([
299
517
  "",
@@ -305,27 +523,70 @@ function stop(backend) {
305
523
  " paddock service start",
306
524
  "",
307
525
  ].join("\n"));
526
+ return EXIT.ok;
308
527
  }
309
528
  function pid(state) {
310
529
  return state.pid !== undefined ? ` (pid ${state.pid})` : "";
311
530
  }
312
- /** The spec `logsHint` needs, built from the INSTALLED unit's data dir. */
313
- function specForLogs(ctx, state) {
314
- const i = state.argv.indexOf("--data-dir");
315
- const dataDir = i >= 0 && state.argv[i + 1] !== undefined ? state.argv[i + 1] : undefined;
316
- const { spec } = specFor({ open: false, verbose: false, help: false, version: false }, { ...ctx, ...(dataDir !== undefined ? { envDataDir: dataDir } : {}) });
317
- return spec;
531
+ /**
532
+ * `logs [-f] [-n <lines>]` (SPEC §1, §7).
533
+ *
534
+ * Reads the file the INSTALLED unit names, which is the only file its service
535
+ * is actually appending to — a unit written before the logs moved still names
536
+ * the old path, and sending its owner to a file nothing writes to would be
537
+ * worse than sending them nowhere.
538
+ *
539
+ * Exit 4 with no unit, like every other command that needs one. A unit whose
540
+ * log file does not exist yet is NOT an error: a service installed thirty
541
+ * seconds ago and still booting has nothing to show, and that is worth saying
542
+ * rather than failing over.
543
+ */
544
+ async function logs(opts, ctx, backend) {
545
+ const state = backend.status();
546
+ if (!state.registered) {
547
+ console.log(notInstalled(backend));
548
+ return EXIT.notInstalled;
549
+ }
550
+ const file = logPathOf(state, dataDirOf(state, ctx));
551
+ const lines = opts.lines ?? DEFAULT_LOG_LINES;
552
+ if (!fs.existsSync(file)) {
553
+ // stderr: with `-f` this is a caveat ahead of a stream, and prose on stdout
554
+ // would be mixed into the log output itself.
555
+ console.error(`paddock: nothing has been written to ${file} yet.`);
556
+ if (opts.follow !== true)
557
+ return EXIT.ok;
558
+ }
559
+ else {
560
+ const tail = tailFile(file, lines);
561
+ if (tail !== "")
562
+ console.log(tail);
563
+ }
564
+ if (opts.follow === true) {
565
+ console.error(`paddock: following ${file} — Ctrl-C to stop.`);
566
+ await followFile(file, { from: sizeOf(file) });
567
+ }
568
+ return EXIT.ok;
318
569
  }
570
+ /**
571
+ * Dispatch, returning the exit code rather than setting one (SPEC §2).
572
+ *
573
+ * The code is returned up to `paddock.ts`, which assigns `process.exitCode` —
574
+ * never `process.exit()`, which truncates piped output mid-write.
575
+ */
319
576
  export async function runService(action, opts, ctx) {
577
+ // `--force` is rejected on every action but `stop` and `restart` — in
578
+ // `parseCommand`, once, for every verb. See the note there.
320
579
  const backend = backendFor(ctx);
321
580
  if (action === "install")
322
581
  return install(opts, ctx, backend);
323
582
  if (action === "uninstall")
324
583
  return uninstall(ctx, backend);
325
584
  if (action === "start" || action === "restart")
326
- return startOrRestart(action, ctx, backend);
585
+ return startOrRestart(action, opts, ctx, backend);
327
586
  if (action === "stop")
328
- return stop(backend);
587
+ return stop(opts, ctx, backend);
588
+ if (action === "logs")
589
+ return logs(opts, ctx, backend);
329
590
  return status(opts, ctx, backend);
330
591
  }
331
592
  /** Exported for the entrypoint's context assembly, and for tests. */