@edspencer/paddock 0.66.2 → 0.68.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/README.md +24 -30
- package/package.json +1 -1
- package/packages/server/dist/adoptable.d.ts +57 -0
- package/packages/server/dist/adoptable.js +26 -3
- package/packages/server/dist/agent-errors.js +1 -1
- package/packages/server/dist/claude-home.d.ts +4 -4
- package/packages/server/dist/claude-home.js +4 -4
- package/packages/server/dist/claude-mcp.d.ts +4 -2
- package/packages/server/dist/cli/args.d.ts +41 -2
- package/packages/server/dist/cli/args.js +119 -17
- package/packages/server/dist/cli/paddock.js +87 -96
- package/packages/server/dist/cli/service/backend.d.ts +38 -0
- package/packages/server/dist/cli/service/backend.js +19 -0
- package/packages/server/dist/cli/service/index.d.ts +28 -0
- package/packages/server/dist/cli/service/index.js +179 -0
- package/packages/server/dist/cli/service/launchd.d.ts +47 -0
- package/packages/server/dist/cli/service/launchd.js +206 -0
- package/packages/server/dist/cli/service/spec.d.ts +106 -0
- package/packages/server/dist/cli/service/spec.js +124 -0
- package/packages/server/dist/cli/service/systemd.d.ts +48 -0
- package/packages/server/dist/cli/service/systemd.js +155 -0
- package/packages/server/dist/discover.d.ts +170 -0
- package/packages/server/dist/discover.js +365 -0
- package/packages/server/dist/herdctl.d.ts +25 -2
- package/packages/server/dist/herdctl.js +30 -1
- package/packages/server/dist/routes/chats.js +14 -2
- package/packages/server/dist/routes/discover.d.ts +3 -0
- package/packages/server/dist/routes/discover.js +140 -0
- package/packages/server/dist/routes.d.ts +1 -0
- package/packages/server/dist/routes.js +2 -0
- package/packages/server/dist/session-hub.d.ts +10 -0
- package/packages/server/dist/session-hub.js +16 -2
- package/packages/server/dist/ws-protocol.d.ts +15 -0
- package/packages/server/dist/ws.js +1 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/Chip-DIdRqcqU.js +2 -0
- package/packages/web/dist/assets/DiscoverPage-BmCWnkjN.js +2 -0
- package/packages/web/dist/assets/DiscoverView-DwhtBdN2.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-D01x1BUg.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DDVO0zhl.js → MarkdownRenderer-BCheNET2.js} +2 -2
- package/packages/web/dist/assets/ProjectMenu-BqlUQuxM.js +2 -0
- package/packages/web/dist/assets/ProjectRedirect-D8QDxYLG.js +2 -0
- package/packages/web/dist/assets/ProjectView-cevSYdcl.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-yd18DXdV.js +2 -0
- package/packages/web/dist/assets/RootHome-DZEkOjko.js +2 -0
- package/packages/web/dist/assets/Surfaces-DvRztndg.js +2 -0
- package/packages/web/dist/assets/{arc-DXqVnx_r.js → arc-DFqBni1E.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-FgkfD1Bk.js → architectureDiagram-3BPJPVTR-DXFeq3pL.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-WwkIIJsI.js → blockDiagram-GPEHLZMM-DpeYsIwP.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BIzkTibx.js → c4Diagram-AAUBKEIU-Bw5r8wjW.js} +1 -1
- package/packages/web/dist/assets/channel-DU94S7yB.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-C4TBH3R6.js → chunk-2J33WTMH-B0SAvGOr.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DTKJZlRM.js → chunk-4BX2VUAB-BaUGs1hD.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BC9_Qc8h.js → chunk-55IACEB6-BDHRluF5.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-D3gQ4rP3.js → chunk-727SXJPM-BX93DBZO.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-My2AXBvz.js → chunk-AQP2D5EJ-3RiQGxW2.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-D09zE6Ow.js → chunk-FMBD7UC4-DMkjv9SP.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CUSgeCLl.js → chunk-ND2GUHAM-CHiQfq4F.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-B6jVPYgy.js → chunk-QZHKN3VN-B1wCSHbM.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BcRv1OPp.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BcRv1OPp.js +2 -0
- package/packages/web/dist/assets/{core-DPY0seI-.js → core-C-49RP59.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BasCiARv.js → cose-bilkent-S5V4N54A-BIwwgSTZ.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-Cimn11Qe.js → dagre-BM42HDAG-BN45kshV.js} +1 -1
- package/packages/web/dist/assets/diagram-2AECGRRQ-C5NekbfR.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-De7wdssd.js +11 -0
- package/packages/web/dist/assets/{diagram-KO2AKTUF-dLORrSiu.js → diagram-KO2AKTUF-CgG0UA0n.js} +3 -3
- package/packages/web/dist/assets/{diagram-LMA3HP47-iUMeaR-J.js → diagram-LMA3HP47-DC-bDqxV.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-oEH6olHK.js → diagram-OG6HWLK6-XXfHDMhE.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-VtQqF9SY.js → erDiagram-TEJ5UH35-CHEd8JiS.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-wvF2EkJa.js → flowDiagram-I6XJVG4X-BbuYxd15.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-ng4qJ0E1.js → ganttDiagram-6RSMTGT7-BkF77xn5.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BV4aWy8k.js → gitGraphDiagram-PVQCEYII-BdL5zVez.js} +2 -2
- package/packages/web/dist/assets/index-C3MdCCzF.js +71 -0
- package/packages/web/dist/assets/index-DYccdR9x.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BpGTxAjU.js → infoDiagram-5YYISTIA-vhTRATYg.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-Cj36LyoS.js → ishikawaDiagram-YF4QCWOH-CxjN-7YF.js} +2 -2
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-0vNlg3um.js → journeyDiagram-JHISSGLW-DJ414TaX.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-wijkEDaR.js → kanban-definition-UN3LZRKU-gaQeFRvZ.js} +1 -1
- package/packages/web/dist/assets/{linear-LqRymMD_.js → linear-CcvdFgxd.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DPrhSs95.js → mermaid.core-Es6hedDB.js} +5 -5
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-en-uGqgC.js → mindmap-definition-RKZ34NQL-Dk7qf3FR.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DKBZ2Vnx.js → pieDiagram-4H26LBE5-CSWXwU6d.js} +3 -3
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB--g-WtxyL.js → quadrantDiagram-W4KKPZXB-CBVdURbr.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-Dl4eYxDI.js → requirementDiagram-4Y6WPE33-CbU18IZA.js} +3 -3
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-7lt4gqSw.js → sankeyDiagram-5OEKKPKP-BeAlNgjE.js} +4 -4
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-COgq-dXg.js → sequenceDiagram-3UESZ5HK-CKVKzxTs.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CL6XC79S.js → stateDiagram-AJRCARHV-B-aSaO4W.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-TptFh32C.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C5egKeGT.js → timeline-definition-PNZ67QCA-CP9JHEBr.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C_UhtC8u.js → vennDiagram-CIIHVFJN-Dbtwwvvd.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Bj9chUqz.js → wardley-L42UT6IY-NonvEhoT.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-D7VnZoRp.js → wardleyDiagram-YWT4CUSO-CDnpv9Ws.js} +2 -2
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-VrWLDoi4.js → xychartDiagram-2RQKCTM6-C1i-Wk0H.js} +1 -1
- package/packages/web/dist/fonts/alegreya-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-latin.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-500.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-400.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-400.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-700.woff2 +0 -0
- package/packages/web/dist/fonts/martian-mono-latin.woff2 +0 -0
- package/packages/web/dist/fonts/saira-latin.woff2 +0 -0
- package/packages/web/dist/index.html +61 -12
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/here.d.ts +0 -25
- package/packages/server/dist/cli/here.js +0 -104
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +0 -2
- package/packages/web/dist/assets/ProjectRedirect-WczXTV0_.js +0 -2
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +0 -13
- package/packages/web/dist/assets/ProjectsGrid-BeJj0q6Y.js +0 -2
- package/packages/web/dist/assets/channel-DUsOl5_p.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +0 -2
- package/packages/web/dist/assets/diagram-2AECGRRQ-fe9cDHyn.js +0 -44
- package/packages/web/dist/assets/diagram-5GNKFQAL-T0tQlfbl.js +0 -11
- package/packages/web/dist/assets/format-CvRqOKZU.js +0 -2
- package/packages/web/dist/assets/index-DGNSeqL-.js +0 -71
- package/packages/web/dist/assets/index-DMRbU1Jl.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +0 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The darwin backend: a launchd **LaunchAgent** (#796).
|
|
3
|
+
*
|
|
4
|
+
* ## Why an agent and not a daemon
|
|
5
|
+
*
|
|
6
|
+
* A LaunchAgent is bootstrapped into the `gui/<uid>` domain when that user logs
|
|
7
|
+
* in, and runs as them. A LaunchDaemon starts at boot, as root, before anyone
|
|
8
|
+
* has logged in. The difference is not convenience — it decides whether Paddock
|
|
9
|
+
* can use your Claude login at all.
|
|
10
|
+
*
|
|
11
|
+
* On macOS that login is a Keychain item, and the login keychain is unlocked by
|
|
12
|
+
* your account password *at login*. A daemon has no such session, so the item
|
|
13
|
+
* is locked and unreadable; `UserName` in the plist does not help, because it
|
|
14
|
+
* changes the effective uid and not the keychain's unlock state. `claude.
|
|
15
|
+
* credentials: host` on darwin therefore structurally requires a logged-in user
|
|
16
|
+
* session, and boot-time start and Keychain credentials are mutually exclusive.
|
|
17
|
+
*
|
|
18
|
+
* The consequence users will meet is that Paddock starts **at login, not at
|
|
19
|
+
* boot** — after a restart nobody logs into, it is not running. Every surface
|
|
20
|
+
* that mentions this service says so, because otherwise it gets filed as a bug.
|
|
21
|
+
*
|
|
22
|
+
* A boot-time instance is a genuinely different feature: a LaunchDaemon plus
|
|
23
|
+
* `claude.credentials: own` and a token sitting in `EnvironmentVariables` —
|
|
24
|
+
* which is exactly the long-lived-credential-in-a-plist problem the agent shape
|
|
25
|
+
* avoids. Stated in the docs as the trade, not offered as a flag.
|
|
26
|
+
*
|
|
27
|
+
* ## What has actually been verified
|
|
28
|
+
*
|
|
29
|
+
* A turn completed under `launchctl kickstart` on a Mac whose Claude login
|
|
30
|
+
* existed only as the Keychain item, with `ps eww` confirming no credential in
|
|
31
|
+
* the process environment, and with no permission dialog. Start **at login**
|
|
32
|
+
* (`RunAtLoad` after a real logout/login) has NOT been tested; if a login-time
|
|
33
|
+
* agent can race keychain unlock, the fix belongs in this file's plist rather
|
|
34
|
+
* than in the design. Nothing here should be read as covering that case.
|
|
35
|
+
*/
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { LAUNCHD_LABEL } from "./spec.js";
|
|
40
|
+
/** `~/Library/LaunchAgents/net.edspencer.paddock.plist`. */
|
|
41
|
+
export function plistPath(homeDir = os.homedir()) {
|
|
42
|
+
return path.join(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
43
|
+
}
|
|
44
|
+
function xmlEscape(s) {
|
|
45
|
+
return s
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">")
|
|
49
|
+
.replace(/"/g, """);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Render the plist.
|
|
53
|
+
*
|
|
54
|
+
* `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
|
|
55
|
+
* false` means "restart it if it exits nonzero, leave it alone if it exits
|
|
56
|
+
* cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
|
|
57
|
+
* make stopping Paddock a wrestling match.
|
|
58
|
+
*
|
|
59
|
+
* `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
|
|
60
|
+
* launchd hands a process a stub PATH that a version manager's `node`, and the
|
|
61
|
+
* `git` an agent will want, are both missing from. It carries nothing else
|
|
62
|
+
* because a plist is a world-readable file in the user's home, and the one
|
|
63
|
+
* category of value that would be tempting to put here — a credential — is
|
|
64
|
+
* precisely what the agent shape exists to avoid needing.
|
|
65
|
+
*
|
|
66
|
+
* No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
|
|
67
|
+
* from the service and from a terminal.
|
|
68
|
+
*/
|
|
69
|
+
export function renderPlist(spec) {
|
|
70
|
+
const argv = [spec.nodePath, spec.scriptPath, ...spec.args];
|
|
71
|
+
const strings = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
72
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
73
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
74
|
+
<plist version="1.0">
|
|
75
|
+
<dict>
|
|
76
|
+
<key>Label</key>
|
|
77
|
+
<string>${xmlEscape(LAUNCHD_LABEL)}</string>
|
|
78
|
+
<key>ProgramArguments</key>
|
|
79
|
+
<array>
|
|
80
|
+
${strings}
|
|
81
|
+
</array>
|
|
82
|
+
<key>RunAtLoad</key>
|
|
83
|
+
<true/>
|
|
84
|
+
<key>KeepAlive</key>
|
|
85
|
+
<dict>
|
|
86
|
+
<key>SuccessfulExit</key>
|
|
87
|
+
<false/>
|
|
88
|
+
</dict>
|
|
89
|
+
<key>ThrottleInterval</key>
|
|
90
|
+
<integer>10</integer>
|
|
91
|
+
<key>WorkingDirectory</key>
|
|
92
|
+
<string>${xmlEscape(spec.workingDirectory)}</string>
|
|
93
|
+
<key>StandardOutPath</key>
|
|
94
|
+
<string>${xmlEscape(spec.stdoutPath)}</string>
|
|
95
|
+
<key>StandardErrorPath</key>
|
|
96
|
+
<string>${xmlEscape(spec.stderrPath)}</string>
|
|
97
|
+
<key>EnvironmentVariables</key>
|
|
98
|
+
<dict>
|
|
99
|
+
<key>PATH</key>
|
|
100
|
+
<string>${xmlEscape(spec.pathEnv)}</string>
|
|
101
|
+
</dict>
|
|
102
|
+
</dict>
|
|
103
|
+
</plist>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Pull `ProgramArguments` back out of a plist we wrote.
|
|
108
|
+
*
|
|
109
|
+
* `status` reports the port the service is ACTUALLY installed with, which is
|
|
110
|
+
* only knowable from the installed unit — recomputing it from today's flags
|
|
111
|
+
* would print a confident URL for a server listening somewhere else. Regex
|
|
112
|
+
* rather than a plist parser because the input is a file this module wrote, in
|
|
113
|
+
* a shape this module controls, and adding an XML dependency to read it back
|
|
114
|
+
* would be the larger risk.
|
|
115
|
+
*/
|
|
116
|
+
export function parsePlistArgv(xml) {
|
|
117
|
+
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml);
|
|
118
|
+
if (block === null)
|
|
119
|
+
return [];
|
|
120
|
+
return [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => m[1]
|
|
121
|
+
.replace(/"/g, '"')
|
|
122
|
+
.replace(/>/g, ">")
|
|
123
|
+
.replace(/</g, "<")
|
|
124
|
+
.replace(/&/g, "&"));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Read liveness out of `launchctl print gui/<uid>/<label>`.
|
|
128
|
+
*
|
|
129
|
+
* That command is the honest answer to "is it running": it reports the service
|
|
130
|
+
* as launchd currently understands it, including `state = running` and the pid.
|
|
131
|
+
* The alternative — checking whether the plist file exists — answers "is it
|
|
132
|
+
* registered", which is a different and much weaker question.
|
|
133
|
+
*/
|
|
134
|
+
export function parseLaunchctlPrint(out) {
|
|
135
|
+
const state = /^\s*state\s*=\s*(\S+)/m.exec(out);
|
|
136
|
+
const pid = /^\s*pid\s*=\s*(\d+)/m.exec(out);
|
|
137
|
+
return {
|
|
138
|
+
running: state !== null && state[1] === "running",
|
|
139
|
+
...(pid !== null ? { pid: pid[1] } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function target() {
|
|
143
|
+
return `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`;
|
|
144
|
+
}
|
|
145
|
+
function domain() {
|
|
146
|
+
return `gui/${process.getuid?.() ?? 0}`;
|
|
147
|
+
}
|
|
148
|
+
export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
149
|
+
const file = plistPath(homeDir);
|
|
150
|
+
return {
|
|
151
|
+
platform: "darwin",
|
|
152
|
+
unitPath: file,
|
|
153
|
+
label: LAUNCHD_LABEL,
|
|
154
|
+
install(spec) {
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
156
|
+
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
157
|
+
fs.writeFileSync(file, renderPlist(spec), "utf8");
|
|
158
|
+
// `bootstrap` fails outright if the label is already loaded, so a
|
|
159
|
+
// reinstall has to unload first. A first install has nothing to unload,
|
|
160
|
+
// and says so on stderr — ignored rather than reported, because "there
|
|
161
|
+
// was no previous service" is the expected case, not a problem.
|
|
162
|
+
run("launchctl", ["bootout", target()]);
|
|
163
|
+
// NOT `load`/`unload`: those are the legacy API, and on a modern macOS
|
|
164
|
+
// they are shims over this one that report success in cases where it
|
|
165
|
+
// reports the actual error.
|
|
166
|
+
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
167
|
+
if (boot.status !== 0) {
|
|
168
|
+
throw new Error(`launchctl bootstrap failed (exit ${String(boot.status)}).\n` +
|
|
169
|
+
`${boot.stderr || boot.stdout}`.trim());
|
|
170
|
+
}
|
|
171
|
+
// `RunAtLoad` should already have started it; `kickstart -k` makes that
|
|
172
|
+
// true rather than probable, and is the same command a later restart uses.
|
|
173
|
+
run("launchctl", ["kickstart", "-k", target()]);
|
|
174
|
+
},
|
|
175
|
+
uninstall() {
|
|
176
|
+
const existed = fs.existsSync(file);
|
|
177
|
+
run("launchctl", ["bootout", target()]);
|
|
178
|
+
if (existed)
|
|
179
|
+
fs.rmSync(file, { force: true });
|
|
180
|
+
return existed;
|
|
181
|
+
},
|
|
182
|
+
status() {
|
|
183
|
+
if (!fs.existsSync(file))
|
|
184
|
+
return { registered: false, running: false, argv: [] };
|
|
185
|
+
const argv = parsePlistArgv(fs.readFileSync(file, "utf8"));
|
|
186
|
+
const printed = run("launchctl", ["print", target()]);
|
|
187
|
+
const parsed = printed.status === 0
|
|
188
|
+
? parseLaunchctlPrint(printed.stdout)
|
|
189
|
+
: { running: false, pid: undefined };
|
|
190
|
+
return {
|
|
191
|
+
registered: true,
|
|
192
|
+
running: parsed.running,
|
|
193
|
+
...(parsed.pid !== undefined ? { pid: parsed.pid } : {}),
|
|
194
|
+
// Strip the interpreter and script: what a reader wants from `status`
|
|
195
|
+
// is the Paddock invocation, not the absolute path to node twice.
|
|
196
|
+
argv: argv.slice(2),
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
logsHint(spec) {
|
|
200
|
+
return ` Logs: ${spec.stdoutPath}\n ${spec.stderrPath}`;
|
|
201
|
+
},
|
|
202
|
+
lingerNote() {
|
|
203
|
+
return undefined;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse-DNS on macOS, plain on Linux. Both are stable identifiers a user may
|
|
3
|
+
* end up typing (`launchctl print gui/501/net.edspencer.paddock`,
|
|
4
|
+
* `systemctl --user status paddock`), so neither is derived from anything that
|
|
5
|
+
* could vary between installs.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LAUNCHD_LABEL = "net.edspencer.paddock";
|
|
8
|
+
export declare const SYSTEMD_UNIT = "paddock.service";
|
|
9
|
+
/** Default port, matching the server's own (#741). Kept here so the generated
|
|
10
|
+
* unit can name a port even when the user passed no flag — a unit that relies
|
|
11
|
+
* on a default is a unit whose port changes under it on upgrade. */
|
|
12
|
+
export declare const DEFAULT_PORT = "7233";
|
|
13
|
+
export interface ServiceSpec {
|
|
14
|
+
/** Absolute path to the `node` that will run us — `process.execPath`. */
|
|
15
|
+
nodePath: string;
|
|
16
|
+
/**
|
|
17
|
+
* Absolute path to `dist/cli/paddock.js`.
|
|
18
|
+
*
|
|
19
|
+
* NOT the `bin` shim. npm installs a bin as a symlink whose `#!/usr/bin/env
|
|
20
|
+
* node` shebang has to find `node` on PATH, and launchd's PATH is a stub —
|
|
21
|
+
* under a version manager there is no `node` on it at all. Invoking the
|
|
22
|
+
* interpreter explicitly removes the lookup entirely.
|
|
23
|
+
*/
|
|
24
|
+
scriptPath: string;
|
|
25
|
+
/** Arguments after the script: always starts with the `start` verb. */
|
|
26
|
+
args: string[];
|
|
27
|
+
/** A directory the service can sit in that it certainly owns. */
|
|
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. */
|
|
33
|
+
pathEnv: string;
|
|
34
|
+
}
|
|
35
|
+
export interface SpecInput {
|
|
36
|
+
nodePath: string;
|
|
37
|
+
scriptPath: string;
|
|
38
|
+
/** Resolved data dir — where the working directory and logs go. */
|
|
39
|
+
dataDir: string;
|
|
40
|
+
/** Only forwarded when the user asked for it explicitly. */
|
|
41
|
+
port?: string;
|
|
42
|
+
host?: string;
|
|
43
|
+
/** Only present when the user wants an instance separate from their terminal one. */
|
|
44
|
+
dataDirArg?: string;
|
|
45
|
+
verbose?: boolean;
|
|
46
|
+
pathEnv?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The service's own directory inside the data dir.
|
|
50
|
+
*
|
|
51
|
+
* `WorkingDirectory` has to be *something*, and the two obvious candidates are
|
|
52
|
+
* both wrong. `$HOME` is wrong on the merits — a background process has no cwd
|
|
53
|
+
* anyone chose, and it should not sit in a directory full of the user's things
|
|
54
|
+
* (it was also, until #798, actively dangerous: `.paddock` was the here-marker
|
|
55
|
+
* AND the default data dir, so `$HOME` looked like an already-opened
|
|
56
|
+
* workspace). The data dir root is wrong because launchd's `StandardOutPath`
|
|
57
|
+
* would then drop log files in among the state directories. A dedicated
|
|
58
|
+
* subdirectory is neither.
|
|
59
|
+
*/
|
|
60
|
+
export declare function serviceDir(dataDir: string): string;
|
|
61
|
+
/** The data dir the service will use, resolved the same way `paddock.ts` does. */
|
|
62
|
+
export declare function defaultDataDir(homeDir?: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Assemble the spec.
|
|
65
|
+
*
|
|
66
|
+
* The argument vector leads with the `start` verb even though bare `paddock`
|
|
67
|
+
* would do the same thing. It costs nothing and it is the difference between
|
|
68
|
+
* `launchctl print` showing an argv you can read and one you have to recognise.
|
|
69
|
+
*
|
|
70
|
+
* `--data-dir` is forwarded ONLY when the user passed it. The default of
|
|
71
|
+
* setting nothing is load-bearing: with no data dir named anywhere, the service
|
|
72
|
+
* and a `paddock` typed into a terminal both fall through to `~/.paddock` and
|
|
73
|
+
* are the same instance. Naming it — even naming it as the same value — would
|
|
74
|
+
* be a second place to change on the day someone moves it.
|
|
75
|
+
*
|
|
76
|
+
* `--open` is deliberately never forwarded, whatever was passed to `install`: a
|
|
77
|
+
* service opening a browser tab at login is not a feature.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildSpec(input: SpecInput): ServiceSpec;
|
|
80
|
+
/**
|
|
81
|
+
* Is this copy of Paddock running out of npx's cache?
|
|
82
|
+
*
|
|
83
|
+
* npx materialises a package under `~/.npm/_npx/<hash>/`, where the hash is
|
|
84
|
+
* keyed on the resolved spec. Copies accumulate, and the whole tree is removed
|
|
85
|
+
* by `npm cache clean`. A unit file pointing in there works right up until it
|
|
86
|
+
* doesn't — at a login, unattended, with the failure reported as "Paddock
|
|
87
|
+
* stopped starting". Refusing to write it is the only outcome that cannot
|
|
88
|
+
* mislead someone six months from now.
|
|
89
|
+
*
|
|
90
|
+
* Matched on the path separator both sides so a project genuinely named `_npx`
|
|
91
|
+
* is not caught, and with a Windows-separator variant because `packageRoot` is
|
|
92
|
+
* whatever the platform produced.
|
|
93
|
+
*/
|
|
94
|
+
export declare function isNpxPath(p: string): boolean;
|
|
95
|
+
export declare const NPX_REFUSAL: string;
|
|
96
|
+
/**
|
|
97
|
+
* The port a generated unit will actually serve on, read back out of its own
|
|
98
|
+
* recorded argument vector.
|
|
99
|
+
*
|
|
100
|
+
* `status` reports a URL, and it must be the URL of the service as installed
|
|
101
|
+
* rather than the one an install run today would produce — those differ the
|
|
102
|
+
* moment someone installs with `--port` and then upgrades their expectations.
|
|
103
|
+
*/
|
|
104
|
+
export declare function portFromArgv(argv: string[], fallback?: string): string;
|
|
105
|
+
/** As {@link portFromArgv}, for the bind address. */
|
|
106
|
+
export declare function hostFromArgv(argv: string[], fallback?: string): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Paddock background service IS, independent of how any one init system
|
|
3
|
+
* spells it (#796).
|
|
4
|
+
*
|
|
5
|
+
* Everything in this file is pure. The two backends — `launchd.ts` and
|
|
6
|
+
* `systemd.ts` — are writers: they take a {@link ServiceSpec} and render a
|
|
7
|
+
* plist or a unit file. Keeping the decisions here rather than in either writer
|
|
8
|
+
* is what lets the darwin plist be tested on Linux, which is the only place it
|
|
9
|
+
* CAN be tested in this project's CI.
|
|
10
|
+
*/
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
/**
|
|
14
|
+
* Reverse-DNS on macOS, plain on Linux. Both are stable identifiers a user may
|
|
15
|
+
* end up typing (`launchctl print gui/501/net.edspencer.paddock`,
|
|
16
|
+
* `systemctl --user status paddock`), so neither is derived from anything that
|
|
17
|
+
* could vary between installs.
|
|
18
|
+
*/
|
|
19
|
+
export const LAUNCHD_LABEL = "net.edspencer.paddock";
|
|
20
|
+
export const SYSTEMD_UNIT = "paddock.service";
|
|
21
|
+
/** Default port, matching the server's own (#741). Kept here so the generated
|
|
22
|
+
* unit can name a port even when the user passed no flag — a unit that relies
|
|
23
|
+
* on a default is a unit whose port changes under it on upgrade. */
|
|
24
|
+
export const DEFAULT_PORT = "7233";
|
|
25
|
+
/**
|
|
26
|
+
* The service's own directory inside the data dir.
|
|
27
|
+
*
|
|
28
|
+
* `WorkingDirectory` has to be *something*, and the two obvious candidates are
|
|
29
|
+
* both wrong. `$HOME` is wrong on the merits — a background process has no cwd
|
|
30
|
+
* anyone chose, and it should not sit in a directory full of the user's things
|
|
31
|
+
* (it was also, until #798, actively dangerous: `.paddock` was the here-marker
|
|
32
|
+
* AND the default data dir, so `$HOME` looked like an already-opened
|
|
33
|
+
* workspace). The data dir root is wrong because launchd's `StandardOutPath`
|
|
34
|
+
* would then drop log files in among the state directories. A dedicated
|
|
35
|
+
* subdirectory is neither.
|
|
36
|
+
*/
|
|
37
|
+
export function serviceDir(dataDir) {
|
|
38
|
+
return path.join(dataDir, "service");
|
|
39
|
+
}
|
|
40
|
+
/** The data dir the service will use, resolved the same way `paddock.ts` does. */
|
|
41
|
+
export function defaultDataDir(homeDir = os.homedir()) {
|
|
42
|
+
return path.join(homeDir, ".paddock");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Assemble the spec.
|
|
46
|
+
*
|
|
47
|
+
* The argument vector leads with the `start` verb even though bare `paddock`
|
|
48
|
+
* would do the same thing. It costs nothing and it is the difference between
|
|
49
|
+
* `launchctl print` showing an argv you can read and one you have to recognise.
|
|
50
|
+
*
|
|
51
|
+
* `--data-dir` is forwarded ONLY when the user passed it. The default of
|
|
52
|
+
* setting nothing is load-bearing: with no data dir named anywhere, the service
|
|
53
|
+
* and a `paddock` typed into a terminal both fall through to `~/.paddock` and
|
|
54
|
+
* are the same instance. Naming it — even naming it as the same value — would
|
|
55
|
+
* be a second place to change on the day someone moves it.
|
|
56
|
+
*
|
|
57
|
+
* `--open` is deliberately never forwarded, whatever was passed to `install`: a
|
|
58
|
+
* service opening a browser tab at login is not a feature.
|
|
59
|
+
*/
|
|
60
|
+
export function buildSpec(input) {
|
|
61
|
+
const args = ["start"];
|
|
62
|
+
if (input.port !== undefined)
|
|
63
|
+
args.push("--port", input.port);
|
|
64
|
+
if (input.host !== undefined)
|
|
65
|
+
args.push("--host", input.host);
|
|
66
|
+
if (input.dataDirArg !== undefined)
|
|
67
|
+
args.push("--data-dir", input.dataDirArg);
|
|
68
|
+
if (input.verbose === true)
|
|
69
|
+
args.push("--verbose");
|
|
70
|
+
const dir = serviceDir(input.dataDir);
|
|
71
|
+
return {
|
|
72
|
+
nodePath: input.nodePath,
|
|
73
|
+
scriptPath: input.scriptPath,
|
|
74
|
+
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",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Is this copy of Paddock running out of npx's cache?
|
|
83
|
+
*
|
|
84
|
+
* npx materialises a package under `~/.npm/_npx/<hash>/`, where the hash is
|
|
85
|
+
* keyed on the resolved spec. Copies accumulate, and the whole tree is removed
|
|
86
|
+
* by `npm cache clean`. A unit file pointing in there works right up until it
|
|
87
|
+
* doesn't — at a login, unattended, with the failure reported as "Paddock
|
|
88
|
+
* stopped starting". Refusing to write it is the only outcome that cannot
|
|
89
|
+
* mislead someone six months from now.
|
|
90
|
+
*
|
|
91
|
+
* Matched on the path separator both sides so a project genuinely named `_npx`
|
|
92
|
+
* is not caught, and with a Windows-separator variant because `packageRoot` is
|
|
93
|
+
* whatever the platform produced.
|
|
94
|
+
*/
|
|
95
|
+
export function isNpxPath(p) {
|
|
96
|
+
return p.includes(`${path.sep}_npx${path.sep}`) || p.includes("/_npx/");
|
|
97
|
+
}
|
|
98
|
+
export const NPX_REFUSAL = "`service install` needs an install path that will still be there at your\n" +
|
|
99
|
+
" next login, and this copy is running from npx's cache — a hash-keyed\n" +
|
|
100
|
+
" directory that `npm cache clean` removes.\n\n" +
|
|
101
|
+
" npm i -g @edspencer/paddock && paddock service install\n";
|
|
102
|
+
/**
|
|
103
|
+
* The port a generated unit will actually serve on, read back out of its own
|
|
104
|
+
* recorded argument vector.
|
|
105
|
+
*
|
|
106
|
+
* `status` reports a URL, and it must be the URL of the service as installed
|
|
107
|
+
* rather than the one an install run today would produce — those differ the
|
|
108
|
+
* moment someone installs with `--port` and then upgrades their expectations.
|
|
109
|
+
*/
|
|
110
|
+
export function portFromArgv(argv, fallback = DEFAULT_PORT) {
|
|
111
|
+
for (let i = 0; i < argv.length; i++) {
|
|
112
|
+
if ((argv[i] === "--port" || argv[i] === "-p") && argv[i + 1] !== undefined)
|
|
113
|
+
return argv[i + 1];
|
|
114
|
+
}
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
117
|
+
/** As {@link portFromArgv}, for the bind address. */
|
|
118
|
+
export function hostFromArgv(argv, fallback = "127.0.0.1") {
|
|
119
|
+
for (let i = 0; i < argv.length; i++) {
|
|
120
|
+
if (argv[i] === "--host" && argv[i + 1] !== undefined)
|
|
121
|
+
return argv[i + 1];
|
|
122
|
+
}
|
|
123
|
+
return fallback;
|
|
124
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ServiceSpec } from "./spec.js";
|
|
2
|
+
import type { Runner, ServiceBackend } from "./backend.js";
|
|
3
|
+
/**
|
|
4
|
+
* `~/.config/systemd/user/paddock.service`, honouring `XDG_CONFIG_HOME`.
|
|
5
|
+
*
|
|
6
|
+
* ## An injected home outranks the ambient variable
|
|
7
|
+
*
|
|
8
|
+
* The precedence is: an explicit `xdgConfigHome` argument, then an explicitly
|
|
9
|
+
* **injected** `homeDir`, then — only when the caller named neither — the real
|
|
10
|
+
* environment. That middle rung is the whole point, and getting it wrong was a
|
|
11
|
+
* live bug rather than a style question.
|
|
12
|
+
*
|
|
13
|
+
* The first version read `process.env.XDG_CONFIG_HOME` before falling back to
|
|
14
|
+
* `homeDir`. On any machine with that variable set — CI, and plenty of Linux
|
|
15
|
+
* desktops — the `homeDir` parameter was therefore **a lie**: a caller passing a
|
|
16
|
+
* throwaway directory still resolved to the real config home. Two things
|
|
17
|
+
* followed. Test isolation quietly stopped working, so which assertion passed
|
|
18
|
+
* depended on test ORDER rather than test content. And the suite wrote a real
|
|
19
|
+
* `paddock.service` into the runner's actual config directory, outside every
|
|
20
|
+
* temp dir it thought it was confined to — which on a contributor's machine
|
|
21
|
+
* would have installed a unit they never asked for.
|
|
22
|
+
*
|
|
23
|
+
* So: passing a home is a statement about where home is, and it wins. The
|
|
24
|
+
* ambient read survives only on the branch where nobody said otherwise, which
|
|
25
|
+
* is the real-use branch and the one where honouring `XDG_CONFIG_HOME` is
|
|
26
|
+
* correct. (`paddock.ts` threads the variable in explicitly as well, so the
|
|
27
|
+
* production path does not depend on this fallback — it is here so that a
|
|
28
|
+
* caller with no opinion still gets the right answer.)
|
|
29
|
+
*/
|
|
30
|
+
export declare function unitPath(homeDir?: string, xdgConfigHome?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Render the unit.
|
|
33
|
+
*
|
|
34
|
+
* `Restart=on-failure` rather than `always`, matching launchd's `SuccessfulExit:
|
|
35
|
+
* false` — a clean shutdown should stay shut down.
|
|
36
|
+
*
|
|
37
|
+
* `WantedBy=default.target` is what makes `systemctl --user enable` mean
|
|
38
|
+
* "start at login" for a user unit; `multi-user.target` is the system-unit
|
|
39
|
+
* spelling and does nothing here.
|
|
40
|
+
*
|
|
41
|
+
* No `StandardOutput=` — a user unit's output goes to the journal, which is
|
|
42
|
+
* where a Linux user will look, and duplicating it into a file would give two
|
|
43
|
+
* answers to "where are the logs".
|
|
44
|
+
*/
|
|
45
|
+
export declare function renderUnit(spec: ServiceSpec): string;
|
|
46
|
+
/** Read `ExecStart=` back out of an installed unit, split into an argv. */
|
|
47
|
+
export declare function parseUnitArgv(text: string): string[];
|
|
48
|
+
export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Linux backend: a `systemd --user` unit (#796).
|
|
3
|
+
*
|
|
4
|
+
* ## Not the server story
|
|
5
|
+
*
|
|
6
|
+
* Paddock already documents itself under systemd — `guides/proxmox-lxc.md`
|
|
7
|
+
* Path B, provisioned by the `paddock-deploy` Ansible recipes. That is a
|
|
8
|
+
* SYSTEM unit, running as a service user, behind a reverse proxy, with a token
|
|
9
|
+
* in its environment. This is the other one: a user unit, running as you, on
|
|
10
|
+
* loopback, using the Claude login you already have. They should not be
|
|
11
|
+
* confused, and this module writes nothing outside `~/.config/systemd/user`.
|
|
12
|
+
*
|
|
13
|
+
* ## Lingering
|
|
14
|
+
*
|
|
15
|
+
* A `--user` manager is started at your first login and stopped at your last
|
|
16
|
+
* logout, so by default the unit dies when you log out — including when you log
|
|
17
|
+
* out of an SSH session, which is how most people meet this. `loginctl
|
|
18
|
+
* enable-linger <user>` keeps the manager (and therefore Paddock) alive.
|
|
19
|
+
*
|
|
20
|
+
* That is called out in the install output rather than being run for the user:
|
|
21
|
+
* lingering is a real change to how the machine behaves after they leave it,
|
|
22
|
+
* and it needs a privilege this command otherwise never asks for.
|
|
23
|
+
*/
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { SYSTEMD_UNIT } from "./spec.js";
|
|
28
|
+
/**
|
|
29
|
+
* `~/.config/systemd/user/paddock.service`, honouring `XDG_CONFIG_HOME`.
|
|
30
|
+
*
|
|
31
|
+
* ## An injected home outranks the ambient variable
|
|
32
|
+
*
|
|
33
|
+
* The precedence is: an explicit `xdgConfigHome` argument, then an explicitly
|
|
34
|
+
* **injected** `homeDir`, then — only when the caller named neither — the real
|
|
35
|
+
* environment. That middle rung is the whole point, and getting it wrong was a
|
|
36
|
+
* live bug rather than a style question.
|
|
37
|
+
*
|
|
38
|
+
* The first version read `process.env.XDG_CONFIG_HOME` before falling back to
|
|
39
|
+
* `homeDir`. On any machine with that variable set — CI, and plenty of Linux
|
|
40
|
+
* desktops — the `homeDir` parameter was therefore **a lie**: a caller passing a
|
|
41
|
+
* throwaway directory still resolved to the real config home. Two things
|
|
42
|
+
* followed. Test isolation quietly stopped working, so which assertion passed
|
|
43
|
+
* depended on test ORDER rather than test content. And the suite wrote a real
|
|
44
|
+
* `paddock.service` into the runner's actual config directory, outside every
|
|
45
|
+
* temp dir it thought it was confined to — which on a contributor's machine
|
|
46
|
+
* would have installed a unit they never asked for.
|
|
47
|
+
*
|
|
48
|
+
* So: passing a home is a statement about where home is, and it wins. The
|
|
49
|
+
* ambient read survives only on the branch where nobody said otherwise, which
|
|
50
|
+
* is the real-use branch and the one where honouring `XDG_CONFIG_HOME` is
|
|
51
|
+
* correct. (`paddock.ts` threads the variable in explicitly as well, so the
|
|
52
|
+
* production path does not depend on this fallback — it is here so that a
|
|
53
|
+
* caller with no opinion still gets the right answer.)
|
|
54
|
+
*/
|
|
55
|
+
export function unitPath(homeDir, xdgConfigHome) {
|
|
56
|
+
const base = xdgConfigHome ??
|
|
57
|
+
(homeDir !== undefined
|
|
58
|
+
? path.join(homeDir, ".config")
|
|
59
|
+
: (process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")));
|
|
60
|
+
return path.join(base, "systemd", "user", SYSTEMD_UNIT);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Render the unit.
|
|
64
|
+
*
|
|
65
|
+
* `Restart=on-failure` rather than `always`, matching launchd's `SuccessfulExit:
|
|
66
|
+
* false` — a clean shutdown should stay shut down.
|
|
67
|
+
*
|
|
68
|
+
* `WantedBy=default.target` is what makes `systemctl --user enable` mean
|
|
69
|
+
* "start at login" for a user unit; `multi-user.target` is the system-unit
|
|
70
|
+
* spelling and does nothing here.
|
|
71
|
+
*
|
|
72
|
+
* No `StandardOutput=` — a user unit's output goes to the journal, which is
|
|
73
|
+
* where a Linux user will look, and duplicating it into a file would give two
|
|
74
|
+
* answers to "where are the logs".
|
|
75
|
+
*/
|
|
76
|
+
export function renderUnit(spec) {
|
|
77
|
+
const exec = [spec.nodePath, spec.scriptPath, ...spec.args].join(" ");
|
|
78
|
+
return `[Unit]
|
|
79
|
+
Description=Paddock — persistent, resumable Claude Code sessions, by project
|
|
80
|
+
Documentation=https://paddock.edspencer.net
|
|
81
|
+
|
|
82
|
+
[Service]
|
|
83
|
+
Type=simple
|
|
84
|
+
ExecStart=${exec}
|
|
85
|
+
WorkingDirectory=${spec.workingDirectory}
|
|
86
|
+
Environment=PATH=${spec.pathEnv}
|
|
87
|
+
Restart=on-failure
|
|
88
|
+
RestartSec=10
|
|
89
|
+
|
|
90
|
+
[Install]
|
|
91
|
+
WantedBy=default.target
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
/** Read `ExecStart=` back out of an installed unit, split into an argv. */
|
|
95
|
+
export function parseUnitArgv(text) {
|
|
96
|
+
const m = /^ExecStart=(.*)$/m.exec(text);
|
|
97
|
+
if (m === null)
|
|
98
|
+
return [];
|
|
99
|
+
return m[1].trim().split(/\s+/).filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
export function createSystemdBackend(run, homeDir, xdgConfigHome) {
|
|
102
|
+
// Both forwarded, neither defaulted here: `unitPath` owns the precedence, and
|
|
103
|
+
// defaulting `homeDir` to `os.homedir()` at this layer would collapse "the
|
|
104
|
+
// caller named a home" into "the caller named nothing" — the exact
|
|
105
|
+
// distinction that precedence turns on.
|
|
106
|
+
const file = unitPath(homeDir, xdgConfigHome);
|
|
107
|
+
return {
|
|
108
|
+
platform: "linux",
|
|
109
|
+
unitPath: file,
|
|
110
|
+
label: SYSTEMD_UNIT,
|
|
111
|
+
install(spec) {
|
|
112
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
113
|
+
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
114
|
+
fs.writeFileSync(file, renderUnit(spec), "utf8");
|
|
115
|
+
// Without this the manager keeps serving the previous unit from cache and
|
|
116
|
+
// an "upgrade" silently changes nothing.
|
|
117
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
118
|
+
const enabled = run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
|
|
119
|
+
if (enabled.status !== 0) {
|
|
120
|
+
throw new Error(`systemctl --user enable --now failed (exit ${String(enabled.status)}).\n` +
|
|
121
|
+
`${enabled.stderr || enabled.stdout}`.trim());
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
uninstall() {
|
|
125
|
+
const existed = fs.existsSync(file);
|
|
126
|
+
run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
|
|
127
|
+
if (existed)
|
|
128
|
+
fs.rmSync(file, { force: true });
|
|
129
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
130
|
+
return existed;
|
|
131
|
+
},
|
|
132
|
+
status() {
|
|
133
|
+
if (!fs.existsSync(file))
|
|
134
|
+
return { registered: false, running: false, argv: [] };
|
|
135
|
+
const argv = parseUnitArgv(fs.readFileSync(file, "utf8"));
|
|
136
|
+
const active = run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]);
|
|
137
|
+
const pid = run("systemctl", ["--user", "show", "-p", "MainPID", "--value", SYSTEMD_UNIT]);
|
|
138
|
+
const mainPid = pid.stdout.trim();
|
|
139
|
+
return {
|
|
140
|
+
registered: true,
|
|
141
|
+
running: active.stdout.trim() === "active",
|
|
142
|
+
...(mainPid !== "" && mainPid !== "0" ? { pid: mainPid } : {}),
|
|
143
|
+
argv: argv.slice(2),
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
logsHint() {
|
|
147
|
+
return ` Logs: journalctl --user -u ${SYSTEMD_UNIT} -f`;
|
|
148
|
+
},
|
|
149
|
+
lingerNote() {
|
|
150
|
+
const user = os.userInfo().username;
|
|
151
|
+
return (" A user service stops when you log out. To keep Paddock up:\n" +
|
|
152
|
+
` loginctl enable-linger ${user}`);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|