@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.
- package/package.json +1 -1
- package/packages/server/dist/app.js +18 -14
- package/packages/server/dist/cli/args.d.ts +47 -7
- package/packages/server/dist/cli/args.js +112 -18
- package/packages/server/dist/cli/exit-codes.d.ts +49 -0
- package/packages/server/dist/cli/exit-codes.js +48 -0
- package/packages/server/dist/cli/paddock.js +84 -37
- package/packages/server/dist/cli/service/backend.d.ts +48 -4
- package/packages/server/dist/cli/service/index.d.ts +56 -2
- package/packages/server/dist/cli/service/index.js +283 -56
- package/packages/server/dist/cli/service/launchd.d.ts +8 -0
- package/packages/server/dist/cli/service/launchd.js +29 -7
- package/packages/server/dist/cli/service/logs.d.ts +39 -0
- package/packages/server/dist/cli/service/logs.js +115 -0
- package/packages/server/dist/cli/service/path-env.d.ts +27 -0
- package/packages/server/dist/cli/service/path-env.js +120 -0
- package/packages/server/dist/cli/service/ready.d.ts +22 -0
- package/packages/server/dist/cli/service/ready.js +30 -0
- package/packages/server/dist/cli/service/spec.d.ts +45 -6
- package/packages/server/dist/cli/service/spec.js +34 -7
- package/packages/server/dist/cli/service/systemd.d.ts +47 -4
- package/packages/server/dist/cli/service/systemd.js +127 -22
- package/packages/server/dist/management-mcp-server.d.ts +21 -3
- package/packages/server/dist/management-mcp-server.js +4 -13
- package/packages/server/dist/routes/meta.js +11 -3
- package/packages/server/dist/service-log.d.ts +29 -0
- package/packages/server/dist/service-log.js +104 -0
- package/packages/server/dist/start.js +11 -0
- package/packages/server/dist/version.d.ts +3 -0
- package/packages/server/dist/version.js +32 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-DHczLhwQ.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-BSGSzTEs.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-DtTOpTji.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-BRoBh1C1.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-DJf8IJuk.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-EioguUKW.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BiKnGfk6.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-D6kfaIal.js} +1 -1
- package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-DpcyuV6e.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-CHM29PhP.js} +1 -1
- package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-fiqD05Hu.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-BuuB2Nkf.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-DQMBuzfi.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-BKN0UxCH.js} +1 -1
- package/packages/web/dist/assets/channel-B7yh6Su_.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-DW37EupX.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-Dyv7zV_c.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-DzMR0dOk.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-BdZ3Sor8.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-B4i7_og0.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-jcTr85it.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-fgMsadqB.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-bJNHYS1W.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +2 -0
- package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-Bk4KFrmN.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-BIqCSlQy.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-6nyhtVQz.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-CjizfipR.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-DlgB5vWb.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-CNC9qanV.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-DSJ-s9lh.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-C9FoHBDg.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-Cj_AY97j.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-z6gku4Yt.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-Dl5c_FgU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-CIscJ-Dt.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-CvttOXM_.js} +1 -1
- package/packages/web/dist/assets/{index-C4tsPSvG.js → index-DN82do6Z.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-dzWYdDJj.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-8yX9k4bo.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-BmitzJDR.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BZmIkfkW.js} +1 -1
- package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BBLXE4o7.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-mSKff4yu.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-BWAUa4CP.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-VmVdiL4C.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-Dybfv6NU.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-BPnOvB7w.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-YXIEmKw7.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DintIRUe.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-eMsjahWp.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-as4zRCPn.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-CPzvUkM0.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY-q6moXj5g.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-BsEEb9Gm.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-DSgZlcZW.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
- package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
- 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 `
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 `
|
|
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 `
|
|
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 = ["
|
|
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:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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;
|