@edspencer/paddock 0.79.1 → 0.80.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 (96) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +18 -14
  3. package/packages/server/dist/cli/args.d.ts +47 -7
  4. package/packages/server/dist/cli/args.js +112 -18
  5. package/packages/server/dist/cli/exit-codes.d.ts +49 -0
  6. package/packages/server/dist/cli/exit-codes.js +48 -0
  7. package/packages/server/dist/cli/paddock.js +84 -37
  8. package/packages/server/dist/cli/service/backend.d.ts +48 -4
  9. package/packages/server/dist/cli/service/index.d.ts +56 -2
  10. package/packages/server/dist/cli/service/index.js +283 -56
  11. package/packages/server/dist/cli/service/launchd.d.ts +8 -0
  12. package/packages/server/dist/cli/service/launchd.js +29 -7
  13. package/packages/server/dist/cli/service/logs.d.ts +39 -0
  14. package/packages/server/dist/cli/service/logs.js +115 -0
  15. package/packages/server/dist/cli/service/path-env.d.ts +27 -0
  16. package/packages/server/dist/cli/service/path-env.js +120 -0
  17. package/packages/server/dist/cli/service/ready.d.ts +22 -0
  18. package/packages/server/dist/cli/service/ready.js +30 -0
  19. package/packages/server/dist/cli/service/spec.d.ts +45 -6
  20. package/packages/server/dist/cli/service/spec.js +34 -7
  21. package/packages/server/dist/cli/service/systemd.d.ts +47 -4
  22. package/packages/server/dist/cli/service/systemd.js +127 -22
  23. package/packages/server/dist/management-mcp-server.d.ts +21 -3
  24. package/packages/server/dist/management-mcp-server.js +4 -13
  25. package/packages/server/dist/routes/meta.js +11 -3
  26. package/packages/server/dist/service-log.d.ts +29 -0
  27. package/packages/server/dist/service-log.js +104 -0
  28. package/packages/server/dist/start.js +11 -0
  29. package/packages/server/dist/version.d.ts +3 -0
  30. package/packages/server/dist/version.js +32 -0
  31. package/packages/server/package.json +1 -1
  32. package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-DHczLhwQ.js} +1 -1
  33. package/packages/web/dist/assets/DiscoverPage-BSGSzTEs.js +2 -0
  34. package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-DtTOpTji.js} +1 -1
  35. package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-BRoBh1C1.js} +1 -1
  36. package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-DJf8IJuk.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-EioguUKW.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BiKnGfk6.js} +3 -3
  39. package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-D6kfaIal.js} +1 -1
  40. package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-DpcyuV6e.js} +1 -1
  41. package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-CHM29PhP.js} +1 -1
  42. package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-fiqD05Hu.js} +1 -1
  43. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-BuuB2Nkf.js} +1 -1
  44. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-DQMBuzfi.js} +1 -1
  45. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-BKN0UxCH.js} +1 -1
  46. package/packages/web/dist/assets/channel-B7yh6Su_.js +2 -0
  47. package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-DW37EupX.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-Dyv7zV_c.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-DzMR0dOk.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-BdZ3Sor8.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-B4i7_og0.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-jcTr85it.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-fgMsadqB.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-bJNHYS1W.js} +1 -1
  55. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +2 -0
  56. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +2 -0
  57. package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-Bk4KFrmN.js} +1 -1
  58. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-BIqCSlQy.js} +1 -1
  59. package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-6nyhtVQz.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-CjizfipR.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-DlgB5vWb.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-CNC9qanV.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-DSJ-s9lh.js} +1 -1
  64. package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-C9FoHBDg.js} +1 -1
  65. package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-Cj_AY97j.js} +1 -1
  66. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-z6gku4Yt.js} +1 -1
  67. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-Dl5c_FgU.js} +1 -1
  68. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-CIscJ-Dt.js} +1 -1
  69. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-CvttOXM_.js} +1 -1
  70. package/packages/web/dist/assets/{index-C4tsPSvG.js → index-DN82do6Z.js} +2 -2
  71. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-dzWYdDJj.js} +1 -1
  72. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-8yX9k4bo.js} +1 -1
  73. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-BmitzJDR.js} +1 -1
  74. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BZmIkfkW.js} +1 -1
  75. package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BBLXE4o7.js} +1 -1
  76. package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-mSKff4yu.js} +4 -4
  77. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-BWAUa4CP.js} +1 -1
  78. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-VmVdiL4C.js} +1 -1
  79. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-Dybfv6NU.js} +1 -1
  80. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-BPnOvB7w.js} +1 -1
  81. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-YXIEmKw7.js} +1 -1
  82. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DintIRUe.js} +1 -1
  83. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-eMsjahWp.js} +1 -1
  84. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +2 -0
  85. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-as4zRCPn.js} +1 -1
  86. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-CPzvUkM0.js} +1 -1
  87. package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY-q6moXj5g.js} +1 -1
  88. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-BsEEb9Gm.js} +1 -1
  89. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-DSgZlcZW.js} +1 -1
  90. package/packages/web/dist/index.html +1 -1
  91. package/packages/web/dist/sw.js +1 -1
  92. package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
  93. package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
  94. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
  96. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `paddock service logs [-f] [-n <lines>]` (SPEC §1, §7).
3
+ *
4
+ * One implementation for every platform, which is the whole reason the units
5
+ * write to a file rather than to the journal: `journalctl --user -u paddock` on
6
+ * Linux and a pair of files on macOS meant "where are the logs" had two
7
+ * answers, neither of which Windows could ever have.
8
+ *
9
+ * Reading the tail of a file is deliberately done by hand rather than by
10
+ * spawning `tail`: `tail -f` is not on every box this runs on (and is `Get-
11
+ * Content -Wait` on the platform the contract requires us not to rule out),
12
+ * and shelling out would make the output depend on which `tail` won a PATH
13
+ * lookup.
14
+ */
15
+ import fs from "node:fs";
16
+ /** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
17
+ export const DEFAULT_LOG_LINES = 50;
18
+ /** How much of the end of the file to read for a tail, in bytes. */
19
+ const TAIL_WINDOW = 256 * 1024;
20
+ /** The gap between size checks when following. */
21
+ const FOLLOW_INTERVAL_MS = 400;
22
+ /**
23
+ * The last `lines` lines of `file`.
24
+ *
25
+ * Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
26
+ * and can reach tens of megabytes between rotations, and reading all of it to
27
+ * print fifty lines would make `logs` slower than the thing it is reporting on.
28
+ * A window that happens to start mid-line drops that partial first line rather
29
+ * than printing half of one.
30
+ */
31
+ export function tailFile(file, lines) {
32
+ const fd = fs.openSync(file, "r");
33
+ try {
34
+ const size = fs.fstatSync(fd).size;
35
+ const start = Math.max(0, size - TAIL_WINDOW);
36
+ const length = size - start;
37
+ if (length === 0)
38
+ return "";
39
+ const buf = Buffer.alloc(length);
40
+ fs.readSync(fd, buf, 0, length, start);
41
+ let text = buf.toString("utf8");
42
+ // A window that starts mid-line: drop the fragment.
43
+ if (start > 0) {
44
+ const nl = text.indexOf("\n");
45
+ text = nl === -1 ? "" : text.slice(nl + 1);
46
+ }
47
+ const all = text.split("\n");
48
+ // A trailing newline produces a final empty element that is not a line.
49
+ if (all[all.length - 1] === "")
50
+ all.pop();
51
+ return all.slice(-lines).join("\n");
52
+ }
53
+ finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ }
57
+ /**
58
+ * Print what gets appended to `file`, from `from` onwards, until the process is
59
+ * interrupted.
60
+ *
61
+ * Polling rather than `fs.watch`: the writer here is the supervisor's own file
62
+ * descriptor, and `fs.watch` on an appended-to file is unreliable across
63
+ * platforms (no events at all on some network filesystems, and a stream of
64
+ * them on others). A 400 ms poll of the size is both portable and cheap.
65
+ *
66
+ * Rotation is handled by the one case that can actually happen: the server
67
+ * rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
68
+ * and the size goes DOWN. Seeing that, this resets to the new start rather than
69
+ * reading past the end forever, which is what a naive follower does — it goes
70
+ * quiet after the first rotation and never recovers.
71
+ */
72
+ export async function followFile(file, opts) {
73
+ const interval = opts.intervalMs ?? FOLLOW_INTERVAL_MS;
74
+ const write = opts.write ?? ((s) => process.stdout.write(s));
75
+ const keepGoing = opts.keepGoing ?? (() => true);
76
+ let offset = opts.from;
77
+ while (keepGoing()) {
78
+ await new Promise((r) => setTimeout(r, interval));
79
+ let size;
80
+ try {
81
+ size = fs.statSync(file).size;
82
+ }
83
+ catch {
84
+ // The file can vanish: an uninstall, or a rotation that renames. Wait for
85
+ // it to come back rather than giving up — `logs -f` is left running.
86
+ continue;
87
+ }
88
+ if (size < offset) {
89
+ // Truncated: the rotation above, or a hand-run `: > service.log`.
90
+ offset = 0;
91
+ }
92
+ if (size === offset)
93
+ continue;
94
+ const fd = fs.openSync(file, "r");
95
+ try {
96
+ const length = size - offset;
97
+ const buf = Buffer.alloc(length);
98
+ fs.readSync(fd, buf, 0, length, offset);
99
+ write(buf.toString("utf8"));
100
+ offset = size;
101
+ }
102
+ finally {
103
+ fs.closeSync(fd);
104
+ }
105
+ }
106
+ }
107
+ /** The file's size, or 0 if it is not there — where a follow should start. */
108
+ export function sizeOf(file) {
109
+ try {
110
+ return fs.statSync(file).size;
111
+ }
112
+ catch {
113
+ return 0;
114
+ }
115
+ }
@@ -0,0 +1,27 @@
1
+ /** PATH entries that belong to one shell session or one npm invocation. */
2
+ export declare const PER_SHELL_PATTERNS: readonly RegExp[];
3
+ /** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
4
+ export declare const SYSTEM_DIRS: readonly string[];
5
+ export interface ServicePathOptions {
6
+ /** Injected for tests: "is this an existing directory?". */
7
+ exists?: (p: string) => boolean;
8
+ /** Injected for tests: the system directories to append. */
9
+ systemDirs?: readonly string[];
10
+ }
11
+ /**
12
+ * The job's PATH: Node's bin directory, then the usable part of `inherited`,
13
+ * then the system directories. De-duplicated, order preserved.
14
+ */
15
+ export declare function servicePath(nodeBin: string, inherited: string | undefined, opts?: ServicePathOptions): string;
16
+ /** `which`: the first executable called `name` on `pathVar`, or undefined. */
17
+ export declare function which(name: string, pathVar: string, isExec?: (p: string) => boolean): string | undefined;
18
+ /**
19
+ * Of `paths`, those that are no longer on disk.
20
+ *
21
+ * This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
22
+ * runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
23
+ * version is uninstalled: the unit still names an absolute interpreter path,
24
+ * the supervisor still dutifully tries to exec it, and the failure arrives as
25
+ * Paddock being absent with nothing in its own logs — because it never ran.
26
+ */
27
+ export declare function missingPaths(paths: readonly (string | undefined)[], exists?: (p: string) => boolean): string[];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The PATH a generated unit gives the service (SPEC §7).
3
+ *
4
+ * From edspencer/cli-contract — Local Review's `server/service/spec.ts`
5
+ * (`PER_SHELL_PATTERNS`, `SYSTEM_DIRS`, `servicePath`, `which`) @ spec 0.1;
6
+ * local changes: Paddock's own doc comments, `node:path`'s platform-aware
7
+ * `delimiter`/`join` instead of hard-coded `:` and `path.posix`, and a
8
+ * `missingFromPath` helper the CLI uses for its unit warnings. Wave C4 moves
9
+ * this to `reference/service/` and re-syncs.
10
+ *
11
+ * ## Why a unit cannot just inherit the installing shell's PATH
12
+ *
13
+ * Paddock used to record `process.env.PATH` verbatim. That is the PATH of one
14
+ * terminal at one moment, and several of its entries are *per-session*:
15
+ *
16
+ * - **fnm** puts a symlink per terminal under `fnm_multishells/<pid>_<ts>/bin`.
17
+ * The directory is deleted when that shell exits — and fnm never cleans up
18
+ * the stale ones, so a unit installed from an fnm shell names a `node`
19
+ * directory that stops existing the moment the tab closes.
20
+ * - **npx** adds its own per-invocation cache; `npm run` adds `node-gyp-bin`
21
+ * and the package's `node_modules/.bin`. A long-lived background job has no
22
+ * business looking in any of them for `git`.
23
+ *
24
+ * So the job's PATH is rebuilt: Node's own bin directory first (so the `node`
25
+ * the unit runs can resolve its neighbours), then whatever of the installing
26
+ * shell's PATH is worth keeping, then the system directories. Entries that are
27
+ * relative, per-shell, or simply not there are dropped — a PATH naming
28
+ * directories that do not exist is how `git` goes missing at some later login,
29
+ * silently, with the symptom appearing in an agent's turn rather than at
30
+ * install time.
31
+ */
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ /** PATH entries that belong to one shell session or one npm invocation. */
35
+ export const PER_SHELL_PATTERNS = [
36
+ /[/\\]fnm_multishells[/\\]/, // fnm: a symlink per terminal, never cleaned up
37
+ /[/\\]_npx[/\\]/, // npx's per-invocation package cache
38
+ /[/\\]node-gyp-bin$/, // added by `npm run` for the script it is running
39
+ /[/\\]node_modules[/\\]\.bin$/, // likewise (and by npx): a package's tools, not the user's
40
+ ];
41
+ /** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
42
+ export const SYSTEM_DIRS = [
43
+ "/opt/homebrew/bin",
44
+ "/usr/local/bin",
45
+ "/usr/bin",
46
+ "/bin",
47
+ "/usr/sbin",
48
+ "/sbin",
49
+ ];
50
+ function isDir(p) {
51
+ try {
52
+ return fs.statSync(p).isDirectory();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function isExecutableFile(p) {
59
+ try {
60
+ if (!fs.statSync(p).isFile())
61
+ return false;
62
+ fs.accessSync(p, fs.constants.X_OK);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /** Kept on the job's PATH: non-empty, absolute, not per-shell, and an existing directory. */
70
+ function keep(dir, exists) {
71
+ if (dir === "" || !path.isAbsolute(dir))
72
+ return false;
73
+ if (PER_SHELL_PATTERNS.some((re) => re.test(dir) || re.test(`${dir}${path.sep}`)))
74
+ return false;
75
+ return exists(dir);
76
+ }
77
+ /**
78
+ * The job's PATH: Node's bin directory, then the usable part of `inherited`,
79
+ * then the system directories. De-duplicated, order preserved.
80
+ */
81
+ export function servicePath(nodeBin, inherited, opts = {}) {
82
+ const exists = opts.exists ?? isDir;
83
+ const system = opts.systemDirs ?? SYSTEM_DIRS;
84
+ const out = [];
85
+ const add = (d) => {
86
+ // Trailing separators would make `/usr/bin/` and `/usr/bin` two entries.
87
+ const clean = d.length > 1 ? d.replace(/[/\\]+$/, "") : d;
88
+ if (!out.includes(clean) && keep(clean, exists))
89
+ out.push(clean);
90
+ };
91
+ add(nodeBin);
92
+ for (const d of (inherited ?? "").split(path.delimiter))
93
+ add(d);
94
+ for (const d of system)
95
+ add(d);
96
+ return out.join(path.delimiter);
97
+ }
98
+ /** `which`: the first executable called `name` on `pathVar`, or undefined. */
99
+ export function which(name, pathVar, isExec = isExecutableFile) {
100
+ for (const dir of pathVar.split(path.delimiter)) {
101
+ if (dir === "")
102
+ continue;
103
+ const p = path.join(dir, name);
104
+ if (isExec(p))
105
+ return p;
106
+ }
107
+ return undefined;
108
+ }
109
+ /**
110
+ * Of `paths`, those that are no longer on disk.
111
+ *
112
+ * This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
113
+ * runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
114
+ * version is uninstalled: the unit still names an absolute interpreter path,
115
+ * the supervisor still dutifully tries to exec it, and the failure arrives as
116
+ * Paddock being absent with nothing in its own logs — because it never ran.
117
+ */
118
+ export function missingPaths(paths, exists = (p) => fs.existsSync(p)) {
119
+ return paths.filter((p) => p !== undefined && p !== "" && !exists(p));
120
+ }
@@ -23,6 +23,28 @@
23
23
  */
24
24
  /** Where the poll got to when it stopped. */
25
25
  export type Readiness = "ready" | "timeout";
26
+ /** What `/api/health` said, when it said anything (SPEC §8). */
27
+ export interface HealthAnswer {
28
+ ok: boolean;
29
+ /** The version the RUNNING server reports. Absent from a pre-contract Paddock. */
30
+ version?: string;
31
+ }
32
+ /**
33
+ * Ask `<url>/api/health` once, and say what it answered.
34
+ *
35
+ * Distinct from {@link waitForReady}, which only needs a boolean and polls for
36
+ * it. `status` needs the body: the version in it is the only way to tell a
37
+ * server that is running the installed Paddock from one that has been up since
38
+ * before an upgrade — the single most confusing state a service can be in, and
39
+ * invisible from the outside until something reports both numbers.
40
+ *
41
+ * `undefined` means nothing answered. That is a real answer (exit 3), not an
42
+ * error, so nothing is thrown.
43
+ */
44
+ export declare function fetchHealth(url: string, opts?: {
45
+ timeoutMs?: number;
46
+ fetchImpl?: typeof fetch;
47
+ }): Promise<HealthAnswer | undefined>;
26
48
  export interface ReadyOptions {
27
49
  /** Base URL of the server, e.g. `http://127.0.0.1:7233` — no trailing slash. */
28
50
  url: string;
@@ -21,6 +21,36 @@
21
21
  * same claim as "it failed". Callers print it as a caveat, not an error, and
22
22
  * point at the logs.
23
23
  */
24
+ /**
25
+ * Ask `<url>/api/health` once, and say what it answered.
26
+ *
27
+ * Distinct from {@link waitForReady}, which only needs a boolean and polls for
28
+ * it. `status` needs the body: the version in it is the only way to tell a
29
+ * server that is running the installed Paddock from one that has been up since
30
+ * before an upgrade — the single most confusing state a service can be in, and
31
+ * invisible from the outside until something reports both numbers.
32
+ *
33
+ * `undefined` means nothing answered. That is a real answer (exit 3), not an
34
+ * error, so nothing is thrown.
35
+ */
36
+ export async function fetchHealth(url, opts = {}) {
37
+ const fetchImpl = opts.fetchImpl ?? fetch;
38
+ try {
39
+ const res = await fetchImpl(`${url}/api/health`, {
40
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 2_000),
41
+ });
42
+ if (!res.ok)
43
+ return undefined;
44
+ const body = (await res.json());
45
+ return {
46
+ ok: body.ok === true,
47
+ ...(typeof body.version === "string" ? { version: body.version } : {}),
48
+ };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
24
54
  /**
25
55
  * How long to wait before saying "could not confirm".
26
56
  *
@@ -22,14 +22,23 @@ export interface ServiceSpec {
22
22
  * interpreter explicitly removes the lookup entirely.
23
23
  */
24
24
  scriptPath: string;
25
- /** Arguments after the script: always starts with the `start` verb. */
25
+ /** Arguments after the script: always starts with the `serve` verb. */
26
26
  args: string[];
27
27
  /** A directory the service can sit in that it certainly owns. */
28
28
  workingDirectory: string;
29
- /** Where stdout/stderr go on launchd. systemd uses journald and ignores these. */
30
- stdoutPath: string;
31
- stderrPath: string;
32
- /** PATH for the service, captured at install time. */
29
+ /**
30
+ * The one file both streams append to, on every OS (SPEC §7).
31
+ *
32
+ * launchd names it twice (`StandardOutPath` and `StandardErrorPath`); systemd
33
+ * names it twice as well (`StandardOutput=append:` / `StandardError=append:`)
34
+ * rather than using the journal. That is what makes `paddock service logs`
35
+ * one Node implementation instead of two, and it is also what makes the
36
+ * Linux logs survive a journal that is volatile or rate-limited — the default
37
+ * on plenty of distributions, where `journalctl --user` after a reboot showed
38
+ * nothing at all.
39
+ */
40
+ logPath: string;
41
+ /** PATH for the service: Node's bin, the usable inherited entries, the system dirs. */
33
42
  pathEnv: string;
34
43
  }
35
44
  export interface SpecInput {
@@ -44,6 +53,19 @@ export interface SpecInput {
44
53
  dataDirArg?: string;
45
54
  verbose?: boolean;
46
55
  pathEnv?: string;
56
+ /**
57
+ * Injected by tests: "is this an existing directory?", and which system
58
+ * directories to append.
59
+ *
60
+ * Not a knob — a necessity. `servicePath` keeps only directories that EXIST,
61
+ * which is what stops a unit naming a `node` that went away with a terminal
62
+ * (path-env.ts). That makes the rendered unit depend on the filesystem it was
63
+ * rendered on, so a golden plist asserted on this box would differ from the
64
+ * same golden on a macOS runner, for reasons that have nothing to do with the
65
+ * code under test. Pinning both makes the goldens about the renderer again.
66
+ */
67
+ pathExists?: (p: string) => boolean;
68
+ systemDirs?: readonly string[];
47
69
  }
48
70
  /**
49
71
  * The service's own directory inside the data dir.
@@ -58,14 +80,31 @@ export interface SpecInput {
58
80
  * subdirectory is neither.
59
81
  */
60
82
  export declare function serviceDir(dataDir: string): string;
83
+ /**
84
+ * Where the service's log lives: `<dataDir>/logs/service.log` (SPEC §7).
85
+ *
86
+ * One file, both streams, every platform — so `paddock service logs` is one
87
+ * implementation and the answer to "where are the logs" is one path. It is NOT
88
+ * under {@link serviceDir}: the logs outlive any one install (`uninstall` keeps
89
+ * them deliberately), and the server rotates this file itself
90
+ * (`service-log.ts`), which means something other than the service CLI has to
91
+ * be able to name it from the data dir alone.
92
+ */
93
+ export declare function logsDir(dataDir: string): string;
94
+ /** The single log file both streams append to. */
95
+ export declare function serviceLogPath(dataDir: string): string;
61
96
  /** The data dir the service will use, resolved the same way `paddock.ts` does. */
62
97
  export declare function defaultDataDir(homeDir?: string): string;
63
98
  /**
64
99
  * Assemble the spec.
65
100
  *
66
- * The argument vector leads with the `start` verb even though bare `paddock`
101
+ * The argument vector leads with the `serve` verb even though bare `paddock`
67
102
  * would do the same thing. It costs nothing and it is the difference between
68
103
  * `launchctl print` showing an argv you can read and one you have to recognise.
104
+ * It was `start` until the CLI contract made `serve` the name for running in
105
+ * the foreground (SPEC §1); `start` is a permanent alias, so a unit written by
106
+ * an older Paddock keeps working untouched and only gets `serve` when something
107
+ * rewrites it.
69
108
  *
70
109
  * `--data-dir` is forwarded ONLY when the user passed it. The default of
71
110
  * setting nothing is load-bearing: with no data dir named anywhere, the service
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
+ import { servicePath } from "./path-env.js";
13
14
  /**
14
15
  * Reverse-DNS on macOS, plain on Linux. Both are stable identifiers a user may
15
16
  * end up typing (`launchctl print gui/501/net.edspencer.paddock`,
@@ -37,6 +38,23 @@ export const DEFAULT_PORT = "7233";
37
38
  export function serviceDir(dataDir) {
38
39
  return path.join(dataDir, "service");
39
40
  }
41
+ /**
42
+ * Where the service's log lives: `<dataDir>/logs/service.log` (SPEC §7).
43
+ *
44
+ * One file, both streams, every platform — so `paddock service logs` is one
45
+ * implementation and the answer to "where are the logs" is one path. It is NOT
46
+ * under {@link serviceDir}: the logs outlive any one install (`uninstall` keeps
47
+ * them deliberately), and the server rotates this file itself
48
+ * (`service-log.ts`), which means something other than the service CLI has to
49
+ * be able to name it from the data dir alone.
50
+ */
51
+ export function logsDir(dataDir) {
52
+ return path.join(dataDir, "logs");
53
+ }
54
+ /** The single log file both streams append to. */
55
+ export function serviceLogPath(dataDir) {
56
+ return path.join(logsDir(dataDir), "service.log");
57
+ }
40
58
  /** The data dir the service will use, resolved the same way `paddock.ts` does. */
41
59
  export function defaultDataDir(homeDir = os.homedir()) {
42
60
  return path.join(homeDir, ".paddock");
@@ -44,9 +62,13 @@ export function defaultDataDir(homeDir = os.homedir()) {
44
62
  /**
45
63
  * Assemble the spec.
46
64
  *
47
- * The argument vector leads with the `start` verb even though bare `paddock`
65
+ * The argument vector leads with the `serve` verb even though bare `paddock`
48
66
  * would do the same thing. It costs nothing and it is the difference between
49
67
  * `launchctl print` showing an argv you can read and one you have to recognise.
68
+ * It was `start` until the CLI contract made `serve` the name for running in
69
+ * the foreground (SPEC §1); `start` is a permanent alias, so a unit written by
70
+ * an older Paddock keeps working untouched and only gets `serve` when something
71
+ * rewrites it.
50
72
  *
51
73
  * `--data-dir` is forwarded ONLY when the user passed it. The default of
52
74
  * setting nothing is load-bearing: with no data dir named anywhere, the service
@@ -58,7 +80,7 @@ export function defaultDataDir(homeDir = os.homedir()) {
58
80
  * service opening a browser tab at login is not a feature.
59
81
  */
60
82
  export function buildSpec(input) {
61
- const args = ["start"];
83
+ const args = ["serve"];
62
84
  if (input.port !== undefined)
63
85
  args.push("--port", input.port);
64
86
  if (input.host !== undefined)
@@ -67,15 +89,20 @@ export function buildSpec(input) {
67
89
  args.push("--data-dir", input.dataDirArg);
68
90
  if (input.verbose === true)
69
91
  args.push("--verbose");
70
- const dir = serviceDir(input.dataDir);
71
92
  return {
72
93
  nodePath: input.nodePath,
73
94
  scriptPath: input.scriptPath,
74
95
  args,
75
- workingDirectory: dir,
76
- stdoutPath: path.join(dir, "paddock.log"),
77
- stderrPath: path.join(dir, "paddock.error.log"),
78
- pathEnv: input.pathEnv ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
96
+ workingDirectory: serviceDir(input.dataDir),
97
+ logPath: serviceLogPath(input.dataDir),
98
+ // Node's own bin directory leads, so the interpreter the unit names can
99
+ // find its neighbours; `servicePath` drops the installing shell's
100
+ // per-session entries. See path-env.ts for why inheriting PATH verbatim
101
+ // writes a unit that rots when a terminal closes.
102
+ pathEnv: servicePath(path.dirname(input.nodePath), input.pathEnv, {
103
+ ...(input.pathExists !== undefined ? { exists: input.pathExists } : {}),
104
+ ...(input.systemDirs !== undefined ? { systemDirs: input.systemDirs } : {}),
105
+ }),
79
106
  };
80
107
  }
81
108
  /**
@@ -1,5 +1,5 @@
1
1
  import { type ServiceSpec } from "./spec.js";
2
- import type { Runner, ServiceBackend } from "./backend.js";
2
+ import type { RunResult, Runner, ServiceBackend } from "./backend.js";
3
3
  /**
4
4
  * `~/.config/systemd/user/paddock.service`, honouring `XDG_CONFIG_HOME`.
5
5
  *
@@ -28,6 +28,17 @@ import type { Runner, ServiceBackend } from "./backend.js";
28
28
  * caller with no opinion still gets the right answer.)
29
29
  */
30
30
  export declare function unitPath(homeDir?: string, xdgConfigHome?: string): string;
31
+ /** One `ExecStart` word: specifiers and variables escaped, quoted when it has to be. */
32
+ export declare function execWord(a: string): string;
33
+ /**
34
+ * The other way: the words of an `ExecStart` line.
35
+ *
36
+ * Reads both shapes on purpose. A unit written before #796's quoting is a bare
37
+ * whitespace-separated line, and its owner is exactly the person whose `status`
38
+ * has to keep working — so unquoted words pass through unchanged, and quoted
39
+ * ones are unescaped.
40
+ */
41
+ export declare function execWords(line: string): string[];
31
42
  /**
32
43
  * Render the unit.
33
44
  *
@@ -47,11 +58,32 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
47
58
  * "start at login" for a user unit; `multi-user.target` is the system-unit
48
59
  * spelling and does nothing here.
49
60
  *
50
- * No `StandardOutput=` — a user unit's output goes to the journal, which is
51
- * where a Linux user will look, and duplicating it into a file would give two
52
- * answers to "where are the logs".
61
+ * ## Logs go to a file, not the journal (SPEC §7)
62
+ *
63
+ * `StandardOutput=append:` / `StandardError=append:` name one file, the same
64
+ * one launchd's two keys name. The journal was the obvious choice and the wrong
65
+ * one for three reasons: `journalctl --user` shows nothing at all on a distro
66
+ * whose journal is volatile (the output is gone after a reboot, which is
67
+ * exactly when someone looks); it makes "where are the logs" have a different
68
+ * answer on each platform, so `paddock service logs` would be two
69
+ * implementations; and it rules out Windows, which the contract requires
70
+ * nothing rule out.
71
+ *
72
+ * The directory has to exist first — systemd creates the file, not its parent —
73
+ * which `install` below does.
74
+ *
75
+ * ## Every ExecStart word is quoted
76
+ *
77
+ * `%` starts a specifier anywhere in a unit file and `$` starts a variable in
78
+ * `ExecStart`, so both are escaped; a word containing whitespace, a quote or a
79
+ * backslash is quoted. Paths with a space in them are the common case
80
+ * (`/Users/Ed Spencer/.paddock`), and the old unqualified `join(" ")` wrote a
81
+ * unit systemd then split into the wrong arguments — a service that fails at
82
+ * login having printed nothing.
53
83
  */
54
84
  export declare function renderUnit(spec: ServiceSpec): string;
85
+ /** The log file an installed unit appends to, or undefined if it names none. */
86
+ export declare function parseUnitLogPath(text: string): string | undefined;
55
87
  /**
56
88
  * Does an installed unit carry `Restart=always` (#872)?
57
89
  *
@@ -64,4 +96,15 @@ export declare function renderUnit(spec: ServiceSpec): string;
64
96
  export declare function unitKeepsAlive(text: string): boolean;
65
97
  /** Read `ExecStart=` back out of an installed unit, split into an argv. */
66
98
  export declare function parseUnitArgv(text: string): string[];
99
+ /**
100
+ * What a `systemctl` failure means, in the manager's own words where it has any.
101
+ *
102
+ * The `status === null` branch is the one QA found: `spawnSync` reports a
103
+ * missing binary that way, with no output at all, so the message was
104
+ * `systemctl --user enable --now failed (exit null).` and then a blank line —
105
+ * which tells someone on a container with no systemd nothing whatsoever. That
106
+ * is also the single likeliest way this command fails on a machine it was never
107
+ * going to work on.
108
+ */
109
+ export declare function systemctlFailure(args: string[], r: RunResult): string;
67
110
  export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;