@edspencer/paddock 0.82.0 → 0.82.1
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/cli/args.js +2 -2
- package/packages/server/dist/cli/busy-command.js +1 -1
- package/packages/server/dist/cli/busy.js +10 -12
- package/packages/server/dist/cli/exit-codes.d.ts +21 -45
- package/packages/server/dist/cli/exit-codes.js +42 -45
- package/packages/server/dist/cli/output.js +5 -6
- package/packages/server/dist/cli/paddock.js +9 -9
- package/packages/server/dist/cli/service/index.d.ts +2 -2
- package/packages/server/dist/cli/service/index.js +30 -24
- package/packages/server/dist/cli/service/launchd.d.ts +31 -84
- package/packages/server/dist/cli/service/launchd.js +104 -304
- package/packages/server/dist/cli/service/logs.d.ts +9 -28
- package/packages/server/dist/cli/service/logs.js +33 -75
- package/packages/server/dist/cli/service/manager.d.ts +93 -0
- package/packages/server/dist/cli/service/manager.js +21 -0
- package/packages/server/dist/cli/service/path-env.d.ts +7 -18
- package/packages/server/dist/cli/service/path-env.js +30 -81
- package/packages/server/dist/cli/service/spec.d.ts +4 -31
- package/packages/server/dist/cli/service/spec.js +9 -0
- package/packages/server/dist/cli/service/systemd.d.ts +27 -101
- package/packages/server/dist/cli/service/systemd.js +106 -262
- package/packages/server/dist/cli/update/health.d.ts +48 -0
- package/packages/server/dist/cli/update/health.js +64 -0
- package/packages/server/dist/cli/update/install-method.d.ts +29 -76
- package/packages/server/dist/cli/update/install-method.js +103 -178
- package/packages/server/dist/cli/update/lock.d.ts +7 -12
- package/packages/server/dist/cli/update/lock.js +28 -62
- package/packages/server/dist/cli/update/package-manager.d.ts +39 -74
- package/packages/server/dist/cli/update/package-manager.js +95 -160
- package/packages/server/dist/cli/update/registry.d.ts +17 -54
- package/packages/server/dist/cli/update/registry.js +46 -89
- package/packages/server/dist/cli/update/semver.d.ts +18 -41
- package/packages/server/dist/cli/update/semver.js +74 -124
- package/packages/server/dist/cli/update/update-check.d.ts +15 -26
- package/packages/server/dist/cli/update/update-check.js +30 -60
- package/packages/server/dist/cli/update/update.d.ts +57 -91
- package/packages/server/dist/cli/update/update.js +230 -431
- package/packages/server/dist/cli/update-command.js +3 -5
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{DeleteProjectDialog-CphdGB3R.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-B_kcF4sm.js → DiscoverView-BKqI0I1E.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CvUK5DKW.js → InstanceConfigPage-DL3raCK1.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DvZ-j_Ao.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-DayHvUH2.js → ProjectRedirect-DerkA-nE.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-CCw83gUi.js → ProjectView-NwfC3_w7.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-B3Klg2H4.js → ProjectsGrid-BeORa0UH.js} +1 -1
- package/packages/web/dist/assets/{RootHome-BxcDvpgI.js → RootHome-BYJs8qJ6.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-7UlhFBSG.js → Surfaces-w-kEaAYU.js} +1 -1
- package/packages/web/dist/assets/{arc-DNkh5o5Q.js → arc-Bv56hasM.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BrgN3oLF.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-D05XOJSk.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CKaMBhMP.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
- package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-8hgadCQc.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-CBfEC-pu.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DV1eMgHk.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Cfgl5fxY.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BXhqD295.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-TFGOPtek.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CHFM665R.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CqEf6MMT.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
- package/packages/web/dist/assets/{core-DmEaKxBb.js → core-CYuJAM2U.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BnGI6jkr.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CKoPx61E.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-mMmtlBl1.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Cdz9Mtnt.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-DQxM3wYj.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BWrinPsq.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-U6MYUve2.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-DGjRXuTP.js → documentTitle-M1lrfXRU.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-xqMCsXxY.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-PMfy_czF.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-whhYYE4N.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-C51JW4tf.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
- package/packages/web/dist/assets/{index-CHOtvlnO.js → index-1E9yHciS.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-SH8cr905.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BjqaoU3U.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CBmdEVrd.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-U8njjMLZ.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
- package/packages/web/dist/assets/{linear-DF66GPs1.js → linear-D3U8NOkt.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-CouJz7Ph.js → mermaid.core-BiArdWKV.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BYIf0oFt.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Cm8rgg09.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-C3Il5kOH.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D3PE2nTe.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DdmvBM18.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-B8M_GAIq.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DZkXnK1_.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-IHXq7rqi.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dga7tLtT.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CmcwnEJ-.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-zHuSLrD4.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D93MU_ic.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/service/backend.d.ts +0 -115
- package/packages/server/dist/cli/service/backend.js +0 -19
- package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +0 -2
- package/packages/web/dist/assets/channel-JrEedxB8.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +0 -2
|
@@ -1,98 +1,53 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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, """);
|
|
1
|
+
// From edspencer/cli-contract reference/service/launchd.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
|
|
2
|
+
// macOS: a launchd LaunchAgent in the `gui/<uid>` domain (SPEC §7). It starts at login, not at boot, and runs as the
|
|
3
|
+
// user, inside their login session (so the login keychain is unlocked). Node built-ins only.
|
|
4
|
+
//
|
|
5
|
+
// ## The bootout/bootstrap race
|
|
6
|
+
//
|
|
7
|
+
// `launchctl bootout` returns once launchd has accepted the request, not once the job has gone. A bootstrap of the
|
|
8
|
+
// same label straight after it races launchd's own teardown, and when it loses, fails with exit 5 (`EIO`,
|
|
9
|
+
// "Input/output error"). It's timing-dependent: Paddock 0.80.0's release run failed on it after the PR had passed.
|
|
10
|
+
// Both halves of the fix are needed:
|
|
11
|
+
//
|
|
12
|
+
// - **after a bootout we issue, wait for the job to go:** poll `launchctl print` until it fails (the label is
|
|
13
|
+
// unknown), up to `BOOTOUT_POLLS` times. Counted, not timed, so an injected no-op sleep can't spin for ten seconds;
|
|
14
|
+
// - **retry a bootstrap that fails with 5 or 37** ("operation already in progress"), a few times with a pause. This
|
|
15
|
+
// covers a bootout someone else issued: `service start` straight after another process's `service stop`.
|
|
16
|
+
//
|
|
17
|
+
// Anything else launchctl says is reported at once, in its own words.
|
|
18
|
+
//
|
|
19
|
+
// A fake `launchctl` in tests must fail `print` when nothing is loaded, or every install sits out the whole poll.
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { sleepSync } from './manager.js';
|
|
24
|
+
/** `~/Library/LaunchAgents/<label>.plist`. */
|
|
25
|
+
export function plistPath(id, homeDir = os.homedir()) {
|
|
26
|
+
return path.join(homeDir, 'Library', 'LaunchAgents', `${id.label}.plist`);
|
|
50
27
|
}
|
|
28
|
+
const xmlEscape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
29
|
+
const xmlUnescape = (s) => s.replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
|
|
51
30
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `KeepAlive` is unconditional `<true
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* to stop", and Paddock stayed down until the next login. A hard crash was the
|
|
61
|
-
* survivable case; the graceful stop was the terminal one.
|
|
62
|
-
*
|
|
63
|
-
* The worry that motivated the dict — that `<true/>` fights a deliberate stop —
|
|
64
|
-
* does not hold for the stop this CLI actually performs. `launchctl bootout`
|
|
65
|
-
* unloads the job from the domain, and KeepAlive only applies to a job that is
|
|
66
|
-
* still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
|
|
67
|
-
* does fight is `launchctl stop`, which is why nothing here uses it.
|
|
68
|
-
*
|
|
69
|
-
* Intent belongs in an explicit command, not in an exit code.
|
|
70
|
-
*
|
|
71
|
-
* `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
|
|
72
|
-
* launchd hands a process a stub PATH that a version manager's `node`, and the
|
|
73
|
-
* `git` an agent will want, are both missing from. It carries nothing else
|
|
74
|
-
* because a plist is a world-readable file in the user's home, and the one
|
|
75
|
-
* category of value that would be tempting to put here — a credential — is
|
|
76
|
-
* precisely what the agent shape exists to avoid needing.
|
|
77
|
-
*
|
|
78
|
-
* No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
|
|
79
|
-
* from the service and from a terminal.
|
|
80
|
-
*
|
|
81
|
-
* `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
|
|
82
|
-
* launchd opens both in append mode, so interleaving is what a reader wants
|
|
83
|
-
* anyway — a warning and the stack trace that followed it used to land in two
|
|
84
|
-
* separate files, in an order nothing recorded. One file is also what makes
|
|
85
|
-
* `paddock service logs` one implementation across platforms.
|
|
31
|
+
* The plist.
|
|
32
|
+
*
|
|
33
|
+
* - `KeepAlive` is an unconditional `<true/>`. `{ SuccessfulExit: false }` reads as the polite choice, but a server
|
|
34
|
+
* exits 0 on SIGTERM, so every SIGTERM launchd sends an agent (sleep, logout) would leave it down until the next
|
|
35
|
+
* login. `bootout` unloads the job, so `KeepAlive` doesn't fight a deliberate stop.
|
|
36
|
+
* - `StandardOutPath` and `StandardErrorPath` name the same file.
|
|
37
|
+
* - `EnvironmentVariables` has PATH (launchd's is a stub) and the spec's `env`. Never a credential: a plist is a
|
|
38
|
+
* readable file.
|
|
86
39
|
*/
|
|
87
|
-
export function renderPlist(spec) {
|
|
88
|
-
const
|
|
89
|
-
const
|
|
40
|
+
export function renderPlist(id, spec) {
|
|
41
|
+
const strings = [spec.nodePath, spec.scriptPath, ...spec.args].map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
42
|
+
const env = Object.entries({ PATH: spec.pathEnv, ...spec.env })
|
|
43
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
44
|
+
.join('\n');
|
|
90
45
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
91
46
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
92
47
|
<plist version="1.0">
|
|
93
48
|
<dict>
|
|
94
49
|
<key>Label</key>
|
|
95
|
-
<string>${xmlEscape(
|
|
50
|
+
<string>${xmlEscape(id.label)}</string>
|
|
96
51
|
<key>ProgramArguments</key>
|
|
97
52
|
<array>
|
|
98
53
|
${strings}
|
|
@@ -111,280 +66,125 @@ ${strings}
|
|
|
111
66
|
<string>${xmlEscape(spec.logPath)}</string>
|
|
112
67
|
<key>EnvironmentVariables</key>
|
|
113
68
|
<dict>
|
|
114
|
-
|
|
115
|
-
<string>${xmlEscape(spec.pathEnv)}</string>
|
|
69
|
+
${env}
|
|
116
70
|
</dict>
|
|
117
71
|
</dict>
|
|
118
72
|
</plist>
|
|
119
73
|
`;
|
|
120
74
|
}
|
|
121
|
-
/**
|
|
122
|
-
* Pull `ProgramArguments` back out of a plist we wrote.
|
|
123
|
-
*
|
|
124
|
-
* `status` reports the port the service is ACTUALLY installed with, which is
|
|
125
|
-
* only knowable from the installed unit — recomputing it from today's flags
|
|
126
|
-
* would print a confident URL for a server listening somewhere else. Regex
|
|
127
|
-
* rather than a plist parser because the input is a file this module wrote, in
|
|
128
|
-
* a shape this module controls, and adding an XML dependency to read it back
|
|
129
|
-
* would be the larger risk.
|
|
130
|
-
*/
|
|
75
|
+
/** `ProgramArguments`, read back from a plist this wrote. A regex is enough for a file whose shape we control. */
|
|
131
76
|
export function parsePlistArgv(xml) {
|
|
132
77
|
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml);
|
|
133
|
-
|
|
134
|
-
return [];
|
|
135
|
-
return [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => m[1]
|
|
136
|
-
.replace(/"/g, '"')
|
|
137
|
-
.replace(/>/g, ">")
|
|
138
|
-
.replace(/</g, "<")
|
|
139
|
-
.replace(/&/g, "&"));
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Does an installed plist carry the unconditional `KeepAlive` from #872?
|
|
143
|
-
*
|
|
144
|
-
* Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
|
|
145
|
-
* and nothing else. The shape it must NOT accept is the old
|
|
146
|
-
* `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
|
|
147
|
-
* leaves a service dead after a graceful stop — so a `false` here is the signal
|
|
148
|
-
* `status` turns into "re-run install", and defaulting to `true` on anything
|
|
149
|
-
* unrecognised would silence exactly the case this exists to catch.
|
|
150
|
-
*/
|
|
151
|
-
export function plistKeepsAlive(xml) {
|
|
152
|
-
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
|
|
78
|
+
return block === null ? [] : [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => xmlUnescape(m[1]));
|
|
153
79
|
}
|
|
154
|
-
/** The log
|
|
80
|
+
/** The log the plist names (`StandardOutPath`). */
|
|
155
81
|
export function parsePlistLogPath(xml) {
|
|
156
82
|
const m = /<key>StandardOutPath<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(xml);
|
|
157
|
-
|
|
158
|
-
return undefined;
|
|
159
|
-
return m[1]
|
|
160
|
-
.replace(/"/g, '"')
|
|
161
|
-
.replace(/>/g, ">")
|
|
162
|
-
.replace(/</g, "<")
|
|
163
|
-
.replace(/&/g, "&");
|
|
83
|
+
return m === null ? undefined : xmlUnescape(m[1]);
|
|
164
84
|
}
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
* The alternative — checking whether the plist file exists — answers "is it
|
|
171
|
-
* registered", which is a different and much weaker question.
|
|
172
|
-
*/
|
|
85
|
+
/** Whether the plist has an unconditional `KeepAlive` (the old `SuccessfulExit` dict doesn't count). */
|
|
86
|
+
export function plistKeepsAlive(xml) {
|
|
87
|
+
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
|
|
88
|
+
}
|
|
89
|
+
/** `state = running` and the pid, from `launchctl print gui/<uid>/<label>`. */
|
|
173
90
|
export function parseLaunchctlPrint(out) {
|
|
174
91
|
const state = /^\s*state\s*=\s*(\S+)/m.exec(out);
|
|
175
92
|
const pid = /^\s*pid\s*=\s*(\d+)/m.exec(out);
|
|
176
|
-
return {
|
|
177
|
-
running: state !== null && state[1] === "running",
|
|
178
|
-
...(pid !== null ? { pid: pid[1] } : {}),
|
|
179
|
-
};
|
|
93
|
+
return { running: state?.[1] === 'running', ...(pid !== null ? { pid: pid[1] } : {}) };
|
|
180
94
|
}
|
|
181
|
-
/** One phrasing for "launchctl said no", so every caller reports it the same way. */
|
|
182
95
|
function launchctlFailure(verb, r) {
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
function target() {
|
|
186
|
-
return `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`;
|
|
187
|
-
}
|
|
188
|
-
function domain() {
|
|
189
|
-
return `gui/${process.getuid?.() ?? 0}`;
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* ## The bootout/bootstrap race (#984)
|
|
193
|
-
*
|
|
194
|
-
* `launchctl bootout` returns as soon as launchd has ACCEPTED the request, not
|
|
195
|
-
* once the job has gone. Every sequence below that boots out and then
|
|
196
|
-
* bootstraps the same label therefore races launchd's own teardown, and when it
|
|
197
|
-
* loses, `bootstrap` fails with:
|
|
198
|
-
*
|
|
199
|
-
* ```
|
|
200
|
-
* launchctl bootstrap failed (exit 5): Input/output error
|
|
201
|
-
* ```
|
|
202
|
-
*
|
|
203
|
-
* Exit 5 is `EIO`, which is launchd's generic "I cannot do that right now" and
|
|
204
|
-
* the classic symptom of this race. It is timing-dependent, so it passes
|
|
205
|
-
* locally and on a PR and then fails on a release: Paddock 0.80.0's first
|
|
206
|
-
* release run failed exactly here, in CI's real-launchd job, on
|
|
207
|
-
* `paddock service restart`.
|
|
208
|
-
*
|
|
209
|
-
* Both halves of the fix are needed, and neither is sufficient:
|
|
210
|
-
*
|
|
211
|
-
* - **Wait for the job to go.** After a bootout, poll `launchctl print` until
|
|
212
|
-
* it reports the label gone (a non-zero exit), up to {@link BOOTOUT_POLLS}
|
|
213
|
-
* times. That removes the race in the case we caused it.
|
|
214
|
-
* - **Retry the bootstrap.** A bootout we did NOT issue can still be in
|
|
215
|
-
* flight — `service start` after a separate process's `service stop`, or a
|
|
216
|
-
* login racing a logout — and there is nothing to poll for there, because
|
|
217
|
-
* from this process's point of view the job was already gone. So a
|
|
218
|
-
* bootstrap that fails with a race code is retried with a short backoff.
|
|
219
|
-
*
|
|
220
|
-
* Timing out the poll is not an error: the retry is the backstop, and a job
|
|
221
|
-
* that will not go is a real failure the bootstrap reports in its own words.
|
|
222
|
-
*/
|
|
223
|
-
const BOOTOUT_POLLS = 40;
|
|
224
|
-
const BOOTOUT_POLL_MS = 250; // 40 × 250ms = 10s
|
|
225
|
-
const BOOTSTRAP_ATTEMPTS = 6;
|
|
226
|
-
const BOOTSTRAP_BACKOFF_MS = 500;
|
|
227
|
-
/**
|
|
228
|
-
* launchctl exits that mean "try again", as opposed to "no".
|
|
229
|
-
*
|
|
230
|
-
* `5` is `EIO`, what the release failure printed. `37` is launchd's "operation
|
|
231
|
-
* already in progress", which is the same race seen from the other side: the
|
|
232
|
-
* bootout is still being processed. Anything else — a malformed plist, a label
|
|
233
|
-
* genuinely already loaded, a permission problem — is reported, not retried,
|
|
234
|
-
* because retrying it only delays the message by three seconds.
|
|
235
|
-
*/
|
|
236
|
-
const BOOTSTRAP_RACE_CODES = new Set([5, 37]);
|
|
237
|
-
/**
|
|
238
|
-
* Block this thread for `ms`.
|
|
239
|
-
*
|
|
240
|
-
* The backend's {@link Runner} is synchronous (`execFileSync`) and so is every
|
|
241
|
-
* `ServiceBackend` method, so the wait has to be too — making `install` async
|
|
242
|
-
* would ripple through the whole service CLI for a sleep. `Atomics.wait` on a
|
|
243
|
-
* throwaway buffer is the built-in way to do it; `setTimeout` cannot help here
|
|
244
|
-
* and spinning on `Date.now()` would burn a core.
|
|
245
|
-
*
|
|
246
|
-
* Injectable because the unit tests drive the retry path with a fake runner
|
|
247
|
-
* that fails every attempt: with the real sleep, pinning the retry count would
|
|
248
|
-
* cost three seconds per case.
|
|
249
|
-
*/
|
|
250
|
-
export function sleepSync(ms) {
|
|
251
|
-
if (ms <= 0)
|
|
252
|
-
return;
|
|
253
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
96
|
+
return `launchctl ${verb} failed (exit ${String(r.status)}): ${`${r.stderr || r.stdout}`.trim()}`;
|
|
254
97
|
}
|
|
255
|
-
export
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
98
|
+
export const BOOTOUT_POLLS = 40;
|
|
99
|
+
export const BOOTOUT_POLL_MS = 250;
|
|
100
|
+
export const BOOTSTRAP_ATTEMPTS = 6;
|
|
101
|
+
export const BOOTSTRAP_BACKOFF_MS = 500;
|
|
102
|
+
/** 5 is `EIO`; 37 is "operation already in progress". Both mean the last bootout hasn't finished. */
|
|
103
|
+
export const BOOTSTRAP_RACE_CODES = new Set([5, 37]);
|
|
104
|
+
export function createLaunchd(id, o) {
|
|
105
|
+
const { run } = o;
|
|
106
|
+
const sleep = o.sleep ?? sleepSync;
|
|
107
|
+
const file = plistPath(id, o.homeDir);
|
|
108
|
+
const domain = `gui/${o.uid ?? process.getuid?.() ?? 0}`;
|
|
109
|
+
const target = `${domain}/${id.label}`;
|
|
110
|
+
/** Boots the job out and waits until launchd agrees it has gone. A job that won't go fails the bootstrap later. */
|
|
264
111
|
const bootoutAndWait = () => {
|
|
265
|
-
run(
|
|
266
|
-
// Counted, not timed: an injected no-op sleep has to terminate the loop
|
|
267
|
-
// too, and a wall-clock deadline with a no-op sleep is a ten-second spin.
|
|
112
|
+
const r = run('launchctl', ['bootout', target]);
|
|
268
113
|
for (let poll = 1; poll <= BOOTOUT_POLLS; poll++) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (run("launchctl", ["print", target()]).status !== 0)
|
|
272
|
-
return;
|
|
114
|
+
if (run('launchctl', ['print', target]).status !== 0)
|
|
115
|
+
break;
|
|
273
116
|
if (poll < BOOTOUT_POLLS)
|
|
274
117
|
sleep(BOOTOUT_POLL_MS);
|
|
275
118
|
}
|
|
119
|
+
return r;
|
|
276
120
|
};
|
|
277
|
-
/**
|
|
278
|
-
const
|
|
121
|
+
/** Bootstraps the plist, retrying the race codes. Not `load`: that's a shim that reports success when this fails. */
|
|
122
|
+
const bootstrap = () => {
|
|
279
123
|
for (let attempt = 1;; attempt++) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// reports the actual error.
|
|
283
|
-
const boot = run("launchctl", ["bootstrap", domain(), file]);
|
|
284
|
-
if (boot.status === 0)
|
|
124
|
+
const r = run('launchctl', ['bootstrap', domain, file]);
|
|
125
|
+
if (r.status === 0)
|
|
285
126
|
return;
|
|
286
|
-
if (attempt >= BOOTSTRAP_ATTEMPTS || !BOOTSTRAP_RACE_CODES.has(
|
|
287
|
-
throw new Error(launchctlFailure(
|
|
288
|
-
}
|
|
127
|
+
if (attempt >= BOOTSTRAP_ATTEMPTS || !BOOTSTRAP_RACE_CODES.has(r.status ?? -1))
|
|
128
|
+
throw new Error(launchctlFailure('bootstrap', r));
|
|
289
129
|
sleep(BOOTSTRAP_BACKOFF_MS);
|
|
290
130
|
}
|
|
291
131
|
};
|
|
292
132
|
return {
|
|
293
|
-
platform:
|
|
133
|
+
platform: 'darwin',
|
|
294
134
|
unitPath: file,
|
|
295
|
-
label:
|
|
135
|
+
label: id.label,
|
|
136
|
+
kind: 'a launchd LaunchAgent',
|
|
296
137
|
install(spec) {
|
|
297
138
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
298
139
|
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
299
|
-
// launchd
|
|
300
|
-
// file whose parent is missing — the job just fails to spawn.
|
|
140
|
+
// launchd doesn't create the log's folder; without it the job fails to spawn, and says nothing.
|
|
301
141
|
fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
|
|
302
|
-
fs.writeFileSync(file, renderPlist(spec),
|
|
303
|
-
//
|
|
304
|
-
// reinstall has to unload first — and has to WAIT for that, which is what
|
|
305
|
-
// `bootoutAndWait` is for (see the race note). A first install has
|
|
306
|
-
// nothing to unload, and says so on stderr: ignored rather than reported,
|
|
307
|
-
// because "there was no previous service" is the expected case.
|
|
142
|
+
fs.writeFileSync(file, renderPlist(id, spec), 'utf8');
|
|
143
|
+
// A bootout first, because bootstrap refuses a label that's loaded. It fails on a first install, as expected.
|
|
308
144
|
bootoutAndWait();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// true rather than probable, and is the same command a later restart uses.
|
|
312
|
-
run("launchctl", ["kickstart", "-k", target()]);
|
|
145
|
+
bootstrap();
|
|
146
|
+
run('launchctl', ['kickstart', '-k', target]);
|
|
313
147
|
},
|
|
314
148
|
uninstall() {
|
|
315
149
|
const existed = fs.existsSync(file);
|
|
316
|
-
|
|
150
|
+
bootoutAndWait();
|
|
317
151
|
if (existed)
|
|
318
152
|
fs.rmSync(file, { force: true });
|
|
319
153
|
return existed;
|
|
320
154
|
},
|
|
321
|
-
/**
|
|
322
|
-
* Load the job back into the GUI domain and make sure it is running (#873).
|
|
323
|
-
*
|
|
324
|
-
* `bootstrap`, not `launchctl start`: `stop` below BOOTS OUT, so a stopped
|
|
325
|
-
* Paddock is not merely idle, it is absent from the domain — and `start` on
|
|
326
|
-
* a label launchd has never heard of fails. Bootstrapping is also what a
|
|
327
|
-
* login does, so `service start` and the next login take the same path.
|
|
328
|
-
*
|
|
329
|
-
* No bootout to wait on here, but the retry in `bootstrapOrThrow` still
|
|
330
|
-
* earns its keep: the `stop` that booted it out may have been a different
|
|
331
|
-
* `paddock` process seconds ago, and launchd may not have finished with it.
|
|
332
|
-
*/
|
|
155
|
+
/** A bootstrap, because `stop` booted it out: `launchctl start` on an unknown label fails. A login does the same. */
|
|
333
156
|
start() {
|
|
334
|
-
|
|
335
|
-
run(
|
|
157
|
+
bootstrap();
|
|
158
|
+
run('launchctl', ['kickstart', target]);
|
|
336
159
|
},
|
|
337
160
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* With #872's unconditional `KeepAlive`, `stop` is a request launchd
|
|
341
|
-
* immediately overrules — it stops the process and then relaunches it,
|
|
342
|
-
* which is exactly the wrestling match the old conditional KeepAlive was
|
|
343
|
-
* meant to avoid. `bootout` unloads the job, so KeepAlive no longer applies
|
|
344
|
-
* and it stays down. The plist is untouched, so `status` still reports it
|
|
345
|
-
* registered, `start` puts it back, and the next LOGIN starts it again via
|
|
346
|
-
* `RunAtLoad` — a stop is for this session, not forever. Uninstall is the
|
|
347
|
-
* one that is forever.
|
|
161
|
+
* A bootout, not `launchctl stop` (which `KeepAlive` immediately overrules). The plist stays, so the next login
|
|
162
|
+
* starts it again (`RunAtLoad`), and `status` still reports it installed.
|
|
348
163
|
*/
|
|
349
164
|
stop() {
|
|
350
|
-
const
|
|
351
|
-
// Exit 3
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
throw new Error(launchctlFailure("bootout", out));
|
|
355
|
-
}
|
|
165
|
+
const r = bootoutAndWait();
|
|
166
|
+
// Exit 3, "No such process", means it wasn't loaded: the state we wanted.
|
|
167
|
+
if (r.status !== 0 && r.status !== 3 && !/no such process/i.test(`${r.stderr}${r.stdout}`))
|
|
168
|
+
throw new Error(launchctlFailure('bootout', r));
|
|
356
169
|
},
|
|
357
|
-
/**
|
|
358
|
-
* Boot out, bootstrap, kick. Not `kickstart -k` alone, which needs the job
|
|
359
|
-
* to be loaded and so fails on the case `restart` most needs to handle: a
|
|
360
|
-
* service that is stopped. This sequence is identical to `install`'s minus
|
|
361
|
-
* writing the plist, which also gives `restart` the property the issue asks
|
|
362
|
-
* for — the unit is re-read on the way up, so an edited plist takes effect.
|
|
363
|
-
*
|
|
364
|
-
* This is the sequence that failed Paddock 0.80.0's release run: bootout
|
|
365
|
-
* and bootstrap back to back, with nothing between them. See the race note.
|
|
366
|
-
*/
|
|
170
|
+
/** Bootout, bootstrap, kickstart: works on a stopped job too, and re-reads the plist. */
|
|
367
171
|
restart() {
|
|
368
172
|
bootoutAndWait();
|
|
369
|
-
|
|
370
|
-
run(
|
|
173
|
+
bootstrap();
|
|
174
|
+
run('launchctl', ['kickstart', '-k', target]);
|
|
371
175
|
},
|
|
372
176
|
status() {
|
|
373
177
|
if (!fs.existsSync(file))
|
|
374
178
|
return { registered: false, running: false, argv: [], keepsAlive: true };
|
|
375
|
-
const xml = fs.readFileSync(file,
|
|
179
|
+
const xml = fs.readFileSync(file, 'utf8');
|
|
376
180
|
const argv = parsePlistArgv(xml);
|
|
377
|
-
const printed = run(
|
|
378
|
-
const
|
|
379
|
-
? parseLaunchctlPrint(printed.stdout)
|
|
380
|
-
: { running: false, pid: undefined };
|
|
181
|
+
const printed = run('launchctl', ['print', target]);
|
|
182
|
+
const live = printed.status === 0 ? parseLaunchctlPrint(printed.stdout) : { running: false };
|
|
381
183
|
const logPath = parsePlistLogPath(xml);
|
|
382
184
|
return {
|
|
383
185
|
registered: true,
|
|
384
|
-
running:
|
|
385
|
-
...(
|
|
386
|
-
// Strip the interpreter and script: what a reader wants from `status`
|
|
387
|
-
// is the Paddock invocation, not the absolute path to node twice.
|
|
186
|
+
running: live.running,
|
|
187
|
+
...(live.pid !== undefined ? { pid: live.pid } : {}),
|
|
388
188
|
argv: argv.slice(2),
|
|
389
189
|
...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
|
|
390
190
|
...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
|
|
@@ -392,7 +192,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir(), sleep = sleepS
|
|
|
392
192
|
keepsAlive: plistKeepsAlive(xml),
|
|
393
193
|
};
|
|
394
194
|
},
|
|
395
|
-
/**
|
|
195
|
+
/** A LaunchAgent lives as long as the login session; there's no linger. */
|
|
396
196
|
linger() {
|
|
397
197
|
return { applies: false };
|
|
398
198
|
},
|
|
@@ -1,39 +1,20 @@
|
|
|
1
|
-
/** How many lines `logs` prints when nobody says
|
|
1
|
+
/** How many lines `logs` prints when nobody says (SPEC §1). */
|
|
2
2
|
export declare const DEFAULT_LOG_LINES = 50;
|
|
3
|
-
/**
|
|
4
|
-
* The last `lines` lines of `file`.
|
|
5
|
-
*
|
|
6
|
-
* Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
|
|
7
|
-
* and can reach tens of megabytes between rotations, and reading all of it to
|
|
8
|
-
* print fifty lines would make `logs` slower than the thing it is reporting on.
|
|
9
|
-
* A window that happens to start mid-line drops that partial first line rather
|
|
10
|
-
* than printing half of one.
|
|
11
|
-
*/
|
|
3
|
+
/** The last `lines` lines of `file`. A window that starts mid-line drops that fragment. */
|
|
12
4
|
export declare function tailFile(file: string, lines: number): string;
|
|
13
5
|
export interface FollowOptions {
|
|
14
|
-
/** Where to start
|
|
6
|
+
/** Where to start: the file's size after the tail. */
|
|
15
7
|
from: number;
|
|
16
|
-
/** Injected for tests. */
|
|
17
8
|
intervalMs?: number;
|
|
18
|
-
/**
|
|
9
|
+
/** Return false to stop. */
|
|
19
10
|
keepGoing?: () => boolean;
|
|
20
11
|
write?: (s: string) => void;
|
|
21
12
|
}
|
|
22
13
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Polling rather than `fs.watch`: the writer here is the supervisor's own file
|
|
27
|
-
* descriptor, and `fs.watch` on an appended-to file is unreliable across
|
|
28
|
-
* platforms (no events at all on some network filesystems, and a stream of
|
|
29
|
-
* them on others). A 400 ms poll of the size is both portable and cheap.
|
|
30
|
-
*
|
|
31
|
-
* Rotation is handled by the one case that can actually happen: the server
|
|
32
|
-
* rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
|
|
33
|
-
* and the size goes DOWN. Seeing that, this resets to the new start rather than
|
|
34
|
-
* reading past the end forever, which is what a naive follower does — it goes
|
|
35
|
-
* quiet after the first rotation and never recovers.
|
|
14
|
+
* Prints what's appended to `file` until stopped. Polls the size (`fs.watch` on an appended file is unreliable across
|
|
15
|
+
* platforms). The tool rotates by copy-and-truncate, so the size goes down; seeing that, it starts again from 0
|
|
16
|
+
* rather than reading past the end for ever. A file that vanishes is waited for.
|
|
36
17
|
*/
|
|
37
|
-
export declare function followFile(file: string,
|
|
38
|
-
/** The file's size, or 0 if it
|
|
18
|
+
export declare function followFile(file: string, o: FollowOptions): Promise<void>;
|
|
19
|
+
/** The file's size, or 0 if it isn't there: where a follow starts. */
|
|
39
20
|
export declare function sizeOf(file: string): number;
|