@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.
Files changed (107) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.js +2 -2
  3. package/packages/server/dist/cli/busy-command.js +1 -1
  4. package/packages/server/dist/cli/busy.js +10 -12
  5. package/packages/server/dist/cli/exit-codes.d.ts +21 -45
  6. package/packages/server/dist/cli/exit-codes.js +42 -45
  7. package/packages/server/dist/cli/output.js +5 -6
  8. package/packages/server/dist/cli/paddock.js +9 -9
  9. package/packages/server/dist/cli/service/index.d.ts +2 -2
  10. package/packages/server/dist/cli/service/index.js +30 -24
  11. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  12. package/packages/server/dist/cli/service/launchd.js +104 -304
  13. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  14. package/packages/server/dist/cli/service/logs.js +33 -75
  15. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  16. package/packages/server/dist/cli/service/manager.js +21 -0
  17. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  18. package/packages/server/dist/cli/service/path-env.js +30 -81
  19. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  20. package/packages/server/dist/cli/service/spec.js +9 -0
  21. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  22. package/packages/server/dist/cli/service/systemd.js +106 -262
  23. package/packages/server/dist/cli/update/health.d.ts +48 -0
  24. package/packages/server/dist/cli/update/health.js +64 -0
  25. package/packages/server/dist/cli/update/install-method.d.ts +29 -76
  26. package/packages/server/dist/cli/update/install-method.js +103 -178
  27. package/packages/server/dist/cli/update/lock.d.ts +7 -12
  28. package/packages/server/dist/cli/update/lock.js +28 -62
  29. package/packages/server/dist/cli/update/package-manager.d.ts +39 -74
  30. package/packages/server/dist/cli/update/package-manager.js +95 -160
  31. package/packages/server/dist/cli/update/registry.d.ts +17 -54
  32. package/packages/server/dist/cli/update/registry.js +46 -89
  33. package/packages/server/dist/cli/update/semver.d.ts +18 -41
  34. package/packages/server/dist/cli/update/semver.js +74 -124
  35. package/packages/server/dist/cli/update/update-check.d.ts +15 -26
  36. package/packages/server/dist/cli/update/update-check.js +30 -60
  37. package/packages/server/dist/cli/update/update.d.ts +57 -91
  38. package/packages/server/dist/cli/update/update.js +230 -431
  39. package/packages/server/dist/cli/update-command.js +3 -5
  40. package/packages/server/package.json +1 -1
  41. package/packages/web/dist/assets/{DeleteProjectDialog-CphdGB3R.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
  42. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
  43. package/packages/web/dist/assets/{DiscoverView-B_kcF4sm.js → DiscoverView-BKqI0I1E.js} +1 -1
  44. package/packages/web/dist/assets/{InstanceConfigPage-CvUK5DKW.js → InstanceConfigPage-DL3raCK1.js} +1 -1
  45. package/packages/web/dist/assets/{MarkdownRenderer-DvZ-j_Ao.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
  46. package/packages/web/dist/assets/{ProjectRedirect-DayHvUH2.js → ProjectRedirect-DerkA-nE.js} +1 -1
  47. package/packages/web/dist/assets/{ProjectView-CCw83gUi.js → ProjectView-NwfC3_w7.js} +3 -3
  48. package/packages/web/dist/assets/{ProjectsGrid-B3Klg2H4.js → ProjectsGrid-BeORa0UH.js} +1 -1
  49. package/packages/web/dist/assets/{RootHome-BxcDvpgI.js → RootHome-BYJs8qJ6.js} +1 -1
  50. package/packages/web/dist/assets/{Surfaces-7UlhFBSG.js → Surfaces-w-kEaAYU.js} +1 -1
  51. package/packages/web/dist/assets/{arc-DNkh5o5Q.js → arc-Bv56hasM.js} +1 -1
  52. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BrgN3oLF.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
  53. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-D05XOJSk.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
  54. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CKaMBhMP.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
  55. package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
  56. package/packages/web/dist/assets/{chunk-2J33WTMH-8hgadCQc.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
  57. package/packages/web/dist/assets/{chunk-4BX2VUAB-CBfEC-pu.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
  58. package/packages/web/dist/assets/{chunk-55IACEB6-DV1eMgHk.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
  59. package/packages/web/dist/assets/{chunk-727SXJPM-Cfgl5fxY.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
  60. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BXhqD295.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
  61. package/packages/web/dist/assets/{chunk-FMBD7UC4-TFGOPtek.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
  62. package/packages/web/dist/assets/{chunk-ND2GUHAM-CHFM665R.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
  63. package/packages/web/dist/assets/{chunk-QZHKN3VN-CqEf6MMT.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
  64. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
  65. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
  66. package/packages/web/dist/assets/{core-DmEaKxBb.js → core-CYuJAM2U.js} +1 -1
  67. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BnGI6jkr.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
  68. package/packages/web/dist/assets/{dagre-BM42HDAG-CKoPx61E.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
  69. package/packages/web/dist/assets/{diagram-2AECGRRQ-mMmtlBl1.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
  70. package/packages/web/dist/assets/{diagram-5GNKFQAL-Cdz9Mtnt.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
  71. package/packages/web/dist/assets/{diagram-KO2AKTUF-DQxM3wYj.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
  72. package/packages/web/dist/assets/{diagram-LMA3HP47-BWrinPsq.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
  73. package/packages/web/dist/assets/{diagram-OG6HWLK6-U6MYUve2.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
  74. package/packages/web/dist/assets/{documentTitle-DGjRXuTP.js → documentTitle-M1lrfXRU.js} +1 -1
  75. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-xqMCsXxY.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
  76. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-PMfy_czF.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
  77. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-whhYYE4N.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
  78. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-C51JW4tf.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
  79. package/packages/web/dist/assets/{index-CHOtvlnO.js → index-1E9yHciS.js} +2 -2
  80. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-SH8cr905.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
  81. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BjqaoU3U.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
  82. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CBmdEVrd.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
  83. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-U8njjMLZ.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
  84. package/packages/web/dist/assets/{linear-DF66GPs1.js → linear-D3U8NOkt.js} +1 -1
  85. package/packages/web/dist/assets/{mermaid.core-CouJz7Ph.js → mermaid.core-BiArdWKV.js} +4 -4
  86. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BYIf0oFt.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
  87. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Cm8rgg09.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
  88. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-C3Il5kOH.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
  89. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D3PE2nTe.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
  90. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DdmvBM18.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
  91. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-B8M_GAIq.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
  92. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DZkXnK1_.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
  94. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-IHXq7rqi.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
  95. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dga7tLtT.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
  96. package/packages/web/dist/assets/{wardley-L42UT6IY-CmcwnEJ-.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
  97. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-zHuSLrD4.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
  98. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D93MU_ic.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
  99. package/packages/web/dist/index.html +1 -1
  100. package/packages/web/dist/sw.js +1 -1
  101. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  102. package/packages/server/dist/cli/service/backend.js +0 -19
  103. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +0 -2
  104. package/packages/web/dist/assets/channel-JrEedxB8.js +0 -2
  105. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +0 -2
  106. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +0 -2
  107. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +0 -2
@@ -1,98 +1,53 @@
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, "&amp;")
47
- .replace(/</g, "&lt;")
48
- .replace(/>/g, "&gt;")
49
- .replace(/"/g, "&quot;");
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
29
+ const xmlUnescape = (s) => s.replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
51
30
  /**
52
- * Render the plist.
53
- *
54
- * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
55
- * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
56
- * nonzero, leave it alone if it exits cleanly" — which reads like the polite
57
- * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
58
- * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
59
- * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
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 argv = [spec.nodePath, spec.scriptPath, ...spec.args];
89
- const strings = argv.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
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(LAUNCHD_LABEL)}</string>
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
- <key>PATH</key>
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
- if (block === null)
134
- return [];
135
- return [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => m[1]
136
- .replace(/&quot;/g, '"')
137
- .replace(/&gt;/g, ">")
138
- .replace(/&lt;/g, "<")
139
- .replace(/&amp;/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 file an installed plist appends to, read back from `StandardOutPath`. */
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
- if (m === null)
158
- return undefined;
159
- return m[1]
160
- .replace(/&quot;/g, '"')
161
- .replace(/&gt;/g, ">")
162
- .replace(/&lt;/g, "<")
163
- .replace(/&amp;/g, "&");
83
+ return m === null ? undefined : xmlUnescape(m[1]);
164
84
  }
165
- /**
166
- * Read liveness out of `launchctl print gui/<uid>/<label>`.
167
- *
168
- * That command is the honest answer to "is it running": it reports the service
169
- * as launchd currently understands it, including `state = running` and the pid.
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 (`launchctl ${verb} failed (exit ${String(r.status)}).\n` + `${r.stderr || r.stdout}`.trim());
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 function createLaunchdBackend(run, homeDir = os.homedir(), sleep = sleepSync) {
256
- const file = plistPath(homeDir);
257
- /**
258
- * Boot the job out and wait until launchd agrees it has gone — see the race
259
- * note above. The bootout's own result is deliberately not inspected: "there
260
- * was no previous service" is the expected case on a first install, and a
261
- * bootout that genuinely failed shows up as a job that is still there, which
262
- * is what the poll below is looking at anyway.
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("launchctl", ["bootout", target()]);
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
- // A non-zero `print` is launchd saying it has never heard of the label,
270
- // which is precisely the state `bootstrap` needs.
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
- /** Bootstrap the plist, retrying the race codes. Throws with launchctl's own words. */
278
- const bootstrapOrThrow = () => {
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
- // NOT `load`/`unload`: those are the legacy API, and on a modern macOS
281
- // they are shims over this one that report success in cases where it
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(boot.status ?? -1)) {
287
- throw new Error(launchctlFailure("bootstrap", boot));
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: "darwin",
133
+ platform: 'darwin',
294
134
  unitPath: file,
295
- label: LAUNCHD_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 creates neither the log file's directory nor, reliably, a log
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), "utf8");
303
- // `bootstrap` fails outright if the label is already loaded, so a
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
- bootstrapOrThrow();
310
- // `RunAtLoad` should already have started it; `kickstart -k` makes that
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
- run("launchctl", ["bootout", target()]);
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
- bootstrapOrThrow();
335
- run("launchctl", ["kickstart", target()]);
157
+ bootstrap();
158
+ run('launchctl', ['kickstart', target]);
336
159
  },
337
160
  /**
338
- * `bootout`, not `launchctl stop`.
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 out = run("launchctl", ["bootout", target()]);
351
- // Exit 3 / "No such process" is launchd for "it was not loaded", which is
352
- // the state the caller asked for. Anything else is worth surfacing.
353
- if (out.status !== 0 && !/no such process/i.test(`${out.stderr}${out.stdout}`)) {
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
- bootstrapOrThrow();
370
- run("launchctl", ["kickstart", "-k", target()]);
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, "utf8");
179
+ const xml = fs.readFileSync(file, 'utf8');
376
180
  const argv = parsePlistArgv(xml);
377
- const printed = run("launchctl", ["print", target()]);
378
- const parsed = printed.status === 0
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: parsed.running,
385
- ...(parsed.pid !== undefined ? { pid: parsed.pid } : {}),
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
- /** launchd has no linger: a LaunchAgent's lifetime IS the login session. */
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 otherwise (SPEC §1). */
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 reading from — normally the file's size after the tail. */
6
+ /** Where to start: the file's size after the tail. */
15
7
  from: number;
16
- /** Injected for tests. */
17
8
  intervalMs?: number;
18
- /** Injected for tests: return false to stop the loop. */
9
+ /** Return false to stop. */
19
10
  keepGoing?: () => boolean;
20
11
  write?: (s: string) => void;
21
12
  }
22
13
  /**
23
- * Print what gets appended to `file`, from `from` onwards, until the process is
24
- * interrupted.
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, opts: FollowOptions): Promise<void>;
38
- /** The file's size, or 0 if it is not there — where a follow should start. */
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;