@hwip/cli 0.0.1 → 0.1.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 (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,682 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serviceHealthChecks = exports.systemdServiceProbe = exports.defaultServiceProbe = exports.restartService = exports.stopService = exports.uninstallService = exports.installService = exports.defaultSystemdOpDeps = exports.defaultServiceOpDeps = exports.renderSystemdUnit = exports.serviceStatus = exports.renderLaunchAgentPlist = exports.resolveServiceSpec = exports.defaultServiceEnv = exports.serviceInstalled = exports.serviceUnitPath = exports.servicePlistPath = exports.serviceSupported = exports.serviceManager = exports.SERVICE_UNIT = exports.SERVICE_LABEL = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_os_1 = require("node:os");
7
+ const node_path_1 = require("node:path");
8
+ const private_files_js_1 = require("./private-files.js");
9
+ const listener_process_js_1 = require("./listener-process.js");
10
+ const node_support_js_1 = require("./node-support.js");
11
+ /**
12
+ * The listener as an OS service: a launchd LaunchAgent on macOS, a systemd user
13
+ * unit on Linux, that starts it at user login so the phone's picker is populated
14
+ * without anyone opening a terminal.
15
+ *
16
+ * Until this existed, the only thing that ever started the daemon was
17
+ * `hwip cc` (wrapper.ts → startListenerDetached). After a reboot that means an
18
+ * empty picker until the user happens to launch an agent — the machine is
19
+ * online, the past sessions are on disk, and the app says "no agents".
20
+ *
21
+ * Two constraints shape this file:
22
+ *
23
+ * 1. **Node builtins only.** wrapper.ts reads `serviceInstalled()` on the
24
+ * `hwip cc` hot path, and the same rule that keeps listener.ts out of
25
+ * wrapper.ts (ws + the crypto stack on every agent launch) applies here.
26
+ * listener-process.ts is the one import, and it is builtins-only too.
27
+ *
28
+ * 2. **Everything the job needs is resolved at install time and baked into
29
+ * the plist.** launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` and
30
+ * nothing else — no shell profile, no homebrew prefix. `verifyTmux()`
31
+ * (listener.ts) exits 127 when tmux is not on PATH, so a plist without a
32
+ * baked PATH produces a service that dies at every login and is restarted
33
+ * forever by KeepAlive. The interpreter is baked for the same reason.
34
+ */
35
+ /** Reverse-DNS launchd label. Lives on the user's machine once installed —
36
+ * renaming it orphans every plist already out there. */
37
+ exports.SERVICE_LABEL = 'app.hwip.listener';
38
+ /** The systemd user unit: the same label, so every message that names
39
+ * SERVICE_LABEL names this unit too. */
40
+ exports.SERVICE_UNIT = `${exports.SERVICE_LABEL}.service`;
41
+ /** What launchd itself puts on PATH. Ours is prepended, never replaces this. */
42
+ const LAUNCHD_BASE_PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'];
43
+ /** Interpreters to try when the CLI's own install prefix has none. */
44
+ const NODE_FALLBACKS = ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
45
+ /**
46
+ * Locale to fall back on when the shell has none, or has a non-UTF-8 one.
47
+ *
48
+ * launchd hands a job no LANG at all. tmux then runs in the C locale and
49
+ * escapes every non-ASCII byte of its format output to `_` — including the
50
+ * U+241F separator the session inventory is split on. The daemon starts
51
+ * clean, tmux answers, and every session is discarded as unparseable: the
52
+ * phone shows nothing while `launchctl list` and the log both look healthy.
53
+ */
54
+ const FALLBACK_LANG = 'en_US.UTF-8';
55
+ /** tmux locations to try when the shell can't answer `command -v`. */
56
+ const TMUX_FALLBACKS = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'];
57
+ /** Version-manager roots. A node under one of these is gone after the next
58
+ * upgrade, which silently breaks the service months later. */
59
+ const VERSION_MANAGER_DIRS = ['/.nvm/', '/.fnm/', '/.volta/', '/.nodenv/', '/.asdf/', '/.n/'];
60
+ /**
61
+ * Which service manager can run the listener here. macOS always has launchd.
62
+ * Linux has a systemd user manager only when one answers: a container, or a
63
+ * shell without a login session, may have none.
64
+ */
65
+ const serviceManager = (platform = process.platform,
66
+ // Tests set HWIP_TEST_NO_SERVICE_MANAGER so a detection never reaches the
67
+ // real host — which may itself run a systemd user manager.
68
+ userManagerAnswers = () => process.env.HWIP_TEST_NO_SERVICE_MANAGER !== '1' && runSystemctl(['show-environment']).ok) => {
69
+ if (platform === 'darwin')
70
+ return 'launchd';
71
+ return platform === 'linux' && userManagerAnswers() ? 'systemd' : null;
72
+ };
73
+ exports.serviceManager = serviceManager;
74
+ /** launchd on macOS, a systemd user manager on Linux. Every other platform is
75
+ * an explicit refusal, never a quiet success. */
76
+ const serviceSupported = () => (0, exports.serviceManager)() !== null;
77
+ exports.serviceSupported = serviceSupported;
78
+ const servicePlistPath = () => (0, node_path_1.join)((0, node_os_1.homedir)(), 'Library', 'LaunchAgents', `${exports.SERVICE_LABEL}.plist`);
79
+ exports.servicePlistPath = servicePlistPath;
80
+ /** Where systemd's user manager reads a user's own units: `$XDG_CONFIG_HOME/systemd/user`,
81
+ * else `~/.config/systemd/user` (systemd.unit(5), user unit search path). */
82
+ const serviceUnitPath = () => {
83
+ const config = process.env.XDG_CONFIG_HOME;
84
+ return (0, node_path_1.join)(config && (0, node_path_1.isAbsolute)(config) ? config : (0, node_path_1.join)((0, node_os_1.homedir)(), '.config'), 'systemd', 'user', exports.SERVICE_UNIT);
85
+ };
86
+ exports.serviceUnitPath = serviceUnitPath;
87
+ /** Read on the `hwip cc` hot path, so it goes by platform and checks a file —
88
+ * it never runs a program to ask whether systemd is there. */
89
+ const serviceInstalled = () => (0, node_fs_1.existsSync)(process.platform === 'linux' ? (0, exports.serviceUnitPath)() : (0, exports.servicePlistPath)());
90
+ exports.serviceInstalled = serviceInstalled;
91
+ /** This user's launchd GUI domain, and our job within it. Not exported: every
92
+ * launchctl call lives in this file, and the label is an implementation
93
+ * detail of that. */
94
+ const serviceDomain = () => `gui/${process.getuid?.() ?? 0}`;
95
+ const serviceTarget = () => `${serviceDomain()}/${exports.SERVICE_LABEL}`;
96
+ const nodeVersionOf = (path) => {
97
+ try {
98
+ return (0, node_child_process_1.execFileSync)(path, ['-v'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ };
104
+ const whichTmuxFromShell = () => {
105
+ try {
106
+ // `/bin/sh -c` on purpose: `which` in an interactive zsh resolves the
107
+ // user's tmux *alias*, which is not a path we can hand to launchd.
108
+ const out = (0, node_child_process_1.execFileSync)('/bin/sh', ['-c', 'command -v tmux'], {
109
+ encoding: 'utf-8',
110
+ stdio: ['ignore', 'pipe', 'ignore'],
111
+ }).trim();
112
+ return out.startsWith('/') ? out : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ };
118
+ exports.defaultServiceEnv = {
119
+ exists: node_fs_1.existsSync,
120
+ nodeVersion: nodeVersionOf,
121
+ whichTmux: whichTmuxFromShell,
122
+ cliPath: listener_process_js_1.resolveCliPath,
123
+ lang: () => process.env.LANG,
124
+ };
125
+ /**
126
+ * The interpreter that owns the CLI's install prefix.
127
+ *
128
+ * A global install lives at `<prefix>/lib/node_modules/@hwip/cli/dist/cli.js`
129
+ * and its node is `<prefix>/bin/node`. Preferring it keeps the plist internally
130
+ * consistent; picking whichever node happens to exist can pair a homebrew
131
+ * interpreter with a `~/.local` install — a combination that works on the
132
+ * machine that wrote it and breaks on the next one.
133
+ */
134
+ const prefixNodeFor = (cliPath) => {
135
+ const marker = '/lib/node_modules/';
136
+ const at = cliPath.indexOf(marker);
137
+ if (at < 0)
138
+ return null;
139
+ return (0, node_path_1.join)(cliPath.slice(0, at), 'bin', 'node');
140
+ };
141
+ const isVersionManaged = (nodePath) => VERSION_MANAGER_DIRS.some((dir) => nodePath.includes(dir));
142
+ const dedupe = (values) => [...new Set(values)];
143
+ /**
144
+ * Decide what to bake into the plist, or say why we can't. Nothing is written
145
+ * and nothing is registered — a caller that gets `ok: false` has touched
146
+ * nothing on the machine.
147
+ */
148
+ const resolveServiceSpec = (env = exports.defaultServiceEnv) => {
149
+ const cliPath = env.cliPath();
150
+ if (!cliPath) {
151
+ return { ok: false, reason: 'could not resolve the hwip CLI entry (dist/cli.js) to run' };
152
+ }
153
+ const tmuxPath = env.whichTmux() ?? TMUX_FALLBACKS.find(env.exists) ?? null;
154
+ if (!tmuxPath) {
155
+ return { ok: false, reason: 'tmux not found — install tmux first (the listener drives tmux panes)' };
156
+ }
157
+ const prefixNode = prefixNodeFor(cliPath);
158
+ const candidates = dedupe([...(prefixNode ? [prefixNode] : []), ...NODE_FALLBACKS, process.execPath]);
159
+ // A node outside `engines` starts and dies, so it is not a candidate.
160
+ const nodePath = candidates.find((c) => env.exists(c) && (0, node_support_js_1.isSupportedNode)(env.nodeVersion(c) ?? ''));
161
+ if (!nodePath) {
162
+ return { ok: false, reason: `no node found to run the listener with (needs ${node_support_js_1.SUPPORTED_NODE})` };
163
+ }
164
+ const pathEnv = dedupe([(0, node_path_1.dirname)(tmuxPath), (0, node_path_1.dirname)(nodePath), ...LAUNCHD_BASE_PATH]).join(':');
165
+ const shellLang = env.lang();
166
+ const lang = shellLang && /utf-?8$/i.test(shellLang) ? shellLang : FALLBACK_LANG;
167
+ const spec = { nodePath, cliPath, tmuxPath, logPath: listener_process_js_1.LISTENER_LOG_FILE, pathEnv, lang };
168
+ if (isVersionManaged(nodePath)) {
169
+ return {
170
+ ok: true,
171
+ value: spec,
172
+ warning: `${nodePath} belongs to a node version manager — the service will stop working ` +
173
+ `after a node upgrade. Re-run \`hwip listener --install-service\` if that happens ` +
174
+ `(\`hwip verify\` reports it).`,
175
+ };
176
+ }
177
+ return { ok: true, value: spec };
178
+ };
179
+ exports.resolveServiceSpec = resolveServiceSpec;
180
+ const escapeXml = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
181
+ const renderLaunchAgentPlist = (spec) => {
182
+ const s = (value) => `<string>${escapeXml(value)}</string>`;
183
+ return `<?xml version="1.0" encoding="UTF-8"?>
184
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
185
+ <plist version="1.0">
186
+ <dict>
187
+ <key>Label</key>
188
+ ${s(exports.SERVICE_LABEL)}
189
+ <key>ProgramArguments</key>
190
+ <array>
191
+ ${s(spec.nodePath)}
192
+ ${s(spec.cliPath)}
193
+ <string>listener</string>
194
+ </array>
195
+ <key>RunAtLoad</key>
196
+ <true/>
197
+ <key>KeepAlive</key>
198
+ <dict>
199
+ <key>SuccessfulExit</key>
200
+ <false/>
201
+ </dict>
202
+ <key>ThrottleInterval</key>
203
+ <integer>30</integer>
204
+ <key>StandardOutPath</key>
205
+ ${s(spec.logPath)}
206
+ <key>StandardErrorPath</key>
207
+ ${s(spec.logPath)}
208
+ <key>EnvironmentVariables</key>
209
+ <dict>
210
+ <key>PATH</key>
211
+ ${s(spec.pathEnv)}
212
+ <key>LANG</key>
213
+ ${s(spec.lang)}
214
+ <key>HWIP_LISTENER_SERVICE</key>
215
+ <string>1</string>
216
+ </dict>
217
+ </dict>
218
+ </plist>
219
+ `;
220
+ };
221
+ exports.renderLaunchAgentPlist = renderLaunchAgentPlist;
222
+ const unescapeXml = (value) => value.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
223
+ /** ProgramArguments strings, in order, from an installed plist. */
224
+ const readProgramArguments = (xml) => {
225
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml);
226
+ if (!block)
227
+ return [];
228
+ return [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
229
+ };
230
+ const readEnvValue = (xml, key) => {
231
+ const found = new RegExp(`<key>${key}</key>\\s*<string>([\\s\\S]*?)</string>`).exec(xml);
232
+ return found ? unescapeXml(found[1]) : null;
233
+ };
234
+ const NOT_INSTALLED = {
235
+ installed: false,
236
+ nodePath: null,
237
+ cliPath: null,
238
+ pathEnv: null,
239
+ langEnv: null,
240
+ missing: [],
241
+ };
242
+ /**
243
+ * What is registered on this machine and whether it can still run. The single
244
+ * source `verify`, `--service-status` and the `hwip cc` branch all read, so
245
+ * they can never disagree about whether a service is installed.
246
+ */
247
+ const serviceStatus = (manager = (0, exports.serviceManager)()) => {
248
+ // A unit file on disk outvotes a user bus that would not answer (a shell
249
+ // with no login session): installed, only unmanageable from here — never
250
+ // "macOS-only".
251
+ if (manager === null && (0, node_fs_1.existsSync)((0, exports.serviceUnitPath)()))
252
+ manager = 'systemd';
253
+ if (manager === 'systemd')
254
+ return unitStatus();
255
+ const plistPath = (0, exports.servicePlistPath)();
256
+ const base = { manager: 'launchd', supported: manager === 'launchd', label: exports.SERVICE_LABEL, plistPath };
257
+ if (!(0, node_fs_1.existsSync)(plistPath))
258
+ return { ...base, ...NOT_INSTALLED };
259
+ let xml = '';
260
+ try {
261
+ xml = (0, node_fs_1.readFileSync)(plistPath, 'utf-8');
262
+ }
263
+ catch {
264
+ // The plist is there but unreadable — installed, nothing knowable.
265
+ return { ...base, ...NOT_INSTALLED, installed: true };
266
+ }
267
+ const [nodePath = null, cliPath = null] = readProgramArguments(xml);
268
+ const missing = [nodePath, cliPath].filter((p) => !!p && !(0, node_fs_1.existsSync)(p));
269
+ return {
270
+ ...base,
271
+ installed: true,
272
+ nodePath,
273
+ cliPath,
274
+ pathEnv: readEnvValue(xml, 'PATH'),
275
+ langEnv: readEnvValue(xml, 'LANG'),
276
+ missing,
277
+ };
278
+ };
279
+ exports.serviceStatus = serviceStatus;
280
+ // ─── systemd unit ────────────────────────────────────────────────────
281
+ /** A quoted unit value: systemd’s C-style escapes (systemd.syntax(7), Quoting),
282
+ * and `%` doubled so no specifier expands (systemd.unit(5), Specifiers).
283
+ * Control characters are stripped first — a newline would start another unit
284
+ * line, and nothing holding one belongs in a value. */
285
+ const quoteUnit = (value) => `"${value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/[\\"]/g, '\\$&').replace(/%/g, '%%')}"`;
286
+ /** A bare unit word (a log path after `append:`): specifiers doubled and
287
+ * control characters stripped, like quoteUnit. */
288
+ const plainUnitValue = (value) => value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/%/g, '%%');
289
+ /** An ExecStart= word: `$` doubled too, which ExecStart= would otherwise expand
290
+ * as a variable (systemd.service(5)). */
291
+ const execWord = (value) => quoteUnit(value.replace(/\$/g, () => '$$'));
292
+ const unquoteUnit = (quoted) => quoted.replace(/%%/g, '%').replace(/\\(.)/g, '$1');
293
+ /**
294
+ * The unit holds what the plist holds, for the same reasons (see the top of
295
+ * this file): absolute node and CLI paths, a PATH that reaches tmux — the user
296
+ * manager's own PATH is whatever the distribution configured (systemd.exec(5),
297
+ * $PATH) — and a UTF-8 LANG.
298
+ *
299
+ * - `Restart=on-failure` + `RestartSec=30` is `KeepAlive:{SuccessfulExit:false}`
300
+ * + `ThrottleInterval 30`: a crash comes back, a clean exit stays down
301
+ * (systemd.service(5), Restart=).
302
+ * - `KillMode=process`: stop and restart kill the main process only. The
303
+ * default (`control-group`) would kill everything else left in the unit's
304
+ * cgroup — including the tmux server the listener started under this unit,
305
+ * and with it every agent session (launchd's detach spares it).
306
+ * systemd.kill(5) marks process "not recommended!", since it "allows
307
+ * processes to escape the service manager's lifecycle and resource
308
+ * management" — that escape is the point here: the tmux server is shared
309
+ * state, not a child of this service.
310
+ * - `append:` keeps the daemon writing ~/.hwip/listener.log, which `hwip verify`
311
+ * and the log rotation know, instead of the journal (systemd.exec(5)).
312
+ * - `WantedBy=default.target`: the user manager's main target, started with it
313
+ * at login (systemd.special(7), Special User Units).
314
+ */
315
+ const renderSystemdUnit = (spec) => `[Unit]
316
+ Description=hwip listener (phone input to agent sessions)
317
+
318
+ [Service]
319
+ ExecStart=${execWord(spec.nodePath)} ${execWord(spec.cliPath)} listener
320
+ Restart=on-failure
321
+ RestartSec=30
322
+ KillMode=process
323
+ Environment=${quoteUnit(`PATH=${spec.pathEnv}`)}
324
+ Environment=${quoteUnit(`LANG=${spec.lang}`)}
325
+ Environment=HWIP_LISTENER_SERVICE=1
326
+ StandardOutput=append:${plainUnitValue(spec.logPath)}
327
+ StandardError=append:${plainUnitValue(spec.logPath)}
328
+
329
+ [Install]
330
+ WantedBy=default.target
331
+ `;
332
+ exports.renderSystemdUnit = renderSystemdUnit;
333
+ /** ExecStart= words, in order, from an installed unit. */
334
+ const readExecStart = (unit) => {
335
+ const line = /^ExecStart=(.*)$/m.exec(unit);
336
+ if (!line)
337
+ return [];
338
+ return [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => unquoteUnit(m[1]).replace(/\$\$/g, () => '$'));
339
+ };
340
+ const readUnitEnv = (unit, name) => {
341
+ const found = new RegExp(`^Environment="${name}=((?:[^"\\\\]|\\\\.)*)"$`, 'm').exec(unit);
342
+ return found ? unquoteUnit(found[1]) : null;
343
+ };
344
+ const unitStatus = () => {
345
+ const unitPath = (0, exports.serviceUnitPath)();
346
+ const base = { manager: 'systemd', supported: true, label: exports.SERVICE_UNIT, plistPath: unitPath };
347
+ if (!(0, node_fs_1.existsSync)(unitPath))
348
+ return { ...base, ...NOT_INSTALLED };
349
+ let unit = '';
350
+ try {
351
+ unit = (0, node_fs_1.readFileSync)(unitPath, 'utf-8');
352
+ }
353
+ catch {
354
+ return { ...base, ...NOT_INSTALLED, installed: true };
355
+ }
356
+ const [nodePath = null, cliPath = null] = readExecStart(unit);
357
+ const missing = [nodePath, cliPath].filter((p) => !!p && !(0, node_fs_1.existsSync)(p));
358
+ return {
359
+ ...base,
360
+ installed: true,
361
+ nodePath,
362
+ cliPath,
363
+ pathEnv: readUnitEnv(unit, 'PATH'),
364
+ langEnv: readUnitEnv(unit, 'LANG'),
365
+ missing,
366
+ };
367
+ };
368
+ /** How long to give launchd to get the daemon up before calling the install
369
+ * a failure. Generous: the daemon writes its pid file as its first act. */
370
+ const POST_INSTALL_SETTLE_MS = 3_000;
371
+ /** How often to look within that window. A fixed 3 s wait made every
372
+ * `hwip install` 3 s slower; the daemon is usually up in well under one. */
373
+ const POST_INSTALL_POLL_MS = 250;
374
+ /** How long one pid must stay up to count as started. */
375
+ const POST_INSTALL_STABLE_MS = 1_000;
376
+ /** A wedged user manager must not hang the CLI: every systemctl call — the
377
+ * systemd detection included — gets five seconds. launchctl stays unbounded,
378
+ * exactly as on main: execFileSync kills a timed-out child with SIGTERM and
379
+ * empty stderr, so capping launchctl would turn a macOS install into a
380
+ * silent "failed: no detail" where it used to wait (and usually succeed). */
381
+ const SYSTEMCTL_TIMEOUT_MS = 5_000;
382
+ const runQuiet = (program, args, timeoutMs) => {
383
+ try {
384
+ (0, node_child_process_1.execFileSync)(program, args, { stdio: ['ignore', 'ignore', 'pipe'], ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }) });
385
+ return { ok: true, stderr: '' };
386
+ }
387
+ catch (err) {
388
+ const stderr = err.stderr?.toString().trim() ?? '';
389
+ if (timeoutMs !== undefined && err.signal === 'SIGTERM') {
390
+ const why = 'timed out after ' + timeoutMs / 1000 + 's';
391
+ return { ok: false, stderr: stderr ? stderr + ' (' + why + ')' : why };
392
+ }
393
+ return { ok: false, stderr };
394
+ }
395
+ };
396
+ const runLaunchctl = (args) => runQuiet('launchctl', args);
397
+ const runSystemctl = (args) => runQuiet('systemctl', ['--user', ...args], SYSTEMCTL_TIMEOUT_MS);
398
+ exports.defaultServiceOpDeps = {
399
+ supported: exports.serviceSupported,
400
+ resolveSpec: () => (0, exports.resolveServiceSpec)(),
401
+ runningPid: listener_process_js_1.runningListenerPid,
402
+ stopListener: (pid) => (0, listener_process_js_1.stopListener)(pid),
403
+ launchctl: runLaunchctl,
404
+ settle: listener_process_js_1.sleep,
405
+ };
406
+ exports.defaultSystemdOpDeps = {
407
+ supported: exports.serviceSupported,
408
+ resolveSpec: () => (0, exports.resolveServiceSpec)(),
409
+ runningPid: listener_process_js_1.runningListenerPid,
410
+ stopListener: (pid) => (0, listener_process_js_1.stopListener)(pid),
411
+ systemctl: runSystemctl,
412
+ settle: listener_process_js_1.sleep,
413
+ };
414
+ const isSystemd = (deps) => 'systemctl' in deps;
415
+ /** Linux asks systemd, everything else launchd (whose `supported` refuses where there is none).
416
+ * Goes by platform alone: `hwip cc` reaches restartService through this. */
417
+ const defaultOpDeps = () => (process.platform === 'linux' ? exports.defaultSystemdOpDeps : exports.defaultServiceOpDeps);
418
+ const UNSUPPORTED = 'the login-time service needs launchd (macOS) or a systemd user manager (Linux), and this machine has neither ' +
419
+ '(run `hwip listener` yourself, or keep using `hwip cc` to autostart it)';
420
+ /** Lingering is a system setting, so this only says how (loginctl(1), enable-linger: a lingering user's
421
+ * manager is spawned at boot and kept around after logouts). */
422
+ const LINGER_NOTE = 'systemd runs it while you are logged in: without lingering your user manager starts at login and stops after '
423
+ + 'your last logout. On a machine nobody logs in to, keep it running with: sudo loginctl enable-linger $USER';
424
+ /** What the install says about a listener it found already running. */
425
+ const stopRunning = async (deps, notes, manager) => {
426
+ const running = deps.runningPid();
427
+ if (running === null)
428
+ return;
429
+ const stopped = await deps.stopListener(running);
430
+ if (stopped.kind === 'stopped' || stopped.kind === 'killed') {
431
+ notes.push(`stopped the listener already running (pid ${running}) so ${manager} can own it`);
432
+ }
433
+ else if (stopped.kind === 'other-listener') {
434
+ notes.push((0, listener_process_js_1.otherListenerNote)(running, stopped.script));
435
+ }
436
+ else if (stopped.kind === 'foreign') {
437
+ notes.push(`left pid ${running} alone: the listener's pid file points at another process (${stopped.program})`);
438
+ }
439
+ else if (stopped.kind === 'unchecked') {
440
+ notes.push(`could not check pid ${running} (${stopped.reason}) — if it is the listener, kill ${running} and run: hwip listener --restart`);
441
+ }
442
+ };
443
+ /** launchd creates the log it is pointed at under the umask, and systemd's `append:` opens it the same way:
444
+ * make it before, and private. */
445
+ const prepareLog = () => {
446
+ (0, private_files_js_1.ensurePrivateDir)((0, node_path_1.dirname)(listener_process_js_1.LISTENER_LOG_FILE));
447
+ (0, private_files_js_1.ensurePrivateFile)(listener_process_js_1.LISTENER_LOG_FILE);
448
+ };
449
+ const writeServiceFile = (path, content, notes) => {
450
+ (0, private_files_js_1.mkdirHandedBack)((0, node_path_1.dirname)(path));
451
+ (0, node_fs_1.writeFileSync)(path, content);
452
+ (0, private_files_js_1.handBackToSudoUser)(path);
453
+ notes.push(`wrote ${path}`);
454
+ };
455
+ /** A failure naming the step the service manager refused, or null. */
456
+ const registerLaunchAgent = (deps, spec, notes) => {
457
+ // Nothing to unload before a first install — that failure is the normal path.
458
+ deps.launchctl(['bootout', serviceTarget()]);
459
+ prepareLog();
460
+ const plistPath = (0, exports.servicePlistPath)();
461
+ writeServiceFile(plistPath, (0, exports.renderLaunchAgentPlist)(spec), notes);
462
+ const bootstrap = deps.launchctl(['bootstrap', serviceDomain(), plistPath]);
463
+ if (!bootstrap.ok) {
464
+ return { ok: false, reason: `launchctl bootstrap failed: ${bootstrap.stderr || 'no detail'}`, notes };
465
+ }
466
+ // Best-effort: a job disabled by an earlier `launchctl disable` (ours never
467
+ // calls it, but a user's hand might have) would otherwise refuse to start.
468
+ deps.launchctl(['enable', serviceTarget()]);
469
+ const kickstart = deps.launchctl(['kickstart', '-k', serviceTarget()]);
470
+ if (!kickstart.ok) {
471
+ return { ok: false, reason: `launchctl kickstart failed: ${kickstart.stderr || 'no detail'}`, notes };
472
+ }
473
+ return null;
474
+ };
475
+ /**
476
+ * The systemd steps, each of which must succeed:
477
+ * - `daemon-reload`: the manager reads unit files on a reload, so a reinstall over
478
+ * a loaded unit would otherwise run the old one (systemd.unit(5)).
479
+ * - `enable`: links the unit into `default.target.wants/` per its [Install]
480
+ * section — what starts it at login (systemctl(1), enable).
481
+ * - `restart`: stops it if running, then starts it — `kickstart -k` (systemctl(1)).
482
+ */
483
+ const registerUnit = (deps, spec, notes) => {
484
+ prepareLog();
485
+ writeServiceFile((0, exports.serviceUnitPath)(), (0, exports.renderSystemdUnit)(spec), notes);
486
+ for (const args of [['daemon-reload'], ['enable', exports.SERVICE_UNIT], ['restart', exports.SERVICE_UNIT]]) {
487
+ const result = deps.systemctl(args);
488
+ if (!result.ok) {
489
+ return { ok: false, reason: `systemctl --user ${args[0]} failed: ${result.stderr || 'no detail'}`, notes };
490
+ }
491
+ }
492
+ notes.push(LINGER_NOTE);
493
+ return null;
494
+ };
495
+ /**
496
+ * Register the service and get the daemon running under it.
497
+ *
498
+ * The order is load-bearing, not incidental:
499
+ *
500
+ * - **Any listener already running is stopped first.** `handleListener` exits
501
+ * **0** when it finds another listener alive, and it only does so quietly
502
+ * under HWIP_LISTENER_AUTOSTART, which a service does not have. So a
503
+ * kickstart into a live `hwip cc` daemon produces a clean exit, and
504
+ * `KeepAlive:{SuccessfulExit:false}` (systemd: `Restart=on-failure`) reads a
505
+ * clean exit as "stay down" — for the whole login session. The manager lists
506
+ * the job; nothing runs.
507
+ * - **Nothing is written until the spec resolves.** A refusal leaves the
508
+ * machine exactly as it was.
509
+ * - **The daemon is confirmed alive afterwards.** Without that check the
510
+ * failure above ships silently.
511
+ *
512
+ * The deps say which manager: launchctl for launchd, systemctl for systemd.
513
+ */
514
+ const installService = async (deps = defaultOpDeps()) => {
515
+ const notes = [];
516
+ if (!deps.supported())
517
+ return { ok: false, reason: UNSUPPORTED, notes };
518
+ const spec = deps.resolveSpec();
519
+ if (!spec.ok)
520
+ return { ok: false, reason: spec.reason, notes };
521
+ if (spec.warning)
522
+ notes.push(spec.warning);
523
+ await stopRunning(deps, notes, isSystemd(deps) ? 'systemd' : 'launchd');
524
+ const refused = isSystemd(deps) ? registerUnit(deps, spec.value, notes) : registerLaunchAgent(deps, spec.value, notes);
525
+ if (refused)
526
+ return refused;
527
+ // Up = the same pid alive for a full second: a daemon that writes its pid
528
+ // file and then dies on a bad key or a bind failure does so within that.
529
+ let last = null;
530
+ let since = 0;
531
+ for (let waited = POST_INSTALL_POLL_MS; waited <= POST_INSTALL_SETTLE_MS; waited += POST_INSTALL_POLL_MS) {
532
+ await deps.settle(POST_INSTALL_POLL_MS);
533
+ const pid = deps.runningPid();
534
+ if (pid === null || pid !== last)
535
+ since = waited;
536
+ else if (waited - since >= POST_INSTALL_STABLE_MS)
537
+ return { ok: true, notes };
538
+ last = pid;
539
+ }
540
+ return {
541
+ ok: false,
542
+ reason: `the service was registered but the listener did not stay up — see ${listener_process_js_1.LISTENER_LOG_FILE}`,
543
+ notes,
544
+ };
545
+ };
546
+ exports.installService = installService;
547
+ /** Unregister the job and remove its file. Doing only one of the two leaves
548
+ * either a running service with no file or a file the manager ignores. */
549
+ const uninstallService = async (deps = defaultOpDeps()) => {
550
+ const notes = [];
551
+ const path = isSystemd(deps) ? (0, exports.serviceUnitPath)() : (0, exports.servicePlistPath)();
552
+ if (!(0, node_fs_1.existsSync)(path))
553
+ return { ok: true, notes: ['service not installed — nothing to remove'] };
554
+ if (isSystemd(deps)) {
555
+ // `--now` stops it as well as removing the `default.target.wants/` link (systemctl(1), disable).
556
+ const disable = deps.systemctl(['disable', '--now', exports.SERVICE_UNIT]);
557
+ if (disable.ok) {
558
+ notes.push(`stopped and disabled ${exports.SERVICE_UNIT}`);
559
+ }
560
+ else {
561
+ // The manager refused; the unit file is gone right after this, so
562
+ // a left-behind wants/ link would dangle into every login.
563
+ const wants = (0, node_path_1.join)((0, node_path_1.dirname)(path), 'default.target.wants', exports.SERVICE_UNIT);
564
+ const hadLink = (0, node_fs_1.existsSync)(wants);
565
+ if (hadLink)
566
+ (0, node_fs_1.rmSync)(wants, { force: true });
567
+ notes.push(`systemctl --user disable failed (${disable.stderr || 'no detail'})`
568
+ + (hadLink ? ' — removed the default.target.wants/ link directly' : '')
569
+ + `; whether the unit stopped is unknown — if the listener is still up,`
570
+ + ` check with: systemctl --user status ${exports.SERVICE_UNIT}`);
571
+ }
572
+ }
573
+ else {
574
+ deps.launchctl(['bootout', serviceTarget()]);
575
+ notes.push(`unloaded ${exports.SERVICE_LABEL}`);
576
+ }
577
+ (0, node_fs_1.rmSync)(path, { force: true });
578
+ notes.push(`removed ${path}`);
579
+ if (isSystemd(deps))
580
+ deps.systemctl(['daemon-reload']);
581
+ return { ok: true, notes };
582
+ };
583
+ exports.uninstallService = uninstallService;
584
+ /** One service-manager call reported as a ServiceOpResult, naming the
585
+ * subcommand in the failure so the user sees which step was refused. */
586
+ const ask = (deps, args, note) => {
587
+ const result = isSystemd(deps) ? deps.systemctl(args) : deps.launchctl(args);
588
+ if (result.ok)
589
+ return { ok: true, notes: [note] };
590
+ const program = isSystemd(deps) ? 'systemctl --user' : 'launchctl';
591
+ return { ok: false, reason: `${program} ${args[0]} failed: ${result.stderr || 'no detail'}`, notes: [] };
592
+ };
593
+ /**
594
+ * Stop the service until the next login.
595
+ *
596
+ * launchd: `bootout` and not `disable` — disable writes to launchd's persistent
597
+ * disabled database, survives logins, and leaves no obvious way back: a
598
+ * `--stop` that quietly becomes permanent. Session-scoped is what stopping
599
+ * should mean. systemd: `stop` leaves the unit enabled, so the user manager
600
+ * starts it again the next time it starts itself.
601
+ */
602
+ const stopService = (deps = defaultOpDeps()) => isSystemd(deps)
603
+ ? ask(deps, ['stop', exports.SERVICE_UNIT], `stopped ${exports.SERVICE_UNIT} — it starts again with hwip listener --restart, or at your next login after a full logout`)
604
+ : ask(deps, ['bootout', serviceTarget()], `unloaded ${exports.SERVICE_LABEL} — it comes back at the next login`);
605
+ exports.stopService = stopService;
606
+ /** Restart the service in place. Both kill the running instance first, so this
607
+ * is also how a version-drifted daemon is replaced. */
608
+ const restartService = (deps = defaultOpDeps()) => isSystemd(deps)
609
+ ? ask(deps, ['restart', exports.SERVICE_UNIT], `restarted ${exports.SERVICE_UNIT}`)
610
+ : ask(deps, ['kickstart', '-k', serviceTarget()], `restarted ${exports.SERVICE_LABEL}`);
611
+ exports.restartService = restartService;
612
+ const pathHasTmux = (pathEnv) => pathEnv.split(':').filter(Boolean).some((dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, 'tmux')));
613
+ const jobIsLoaded = () => runLaunchctl(['print', serviceTarget()]).ok;
614
+ exports.defaultServiceProbe = { tmuxOnPath: pathHasTmux, loaded: jobIsLoaded };
615
+ /** `is-active` exits 0 when the unit is running (systemctl(1)). */
616
+ exports.systemdServiceProbe = {
617
+ tmuxOnPath: pathHasTmux,
618
+ loaded: () => runSystemctl(['is-active', '--quiet', exports.SERVICE_UNIT]).ok,
619
+ };
620
+ /**
621
+ * Why this exists: every way the service breaks leaves it looking installed.
622
+ * `launchctl list` shows the job whether or not its interpreter still exists,
623
+ * and a PATH without tmux makes the daemon exit 127 at every login while the
624
+ * registration sits there unchanged. None of that is visible without asking.
625
+ */
626
+ const serviceHealthChecks = (status, probe = status.manager === 'systemd' ? exports.systemdServiceProbe : exports.defaultServiceProbe) => {
627
+ if (!status.supported)
628
+ return [];
629
+ if (!status.installed) {
630
+ return [
631
+ {
632
+ label: 'no login-time service — after a reboot the phone sees nothing until you run `hwip cc` '
633
+ + '(add it: hwip listener --install-service)',
634
+ state: 'warn',
635
+ },
636
+ ];
637
+ }
638
+ const rows = [{ label: `${status.label} registered`, state: 'pass' }];
639
+ if (status.missing.length > 0) {
640
+ rows.push({
641
+ label: `service points at ${status.missing.join(', ')} — gone. Re-run: hwip listener --install-service`,
642
+ state: 'fail',
643
+ });
644
+ }
645
+ else {
646
+ rows.push({ label: 'service programs still exist', state: 'pass' });
647
+ }
648
+ // launchd hands the job its own bare PATH, so tmux has to come from what
649
+ // the plist baked in. Without it verifyTmux() exits 127 at every login.
650
+ if (status.pathEnv && probe.tmuxOnPath(status.pathEnv)) {
651
+ rows.push({ label: 'tmux reachable from the service PATH', state: 'pass' });
652
+ }
653
+ else {
654
+ rows.push({
655
+ label: 'tmux NOT on the service PATH — the daemon exits 127 at login. '
656
+ + 'Re-run: hwip listener --install-service',
657
+ state: 'fail',
658
+ });
659
+ }
660
+ // Without a UTF-8 LANG, tmux escapes the U+241F field separator to `_` and
661
+ // every session is dropped as unparseable — with nothing in the log that
662
+ // says so except a raw line the user has no reason to read as fatal.
663
+ if (status.langEnv && /utf-?8$/i.test(status.langEnv)) {
664
+ rows.push({ label: 'service locale is UTF-8', state: 'pass' });
665
+ }
666
+ else {
667
+ rows.push({
668
+ label: 'service LANG is not UTF-8 — tmux mangles the session separator and every session is dropped. '
669
+ + 'Re-run: hwip listener --install-service',
670
+ state: 'fail',
671
+ });
672
+ }
673
+ rows.push(status.manager === 'systemd'
674
+ ? probe.loaded()
675
+ ? { label: 'systemd is running the unit', state: 'pass' }
676
+ : { label: 'registered but systemd is not running it — start it: hwip listener --restart', state: 'warn' }
677
+ : probe.loaded()
678
+ ? { label: 'launchd is running the job', state: 'pass' }
679
+ : { label: 'registered but launchd is not running it — it returns at the next login', state: 'warn' });
680
+ return rows;
681
+ };
682
+ exports.serviceHealthChecks = serviceHealthChecks;