@indianic/dialout 1.1.2 → 1.2.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 (224) hide show
  1. package/README.md +425 -24
  2. package/bin/AGENTS.md +44 -0
  3. package/bin/devdash-agent.js +2 -0
  4. package/dist/ai-adapters/claude.d.ts +3 -0
  5. package/dist/ai-adapters/claude.d.ts.map +1 -0
  6. package/dist/ai-adapters/claude.js +98 -0
  7. package/dist/ai-adapters/claude.js.map +1 -0
  8. package/dist/ai-adapters/codex.d.ts +3 -0
  9. package/dist/ai-adapters/codex.d.ts.map +1 -0
  10. package/dist/ai-adapters/codex.js +147 -0
  11. package/dist/ai-adapters/codex.js.map +1 -0
  12. package/dist/ai-adapters/grok.d.ts +3 -0
  13. package/dist/ai-adapters/grok.d.ts.map +1 -0
  14. package/dist/ai-adapters/grok.js +164 -0
  15. package/dist/ai-adapters/grok.js.map +1 -0
  16. package/dist/ai-adapters/index.d.ts +5 -0
  17. package/dist/ai-adapters/index.d.ts.map +1 -0
  18. package/dist/ai-adapters/index.js +30 -0
  19. package/dist/ai-adapters/index.js.map +1 -0
  20. package/dist/ai-adapters/types.d.ts +3 -0
  21. package/dist/ai-adapters/types.d.ts.map +1 -0
  22. package/dist/ai-adapters/types.js +7 -0
  23. package/dist/ai-adapters/types.js.map +1 -0
  24. package/dist/ai-capabilities/claude.d.ts +4 -0
  25. package/dist/ai-capabilities/claude.d.ts.map +1 -0
  26. package/dist/ai-capabilities/claude.js +174 -0
  27. package/dist/ai-capabilities/claude.js.map +1 -0
  28. package/dist/ai-capabilities/describe.d.ts +2 -0
  29. package/dist/ai-capabilities/describe.d.ts.map +1 -0
  30. package/dist/ai-capabilities/describe.js +31 -0
  31. package/dist/ai-capabilities/describe.js.map +1 -0
  32. package/dist/ai-capabilities/fsdeps.d.ts +3 -0
  33. package/dist/ai-capabilities/fsdeps.d.ts.map +1 -0
  34. package/dist/ai-capabilities/fsdeps.js +71 -0
  35. package/dist/ai-capabilities/fsdeps.js.map +1 -0
  36. package/dist/ai-capabilities/grok.d.ts +5 -0
  37. package/dist/ai-capabilities/grok.d.ts.map +1 -0
  38. package/dist/ai-capabilities/grok.js +145 -0
  39. package/dist/ai-capabilities/grok.js.map +1 -0
  40. package/dist/ai-capabilities/index.d.ts +4 -0
  41. package/dist/ai-capabilities/index.d.ts.map +1 -0
  42. package/dist/ai-capabilities/index.js +94 -0
  43. package/dist/ai-capabilities/index.js.map +1 -0
  44. package/dist/ai-capabilities/redact.d.ts +2 -0
  45. package/dist/ai-capabilities/redact.d.ts.map +1 -0
  46. package/dist/ai-capabilities/redact.js +27 -0
  47. package/dist/ai-capabilities/redact.js.map +1 -0
  48. package/dist/ai-capabilities/types.d.ts +11 -0
  49. package/dist/ai-capabilities/types.d.ts.map +1 -0
  50. package/dist/ai-capabilities/types.js +3 -0
  51. package/dist/ai-capabilities/types.js.map +1 -0
  52. package/dist/ai-launch.d.ts +35 -0
  53. package/dist/ai-launch.d.ts.map +1 -0
  54. package/dist/ai-launch.js +211 -0
  55. package/dist/ai-launch.js.map +1 -0
  56. package/dist/ai-session-detector.d.ts +19 -0
  57. package/dist/ai-session-detector.d.ts.map +1 -0
  58. package/dist/ai-session-detector.js +79 -0
  59. package/dist/ai-session-detector.js.map +1 -0
  60. package/dist/ai-sessions.d.ts +36 -0
  61. package/dist/ai-sessions.d.ts.map +1 -0
  62. package/dist/ai-sessions.js +309 -0
  63. package/dist/ai-sessions.js.map +1 -0
  64. package/dist/ai-status.d.ts +6 -0
  65. package/dist/ai-status.d.ts.map +1 -0
  66. package/dist/ai-status.js +40 -0
  67. package/dist/ai-status.js.map +1 -0
  68. package/dist/ai-transcript-locator.d.ts +35 -0
  69. package/dist/ai-transcript-locator.d.ts.map +1 -0
  70. package/dist/ai-transcript-locator.js +268 -0
  71. package/dist/ai-transcript-locator.js.map +1 -0
  72. package/dist/ai-transcript-tail.d.ts +17 -0
  73. package/dist/ai-transcript-tail.d.ts.map +1 -0
  74. package/dist/ai-transcript-tail.js +120 -0
  75. package/dist/ai-transcript-tail.js.map +1 -0
  76. package/dist/checklist.d.ts +20 -0
  77. package/dist/checklist.d.ts.map +1 -0
  78. package/dist/checklist.js +51 -0
  79. package/dist/checklist.js.map +1 -0
  80. package/dist/cli.d.ts +2 -0
  81. package/dist/cli.d.ts.map +1 -0
  82. package/dist/cli.js +1427 -0
  83. package/dist/cli.js.map +1 -0
  84. package/dist/command-runner.d.ts +15 -0
  85. package/dist/command-runner.d.ts.map +1 -0
  86. package/dist/command-runner.js +84 -0
  87. package/dist/command-runner.js.map +1 -0
  88. package/dist/config.d.ts +41 -0
  89. package/dist/config.d.ts.map +1 -0
  90. package/dist/config.js +152 -0
  91. package/dist/config.js.map +1 -0
  92. package/dist/cowork.d.ts +26 -0
  93. package/dist/cowork.d.ts.map +1 -0
  94. package/dist/cowork.js +370 -0
  95. package/dist/cowork.js.map +1 -0
  96. package/dist/fs-browser.d.ts +6 -0
  97. package/dist/fs-browser.d.ts.map +1 -0
  98. package/dist/fs-browser.js +61 -0
  99. package/dist/fs-browser.js.map +1 -0
  100. package/dist/has-command.d.ts +12 -0
  101. package/dist/has-command.d.ts.map +1 -0
  102. package/dist/has-command.js +24 -0
  103. package/dist/has-command.js.map +1 -0
  104. package/dist/heartbeat.d.ts +7 -0
  105. package/dist/heartbeat.d.ts.map +1 -0
  106. package/dist/heartbeat.js +50 -0
  107. package/dist/heartbeat.js.map +1 -0
  108. package/dist/index.d.ts +2 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +56 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/port-scanner.d.ts +4 -0
  113. package/dist/port-scanner.d.ts.map +1 -0
  114. package/dist/port-scanner.js +75 -0
  115. package/dist/port-scanner.js.map +1 -0
  116. package/dist/project-scanner.d.ts +15 -0
  117. package/dist/project-scanner.d.ts.map +1 -0
  118. package/dist/project-scanner.js +333 -0
  119. package/dist/project-scanner.js.map +1 -0
  120. package/dist/pty-manager.d.ts +28 -0
  121. package/dist/pty-manager.d.ts.map +1 -0
  122. package/dist/pty-manager.js +506 -0
  123. package/dist/pty-manager.js.map +1 -0
  124. package/dist/service-installer.d.ts +95 -0
  125. package/dist/service-installer.d.ts.map +1 -0
  126. package/dist/service-installer.js +911 -0
  127. package/dist/service-installer.js.map +1 -0
  128. package/dist/single-instance.d.ts +20 -0
  129. package/dist/single-instance.d.ts.map +1 -0
  130. package/dist/single-instance.js +151 -0
  131. package/dist/single-instance.js.map +1 -0
  132. package/dist/ssh-local-network.d.ts +26 -0
  133. package/dist/ssh-local-network.d.ts.map +1 -0
  134. package/dist/ssh-local-network.js +169 -0
  135. package/dist/ssh-local-network.js.map +1 -0
  136. package/dist/terminal-detect.d.ts +52 -0
  137. package/dist/terminal-detect.d.ts.map +1 -0
  138. package/dist/terminal-detect.js +147 -0
  139. package/dist/terminal-detect.js.map +1 -0
  140. package/dist/terminal-markers.d.ts +52 -0
  141. package/dist/terminal-markers.d.ts.map +1 -0
  142. package/dist/terminal-markers.js +174 -0
  143. package/dist/terminal-markers.js.map +1 -0
  144. package/dist/tmux-manager.d.ts +35 -0
  145. package/dist/tmux-manager.d.ts.map +1 -0
  146. package/dist/tmux-manager.js +274 -0
  147. package/dist/tmux-manager.js.map +1 -0
  148. package/dist/update-check.d.ts +3 -0
  149. package/dist/update-check.d.ts.map +1 -0
  150. package/dist/update-check.js +202 -0
  151. package/dist/update-check.js.map +1 -0
  152. package/dist/websocket.d.ts +8 -0
  153. package/dist/websocket.d.ts.map +1 -0
  154. package/dist/websocket.js +472 -0
  155. package/dist/websocket.js.map +1 -0
  156. package/node_modules/@dialout/shared/README.md +16 -0
  157. package/node_modules/@dialout/shared/dist/ai-chat-prefs.d.ts +8 -0
  158. package/node_modules/@dialout/shared/dist/ai-chat-prefs.d.ts.map +1 -0
  159. package/node_modules/@dialout/shared/dist/ai-chat-prefs.js +41 -0
  160. package/node_modules/@dialout/shared/dist/capabilities.d.ts +25 -0
  161. package/node_modules/@dialout/shared/dist/capabilities.d.ts.map +1 -0
  162. package/node_modules/@dialout/shared/dist/capabilities.js +2 -0
  163. package/node_modules/@dialout/shared/dist/chat-blocks.d.ts +33 -0
  164. package/node_modules/@dialout/shared/dist/chat-blocks.d.ts.map +1 -0
  165. package/node_modules/@dialout/shared/dist/chat-blocks.js +61 -0
  166. package/node_modules/@dialout/shared/dist/command-filter.d.ts +4 -0
  167. package/node_modules/@dialout/shared/dist/command-filter.d.ts.map +1 -0
  168. package/node_modules/@dialout/shared/dist/command-filter.js +49 -0
  169. package/node_modules/@dialout/shared/dist/composer-behaviour.d.ts +6 -0
  170. package/node_modules/@dialout/shared/dist/composer-behaviour.d.ts.map +1 -0
  171. package/node_modules/@dialout/shared/dist/composer-behaviour.js +24 -0
  172. package/node_modules/@dialout/shared/dist/events.d.ts +37 -0
  173. package/node_modules/@dialout/shared/dist/events.d.ts.map +1 -0
  174. package/node_modules/@dialout/shared/dist/events.js +13 -0
  175. package/node_modules/@dialout/shared/dist/index.d.ts +21 -0
  176. package/node_modules/@dialout/shared/dist/index.d.ts.map +1 -0
  177. package/node_modules/@dialout/shared/dist/index.js +37 -0
  178. package/node_modules/@dialout/shared/dist/kinds.d.ts +2 -0
  179. package/node_modules/@dialout/shared/dist/kinds.d.ts.map +1 -0
  180. package/node_modules/@dialout/shared/dist/kinds.js +2 -0
  181. package/node_modules/@dialout/shared/dist/notify.d.ts +3 -0
  182. package/node_modules/@dialout/shared/dist/notify.d.ts.map +1 -0
  183. package/node_modules/@dialout/shared/dist/notify.js +31 -0
  184. package/node_modules/@dialout/shared/dist/permission.d.ts +4 -0
  185. package/node_modules/@dialout/shared/dist/permission.d.ts.map +1 -0
  186. package/node_modules/@dialout/shared/dist/permission.js +8 -0
  187. package/node_modules/@dialout/shared/dist/scroll-pin.d.ts +3 -0
  188. package/node_modules/@dialout/shared/dist/scroll-pin.d.ts.map +1 -0
  189. package/node_modules/@dialout/shared/dist/scroll-pin.js +11 -0
  190. package/node_modules/@dialout/shared/dist/session.d.ts +19 -0
  191. package/node_modules/@dialout/shared/dist/session.d.ts.map +1 -0
  192. package/node_modules/@dialout/shared/dist/session.js +6 -0
  193. package/node_modules/@dialout/shared/dist/terminal-name.d.ts +25 -0
  194. package/node_modules/@dialout/shared/dist/terminal-name.d.ts.map +1 -0
  195. package/node_modules/@dialout/shared/dist/terminal-name.js +63 -0
  196. package/node_modules/@dialout/shared/dist/tool-appearance.d.ts +9 -0
  197. package/node_modules/@dialout/shared/dist/tool-appearance.d.ts.map +1 -0
  198. package/node_modules/@dialout/shared/dist/tool-appearance.js +30 -0
  199. package/node_modules/@dialout/shared/package.json +37 -0
  200. package/node_modules/@dialout/shared/src/ai-chat-prefs.test.ts +40 -0
  201. package/node_modules/@dialout/shared/src/ai-chat-prefs.ts +39 -0
  202. package/node_modules/@dialout/shared/src/capabilities.ts +30 -0
  203. package/node_modules/@dialout/shared/src/chat-blocks.test.ts +65 -0
  204. package/node_modules/@dialout/shared/src/chat-blocks.ts +79 -0
  205. package/node_modules/@dialout/shared/src/command-filter.test.ts +76 -0
  206. package/node_modules/@dialout/shared/src/command-filter.ts +45 -0
  207. package/node_modules/@dialout/shared/src/composer-behaviour.test.ts +31 -0
  208. package/node_modules/@dialout/shared/src/composer-behaviour.ts +22 -0
  209. package/node_modules/@dialout/shared/src/events.ts +27 -0
  210. package/node_modules/@dialout/shared/src/index.ts +32 -0
  211. package/node_modules/@dialout/shared/src/kinds.ts +5 -0
  212. package/node_modules/@dialout/shared/src/notify.test.ts +49 -0
  213. package/node_modules/@dialout/shared/src/notify.ts +30 -0
  214. package/node_modules/@dialout/shared/src/permission.ts +10 -0
  215. package/node_modules/@dialout/shared/src/scroll-pin.test.ts +23 -0
  216. package/node_modules/@dialout/shared/src/scroll-pin.ts +13 -0
  217. package/node_modules/@dialout/shared/src/session.ts +25 -0
  218. package/node_modules/@dialout/shared/src/terminal-name.ts +82 -0
  219. package/node_modules/@dialout/shared/src/tool-appearance.test.ts +41 -0
  220. package/node_modules/@dialout/shared/src/tool-appearance.ts +39 -0
  221. package/package.json +54 -14
  222. package/scripts/postinstall.js +79 -0
  223. package/scripts/prepack.js +45 -0
  224. package/bin/dialout.js +0 -13
@@ -0,0 +1,911 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.serviceUser = serviceUser;
37
+ exports.isLingerEnabled = isLingerEnabled;
38
+ exports.enableLinger = enableLinger;
39
+ exports.defaultLinuxScope = defaultLinuxScope;
40
+ exports.installService = installService;
41
+ exports.uninstallService = uninstallService;
42
+ exports.isServiceInstalled = isServiceInstalled;
43
+ exports.getServiceStatus = getServiceStatus;
44
+ exports.getWatchdogScript = getWatchdogScript;
45
+ exports.installCron = installCron;
46
+ exports.uninstallCron = uninstallCron;
47
+ exports.isCronInstalled = isCronInstalled;
48
+ exports.listSupervisors = listSupervisors;
49
+ exports.staleSupervisorAdvice = staleSupervisorAdvice;
50
+ exports.repairWatchdog = repairWatchdog;
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const os = __importStar(require("os"));
54
+ const child_process_1 = require("child_process");
55
+ const config_1 = require("./config");
56
+ const SERVICE_NAME = 'com.dialout.agent';
57
+ const SYSTEMD_UNIT = 'dialout';
58
+ function getNodePath() {
59
+ try {
60
+ return (0, child_process_1.execSync)('which node', { encoding: 'utf-8' }).trim();
61
+ }
62
+ catch {
63
+ return '/usr/local/bin/node';
64
+ }
65
+ }
66
+ function getAgentScript() {
67
+ return path.resolve(__dirname, '../dist/index.js');
68
+ }
69
+ // The human the boot service must run AS.
70
+ //
71
+ // os.userInfo() reports the EFFECTIVE user, so under `sudo devdash-agent
72
+ // install-service --system` it returns "root" — and a plist with
73
+ // UserName=root gives the daemon root's HOME and root's tmux socket
74
+ // (/tmp/tmux-0), where the user's sessions do not exist. The agent then
75
+ // connects, authenticates, and reports an empty session list forever, which
76
+ // looks exactly like "nothing is running". sudo exports the original login
77
+ // name in SUDO_USER, so prefer it.
78
+ function serviceUser(env = process.env, info = () => os.userInfo().username) {
79
+ const sudoUser = (env.SUDO_USER || '').trim();
80
+ if (sudoUser && sudoUser !== 'root')
81
+ return sudoUser;
82
+ return info();
83
+ }
84
+ function isRoot() {
85
+ return typeof process.getuid === 'function' && process.getuid() === 0;
86
+ }
87
+ // We can drive an interactive `sudo` password prompt only on a real terminal.
88
+ function canPromptSudo() {
89
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
90
+ }
91
+ // Run a privileged command the way the INSTALL path already does: directly when
92
+ // root, otherwise through an interactive sudo, otherwise not at all.
93
+ //
94
+ // uninstall used to have no sudo branch — only isRoot() and "here are the
95
+ // commands, run them yourself". Since install-service --system escalates
96
+ // happily, the normal way to end up with a LaunchDaemon is to never have been
97
+ // root, and then uninstall-service could not remove what install-service had
98
+ // just installed. Worse, it set removed = true on that branch, so it printed
99
+ // no failure and exited 0 while the daemon kept running and kept restarting at
100
+ // boot. Returns whether the command actually ran.
101
+ function runPrivileged(cmd, deps = {}) {
102
+ const root = deps.isRoot ?? isRoot;
103
+ const prompt = deps.canPromptSudo ?? canPromptSudo;
104
+ const run = deps.run ?? ((c, o) => { (0, child_process_1.execSync)(c, o); });
105
+ const log = deps.log ?? console.log;
106
+ if (root()) {
107
+ try {
108
+ run(cmd);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ if (prompt()) {
116
+ log('Removing the boot service requires admin rights — you may be prompted for your password.\n');
117
+ try {
118
+ run(`sudo bash -c '${cmd}'`, { stdio: 'inherit' });
119
+ return true;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+ // --- macOS (launchd) ---
128
+ function getLaunchdPlistPath(homedir = os.homedir()) {
129
+ // Per-user LaunchAgent — starts at login.
130
+ return path.join(homedir, 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`);
131
+ }
132
+ function getLaunchDaemonPlistPath() {
133
+ // System LaunchDaemon — starts at boot, before login.
134
+ return `/Library/LaunchDaemons/${SERVICE_NAME}.plist`;
135
+ }
136
+ function generatePlist(system) {
137
+ const nodePath = getNodePath();
138
+ const script = getAgentScript();
139
+ const logDir = path.join((0, config_1.configDirFor)(os.homedir()), 'logs');
140
+ if (!fs.existsSync(logDir)) {
141
+ fs.mkdirSync(logDir, { recursive: true });
142
+ }
143
+ // A system daemon runs as root by default; pin it to the invoking user so it
144
+ // can read ~/.dialout/config.json and write the user's log dir.
145
+ const userKey = system
146
+ ? ` <key>UserName</key>\n <string>${serviceUser()}</string>\n`
147
+ : '';
148
+ return `<?xml version="1.0" encoding="UTF-8"?>
149
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
150
+ <plist version="1.0">
151
+ <dict>
152
+ <key>Label</key>
153
+ <string>${SERVICE_NAME}</string>
154
+ <key>ProgramArguments</key>
155
+ <array>
156
+ <string>${nodePath}</string>
157
+ <string>${script}</string>
158
+ </array>
159
+ <key>EnvironmentVariables</key>
160
+ <dict>
161
+ <key>PATH</key>
162
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${path.dirname(nodePath)}</string>
163
+ <!-- launchd sets no locale at all, and tmux sanitizes the \x1f separator in
164
+ its -F output to '_' outside a UTF-8 locale, which made list-sessions
165
+ unparseable and the agent report zero terminals. tmux-manager forces
166
+ this per-spawn too; setting it here keeps the daemon's whole
167
+ environment honest for anything else that shells out. -->
168
+ <key>LC_ALL</key>
169
+ <string>C.UTF-8</string>
170
+ </dict>
171
+ ${userKey} <key>RunAtLoad</key>
172
+ <true/>
173
+ <key>KeepAlive</key>
174
+ <true/>
175
+ <key>ThrottleInterval</key>
176
+ <integer>5</integer>
177
+ <key>StandardOutPath</key>
178
+ <string>${logDir}/stdout.log</string>
179
+ <key>StandardErrorPath</key>
180
+ <string>${logDir}/stderr.log</string>
181
+ <key>WorkingDirectory</key>
182
+ <string>${os.homedir()}</string>
183
+ </dict>
184
+ </plist>`;
185
+ }
186
+ // Remove the per-user LaunchAgent (used when switching to a system daemon).
187
+ function removeLaunchAgent() {
188
+ const plistPath = getLaunchdPlistPath();
189
+ if (fs.existsSync(plistPath)) {
190
+ try {
191
+ (0, child_process_1.execSync)(`launchctl unload ${plistPath}`);
192
+ }
193
+ catch { /* may not be loaded */ }
194
+ fs.unlinkSync(plistPath);
195
+ }
196
+ }
197
+ function installMacOSAgent() {
198
+ const plistPath = getLaunchdPlistPath();
199
+ const dir = path.dirname(plistPath);
200
+ if (!fs.existsSync(dir)) {
201
+ fs.mkdirSync(dir, { recursive: true });
202
+ }
203
+ // Reload cleanly so an existing service picks up the new plist.
204
+ try {
205
+ (0, child_process_1.execSync)(`launchctl unload ${plistPath} 2>/dev/null`);
206
+ }
207
+ catch { /* not loaded */ }
208
+ fs.writeFileSync(plistPath, generatePlist(false));
209
+ (0, child_process_1.execSync)(`launchctl load ${plistPath}`);
210
+ console.log(`Service installed: ${plistPath}`);
211
+ console.log('The agent will start automatically on login.');
212
+ }
213
+ function installMacOSDaemon() {
214
+ const target = getLaunchDaemonPlistPath();
215
+ const staging = path.join((0, config_1.configDirFor)(os.homedir()), `${SERVICE_NAME}.plist`);
216
+ fs.writeFileSync(staging, generatePlist(true));
217
+ // A system daemon and a per-user agent share a Label — drop the agent first.
218
+ removeLaunchAgent();
219
+ if (isRoot()) {
220
+ fs.copyFileSync(staging, target);
221
+ (0, child_process_1.execSync)(`chown root:wheel ${target}`);
222
+ (0, child_process_1.execSync)(`chmod 644 ${target}`);
223
+ try {
224
+ (0, child_process_1.execSync)(`launchctl bootout system ${target} 2>/dev/null`);
225
+ }
226
+ catch { /* not loaded */ }
227
+ try {
228
+ (0, child_process_1.execSync)(`launchctl bootstrap system ${target}`);
229
+ }
230
+ catch {
231
+ (0, child_process_1.execSync)(`launchctl load ${target}`); // older macOS fallback
232
+ }
233
+ console.log(`System service installed: ${target}`);
234
+ console.log('The agent will start at boot, before login.');
235
+ }
236
+ else if (canPromptSudo()) {
237
+ console.log('Boot service requires admin rights — you may be prompted for your password.\n');
238
+ try {
239
+ (0, child_process_1.execSync)(`sudo bash -c 'cp "${staging}" "${target}" && chown root:wheel "${target}" && chmod 644 "${target}" && { launchctl bootout system "${target}" 2>/dev/null; launchctl bootstrap system "${target}"; }'`, { stdio: 'inherit' });
240
+ console.log(`\nSystem service installed: ${target}`);
241
+ console.log(`The agent will start at boot, before login (as user "${serviceUser()}").`);
242
+ console.log('Verify with: dialout status');
243
+ }
244
+ catch {
245
+ console.error('\nsudo install failed. Run these commands manually:');
246
+ printMacDaemonSudoSteps(staging, target);
247
+ }
248
+ }
249
+ else {
250
+ console.log('Boot-before-login requires root. Run these commands:');
251
+ printMacDaemonSudoSteps(staging, target);
252
+ }
253
+ }
254
+ function printMacDaemonSudoSteps(staging, target) {
255
+ console.log('');
256
+ console.log(` sudo cp ${staging} ${target}`);
257
+ console.log(` sudo chown root:wheel ${target}`);
258
+ console.log(` sudo chmod 644 ${target}`);
259
+ console.log(` sudo launchctl bootstrap system ${target}`);
260
+ console.log('');
261
+ console.log(`Then it runs at boot as user "${serviceUser()}". Verify: dialout status`);
262
+ }
263
+ function uninstallMacOS(deps = {}) {
264
+ const exists = deps.existsSync ?? fs.existsSync;
265
+ const log = deps.log ?? console.log;
266
+ let removed = false;
267
+ const pending = [];
268
+ // System daemon (install-service --system). Needs root to remove, exactly as
269
+ // it needed root to install — so escalate the same way install does.
270
+ const daemonPath = getLaunchDaemonPlistPath();
271
+ if (exists(daemonPath)) {
272
+ const cmd = `launchctl bootout system "${daemonPath}" 2>/dev/null; `
273
+ + `launchctl unload "${daemonPath}" 2>/dev/null; `
274
+ + `rm -f "${daemonPath}"`;
275
+ if (runPrivileged(cmd, deps)) {
276
+ log('System service uninstalled.');
277
+ removed = true;
278
+ }
279
+ else {
280
+ // NOT removed. Say so plainly and leave removed false, so the caller can
281
+ // exit non-zero instead of reporting a success that did not happen.
282
+ log('\nCould not remove the boot service automatically. Run:\n');
283
+ log(` sudo launchctl bootout system ${daemonPath}`);
284
+ log(` sudo rm ${daemonPath}\n`);
285
+ pending.push(`sudo launchctl bootout system ${daemonPath}`, `sudo rm ${daemonPath}`);
286
+ }
287
+ }
288
+ // Per-user agent — owned by this user, so no escalation is ever needed.
289
+ const agentPath = getLaunchdPlistPath();
290
+ if (exists(agentPath)) {
291
+ removeLaunchAgent();
292
+ log('Service uninstalled.');
293
+ removed = true;
294
+ }
295
+ if (!removed && pending.length === 0)
296
+ log('Service not installed.');
297
+ return { removed, pending };
298
+ }
299
+ // Returns the running PID of a launchd job, or null if loaded-but-stopped / absent.
300
+ function launchdPid(domainTarget) {
301
+ try {
302
+ const out = (0, child_process_1.execSync)(`launchctl print ${domainTarget} 2>/dev/null`, { encoding: 'utf-8' });
303
+ const m = out.match(/\bpid\s*=\s*(\d+)/);
304
+ return m ? parseInt(m[1], 10) : null;
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
310
+ // --- Linux (systemd) ---
311
+ function getSystemdUnitPath(homedir = os.homedir()) {
312
+ return path.join(homedir, '.config', 'systemd', 'user', `${SYSTEMD_UNIT}.service`);
313
+ }
314
+ function getSystemdSystemUnitPath() {
315
+ return `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
316
+ }
317
+ function generateSystemdUnit(system) {
318
+ const nodePath = getNodePath();
319
+ const script = getAgentScript();
320
+ const userLine = system ? `User=${serviceUser()}\n` : '';
321
+ return `[Unit]
322
+ Description=Dialout Agent
323
+ After=network-online.target
324
+ Wants=network-online.target
325
+
326
+ [Service]
327
+ ${userLine}ExecStart=${nodePath} ${script}
328
+ # systemd, like launchd, starts a unit with no locale. tmux mangles the \x1f
329
+ # field separator in its -F output to '_' outside a UTF-8 locale, which makes
330
+ # list-sessions unparseable and the agent report zero terminals.
331
+ Environment=LC_ALL=C.UTF-8
332
+ Restart=always
333
+ RestartSec=5
334
+ Environment=NODE_ENV=production
335
+
336
+ [Install]
337
+ WantedBy=${system ? 'multi-user.target' : 'default.target'}
338
+ `;
339
+ }
340
+ function isLingerEnabled(user) {
341
+ try {
342
+ return /Linger=yes/i.test((0, child_process_1.execSync)(`loginctl show-user ${user}`, { encoding: 'utf-8', stdio: 'pipe' }));
343
+ }
344
+ catch {
345
+ return false; // no such user record yet — enable-linger creates one
346
+ }
347
+ }
348
+ function enableLinger(user, deps = {}) {
349
+ const isEnabled = deps.isEnabled ?? isLingerEnabled;
350
+ const run = deps.run ?? ((cmd) => { (0, child_process_1.execSync)(cmd, { stdio: 'pipe' }); });
351
+ if (isEnabled(user))
352
+ return { ok: true, alreadyOn: true };
353
+ try {
354
+ run(`loginctl enable-linger ${user}`);
355
+ }
356
+ catch (err) {
357
+ // Non-root without polkit rights lands here. The unit is still installed
358
+ // and works while logged in, so this is a warning, not an install failure.
359
+ return { ok: false, alreadyOn: false, error: err?.message || 'enable-linger failed' };
360
+ }
361
+ return { ok: isEnabled(user), alreadyOn: false, error: isEnabled(user) ? undefined : 'enable-linger reported success but lingering is still off' };
362
+ }
363
+ function defaultLinuxScope(deps = {}) {
364
+ const root = deps.isRoot ?? isRoot;
365
+ return root() ? 'system' : 'user';
366
+ }
367
+ function installLinuxUser() {
368
+ const unitPath = getSystemdUnitPath();
369
+ const dir = path.dirname(unitPath);
370
+ if (!fs.existsSync(dir)) {
371
+ fs.mkdirSync(dir, { recursive: true });
372
+ }
373
+ fs.writeFileSync(unitPath, generateSystemdUnit(false));
374
+ (0, child_process_1.execSync)('systemctl --user daemon-reload');
375
+ (0, child_process_1.execSync)(`systemctl --user enable ${SYSTEMD_UNIT}`);
376
+ (0, child_process_1.execSync)(`systemctl --user restart ${SYSTEMD_UNIT}`);
377
+ console.log(`Service installed: ${unitPath}`);
378
+ // Without this the unit is torn down on logout — see LingerDeps above.
379
+ const user = serviceUser();
380
+ const linger = enableLinger(user);
381
+ if (linger.ok) {
382
+ console.log('The agent is running and will keep running after you log out (lingering enabled).');
383
+ }
384
+ else {
385
+ console.log('The agent is running and will start on login.');
386
+ console.log('');
387
+ console.log('\x1b[33mWarning:\x1b[0m this service stops when your last session ends.');
388
+ console.log(` Could not enable lingering: ${linger.error}`);
389
+ console.log(` Fix with: sudo loginctl enable-linger ${user}`);
390
+ console.log(' Or install the boot service instead: dialout install-service --system');
391
+ }
392
+ }
393
+ function installLinuxSystem() {
394
+ const target = getSystemdSystemUnitPath();
395
+ const staging = path.join((0, config_1.configDirFor)(os.homedir()), `${SYSTEMD_UNIT}.service`);
396
+ fs.writeFileSync(staging, generateSystemdUnit(true));
397
+ if (isRoot()) {
398
+ fs.copyFileSync(staging, target);
399
+ (0, child_process_1.execSync)('systemctl daemon-reload');
400
+ (0, child_process_1.execSync)(`systemctl enable ${SYSTEMD_UNIT}`);
401
+ (0, child_process_1.execSync)(`systemctl restart ${SYSTEMD_UNIT}`);
402
+ console.log(`System service installed: ${target}`);
403
+ console.log('The agent will start at boot, before login.');
404
+ }
405
+ else if (canPromptSudo()) {
406
+ console.log('Boot service requires admin rights — you may be prompted for your password.\n');
407
+ try {
408
+ (0, child_process_1.execSync)(`sudo bash -c 'cp "${staging}" "${target}" && systemctl daemon-reload && systemctl enable --now ${SYSTEMD_UNIT}'`, { stdio: 'inherit' });
409
+ console.log(`\nSystem service installed: ${target}`);
410
+ console.log(`The agent will start at boot, before login (as user "${serviceUser()}").`);
411
+ console.log('Verify with: dialout status');
412
+ }
413
+ catch {
414
+ console.error('\nsudo install failed. Run these commands manually:');
415
+ printLinuxSystemSudoSteps(staging, target);
416
+ }
417
+ }
418
+ else {
419
+ console.log('Boot-before-login requires root. Run these commands:');
420
+ printLinuxSystemSudoSteps(staging, target);
421
+ }
422
+ }
423
+ function printLinuxSystemSudoSteps(staging, target) {
424
+ console.log('');
425
+ console.log(` sudo cp ${staging} ${target}`);
426
+ console.log(` sudo systemctl daemon-reload`);
427
+ console.log(` sudo systemctl enable --now ${SYSTEMD_UNIT}`);
428
+ console.log('');
429
+ console.log(`Then it runs at boot as user "${serviceUser()}". Verify: dialout status`);
430
+ }
431
+ function uninstallLinux(deps = {}) {
432
+ const exists = deps.existsSync ?? fs.existsSync;
433
+ const log = deps.log ?? console.log;
434
+ let removed = false;
435
+ const pending = [];
436
+ // System unit — same escalation story as the macOS LaunchDaemon above.
437
+ const sysPath = getSystemdSystemUnitPath();
438
+ if (exists(sysPath)) {
439
+ const cmd = `systemctl disable --now ${SYSTEMD_UNIT} 2>/dev/null; `
440
+ + `rm -f "${sysPath}"; `
441
+ + `systemctl daemon-reload`;
442
+ if (runPrivileged(cmd, deps)) {
443
+ log('System service uninstalled.');
444
+ removed = true;
445
+ }
446
+ else {
447
+ log('\nCould not remove the boot service automatically. Run:\n');
448
+ log(` sudo systemctl disable --now ${SYSTEMD_UNIT}`);
449
+ log(` sudo rm ${sysPath}`);
450
+ log(` sudo systemctl daemon-reload\n`);
451
+ pending.push(`sudo systemctl disable --now ${SYSTEMD_UNIT}`, `sudo rm ${sysPath}`, 'sudo systemctl daemon-reload');
452
+ }
453
+ }
454
+ const userPath = getSystemdUnitPath();
455
+ if (exists(userPath)) {
456
+ try {
457
+ (0, child_process_1.execSync)(`systemctl --user disable --now ${SYSTEMD_UNIT}`);
458
+ }
459
+ catch { /* may not be running */ }
460
+ fs.unlinkSync(userPath);
461
+ (0, child_process_1.execSync)('systemctl --user daemon-reload');
462
+ log('Service uninstalled.');
463
+ removed = true;
464
+ }
465
+ if (!removed && pending.length === 0)
466
+ log('Service not installed.');
467
+ return { removed, pending };
468
+ }
469
+ function systemdActive(scope) {
470
+ const flag = scope === 'user' ? '--user ' : '';
471
+ let active = false;
472
+ let pid = null;
473
+ try {
474
+ active = (0, child_process_1.execSync)(`systemctl ${flag}is-active ${SYSTEMD_UNIT} 2>/dev/null`, { encoding: 'utf-8' }).trim() === 'active';
475
+ }
476
+ catch { /* inactive/failed */ }
477
+ try {
478
+ const mp = parseInt((0, child_process_1.execSync)(`systemctl ${flag}show -p MainPID --value ${SYSTEMD_UNIT} 2>/dev/null`, { encoding: 'utf-8' }).trim(), 10);
479
+ pid = mp > 0 ? mp : null;
480
+ }
481
+ catch { /* none */ }
482
+ return { active, pid };
483
+ }
484
+ function installService(opts = {}) {
485
+ const platform = os.platform();
486
+ if (platform === 'darwin') {
487
+ opts.system ? installMacOSDaemon() : installMacOSAgent();
488
+ }
489
+ else if (platform === 'linux') {
490
+ opts.system ? installLinuxSystem() : installLinuxUser();
491
+ }
492
+ else {
493
+ console.error(`Unsupported platform: ${platform}. Use "dialout start --daemon" instead.`);
494
+ process.exit(1);
495
+ }
496
+ }
497
+ function uninstallService(deps = {}) {
498
+ const platform = deps.platform ?? os.platform();
499
+ if (platform === 'darwin') {
500
+ return uninstallMacOS(deps);
501
+ }
502
+ else if (platform === 'linux') {
503
+ return uninstallLinux(deps);
504
+ }
505
+ console.error(`Unsupported platform: ${platform}`);
506
+ process.exit(1);
507
+ }
508
+ function isServiceInstalled() {
509
+ const platform = os.platform();
510
+ if (platform === 'darwin') {
511
+ return fs.existsSync(getLaunchDaemonPlistPath()) || fs.existsSync(getLaunchdPlistPath());
512
+ }
513
+ else if (platform === 'linux') {
514
+ return fs.existsSync(getSystemdSystemUnitPath()) || fs.existsSync(getSystemdUnitPath());
515
+ }
516
+ return false;
517
+ }
518
+ // Reports the REAL state of the managed service (queries launchd/systemd),
519
+ // not the manual-daemon pidfile.
520
+ function getServiceStatus() {
521
+ const platform = os.platform();
522
+ if (platform === 'darwin') {
523
+ if (fs.existsSync(getLaunchDaemonPlistPath())) {
524
+ const pid = launchdPid(`system/${SERVICE_NAME}`);
525
+ return { installed: true, running: pid !== null, pid, kind: 'launchd-daemon', atBoot: true };
526
+ }
527
+ if (fs.existsSync(getLaunchdPlistPath())) {
528
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
529
+ const pid = launchdPid(`gui/${uid}/${SERVICE_NAME}`);
530
+ return { installed: true, running: pid !== null, pid, kind: 'launchd-agent', atBoot: false };
531
+ }
532
+ return { installed: false, running: false, pid: null, kind: null, atBoot: false };
533
+ }
534
+ if (platform === 'linux') {
535
+ if (fs.existsSync(getSystemdSystemUnitPath())) {
536
+ const { active, pid } = systemdActive('system');
537
+ return { installed: true, running: active, pid, kind: 'systemd-system', atBoot: true };
538
+ }
539
+ if (fs.existsSync(getSystemdUnitPath())) {
540
+ const { active, pid } = systemdActive('user');
541
+ return { installed: true, running: active, pid, kind: 'systemd-user', atBoot: false };
542
+ }
543
+ return { installed: false, running: false, pid: null, kind: null, atBoot: false };
544
+ }
545
+ return { installed: false, running: false, pid: null, kind: null, atBoot: false };
546
+ }
547
+ // --- Cron-based watchdog ---
548
+ const CRON_MARKER = '# dialout-watchdog';
549
+ function getWatchdogPath(homedir = os.homedir()) {
550
+ return path.join((0, config_1.configDirFor)(homedir), 'watchdog.sh');
551
+ }
552
+ function getWatchdogScript() {
553
+ const nodePath = getNodePath();
554
+ const script = getAgentScript();
555
+ const pidFile = path.join((0, config_1.configDirFor)(os.homedir()), 'daemon.pid');
556
+ const logDir = path.join((0, config_1.configDirFor)(os.homedir()), 'logs');
557
+ if (!fs.existsSync(logDir)) {
558
+ fs.mkdirSync(logDir, { recursive: true });
559
+ }
560
+ const watchdogPath = getWatchdogPath();
561
+ const content = `#!/bin/bash
562
+ # Dialout Agent Watchdog — auto-restart if not running
563
+ PID_FILE="${pidFile}"
564
+ LOG="${logDir}/watchdog.log"
565
+ NODE="${nodePath}"
566
+ SCRIPT="${script}"
567
+
568
+ is_running() {
569
+ if [ -f "$PID_FILE" ]; then
570
+ PID=$(cat "$PID_FILE" 2>/dev/null)
571
+ if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
572
+ return 0
573
+ fi
574
+ fi
575
+ return 1
576
+ }
577
+
578
+ if is_running; then
579
+ exit 0
580
+ fi
581
+
582
+ echo "[$(date)] Agent not running — restarting..." >> "$LOG"
583
+ nohup "$NODE" "$SCRIPT" >> "${logDir}/stdout.log" 2>> "${logDir}/stderr.log" &
584
+ echo $! > "$PID_FILE"
585
+ echo "[$(date)] Started with PID $!" >> "$LOG"
586
+ `;
587
+ // Write-then-rename instead of a truncating in-place fs.writeFileSync.
588
+ // Two reasons:
589
+ // 1. Atomicity: if the process dies mid-write (OOM, power loss, SIGKILL),
590
+ // an in-place write can leave a truncated, syntactically-broken script
591
+ // sitting exactly where cron invokes it every N minutes. rename(2) on
592
+ // the same filesystem is a single atomic directory-entry swap — cron
593
+ // only ever sees the old complete file or the new complete file, never
594
+ // a partial one.
595
+ // 2. Mode: POSIX ignores the `mode` passed to open()/writeFileSync when
596
+ // O_CREAT hits a path that already exists — so { mode: 0o755 } on an
597
+ // in-place writeFileSync silently does nothing if the target file had
598
+ // already lost its executable bit (a hardening scan, a backup/restore
599
+ // tool, or a manual `chmod 644` while debugging). Writing to a brand
600
+ // new temp file always honors `mode` (there's nothing existing to
601
+ // ignore it for), and rename() moves that file's mode along with it,
602
+ // replacing whatever permissions the old target had.
603
+ // The temp name is unique per call (pid + high-res time + random suffix)
604
+ // so concurrent getWatchdogScript() calls (e.g. setup-cron racing a
605
+ // manual repair) can't collide on it.
606
+ const tmpPath = `${watchdogPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
607
+ try {
608
+ fs.writeFileSync(tmpPath, content, { mode: 0o755 });
609
+ fs.chmodSync(tmpPath, 0o755); // belt-and-suspenders: independent of umask, since tmpPath is always newly created
610
+ fs.renameSync(tmpPath, watchdogPath);
611
+ }
612
+ catch (err) {
613
+ try {
614
+ fs.unlinkSync(tmpPath);
615
+ }
616
+ catch { /* tmp file may not have been created */ }
617
+ throw err;
618
+ }
619
+ return watchdogPath;
620
+ }
621
+ function installCron(intervalMinutes = 5) {
622
+ const watchdogPath = getWatchdogScript();
623
+ // Get existing crontab (without our entry)
624
+ let existing = '';
625
+ try {
626
+ existing = (0, child_process_1.execSync)('crontab -l 2>/dev/null', { encoding: 'utf-8' });
627
+ }
628
+ catch { /* no existing crontab */ }
629
+ // Remove any old dialout entries
630
+ const lines = existing.split('\n').filter((l) => !l.includes(CRON_MARKER) && l.trim() !== '');
631
+ // Add new entry
632
+ lines.push(`*/${intervalMinutes} * * * * ${watchdogPath} ${CRON_MARKER}`);
633
+ lines.push(''); // trailing newline
634
+ (0, child_process_1.execSync)('crontab -', { input: lines.join('\n'), encoding: 'utf-8' });
635
+ console.log(`Cron watchdog installed — checks every ${intervalMinutes} minute${intervalMinutes > 1 ? 's' : ''}`);
636
+ console.log(`Watchdog script: ${watchdogPath}`);
637
+ console.log('The agent will auto-restart if it dies.');
638
+ }
639
+ function uninstallCron() {
640
+ let existing = '';
641
+ try {
642
+ existing = (0, child_process_1.execSync)('crontab -l 2>/dev/null', { encoding: 'utf-8' });
643
+ }
644
+ catch {
645
+ console.log('No crontab found.');
646
+ return;
647
+ }
648
+ const lines = existing.split('\n').filter((l) => !l.includes(CRON_MARKER));
649
+ const cleaned = lines.filter((l) => l.trim() !== '');
650
+ if (cleaned.length === 0) {
651
+ (0, child_process_1.execSync)('crontab -r 2>/dev/null', { encoding: 'utf-8' }).toString();
652
+ }
653
+ else {
654
+ cleaned.push('');
655
+ (0, child_process_1.execSync)('crontab -', { input: cleaned.join('\n'), encoding: 'utf-8' });
656
+ }
657
+ // Clean up watchdog script
658
+ const watchdogPath = getWatchdogPath();
659
+ if (fs.existsSync(watchdogPath))
660
+ fs.unlinkSync(watchdogPath);
661
+ console.log('Cron watchdog removed.');
662
+ }
663
+ function isCronInstalled() {
664
+ try {
665
+ const crontab = (0, child_process_1.execSync)('crontab -l 2>/dev/null', { encoding: 'utf-8' });
666
+ return crontab.includes(CRON_MARKER);
667
+ }
668
+ catch {
669
+ return false;
670
+ }
671
+ }
672
+ // --- listSupervisors: report every supervisor, not just the first match ---
673
+ //
674
+ // getServiceStatus() and isServiceInstalled() (above) intentionally return on
675
+ // the first match — that shape is left untouched, existing callers depend on
676
+ // it. But it means a coexisting second supervisor (e.g. a stale user systemd
677
+ // unit next to a correct system unit) is invisible to them. This is how a
678
+ // production incident happened: three supervisors + a manually-started
679
+ // daemon were all racing for the single-instance lock, and `status` reported
680
+ // only one of them.
681
+ //
682
+ // This function checks every supervisor slot independently and returns all
683
+ // of them, so `dialout status` (in cli.ts) can show the full picture.
684
+ /** Parse the script path out of a systemd unit's `ExecStart=<node> <script>` line. */
685
+ function parseScriptFromUnit(content) {
686
+ const m = content.match(/^ExecStart=(.+)$/m);
687
+ if (!m)
688
+ return null;
689
+ const parts = m[1].trim().split(/\s+/);
690
+ return parts.length > 0 ? parts[parts.length - 1] : null;
691
+ }
692
+ /** Parse the script path out of a launchd plist's <ProgramArguments> array (2nd <string>, after the node binary). */
693
+ function parseScriptFromPlist(content) {
694
+ const arrayMatch = content.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
695
+ if (!arrayMatch)
696
+ return null;
697
+ const strings = [...arrayMatch[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
698
+ return strings.length > 1 ? strings[1] : null;
699
+ }
700
+ /** Parse the script path out of the cron watchdog's `SCRIPT="…"` line. */
701
+ function parseScriptFromWatchdog(content) {
702
+ const m = content.match(/^SCRIPT="(.*)"$/m);
703
+ return m ? m[1] : null;
704
+ }
705
+ // A literal string compare is fooled by a symlinked global-install root
706
+ // (Homebrew-linked npm prefix, `npm link`, a package manager resolving
707
+ // through an intermediate symlink): two textually different paths can name
708
+ // the same real file, or vice versa. Canonicalize both sides via realpath
709
+ // before comparing. A *stale* target by definition may point at a file that
710
+ // was deleted (the pre-rename package no longer installed) — realpath throws
711
+ // for that, which is exactly the case staleness detection most needs to
712
+ // survive, so fall back to path.resolve() (no symlink following, but still
713
+ // normalizes `.`/`..`/separators) rather than letting the throw escape.
714
+ // Shared by listSupervisors() and repairWatchdog() so there is one place that
715
+ // decides what "the same script" means.
716
+ function normalizeScriptPath(p, realpath = fs.realpathSync) {
717
+ try {
718
+ return realpath(p);
719
+ }
720
+ catch {
721
+ return path.resolve(p);
722
+ }
723
+ }
724
+ /** Every supervisor present, not just the first match. */
725
+ function listSupervisors(deps = {}) {
726
+ const platform = deps.platform ?? os.platform();
727
+ const homedir = deps.homedir ?? os.homedir();
728
+ const existsSync = deps.existsSync ?? fs.existsSync;
729
+ const readFile = deps.readFileSync ?? ((p) => fs.readFileSync(p, 'utf-8'));
730
+ const launchdPidFn = deps.launchdPid ?? launchdPid;
731
+ const systemdActiveFn = deps.systemdActive ?? systemdActive;
732
+ const isCronInstalledFn = deps.isCronInstalled ?? isCronInstalled;
733
+ const agentScript = deps.agentScript ?? getAgentScript();
734
+ const realpath = deps.realpathSync ?? fs.realpathSync;
735
+ const readSafe = (p) => {
736
+ try {
737
+ return readFile(p);
738
+ }
739
+ catch {
740
+ return '';
741
+ }
742
+ };
743
+ // normalizeScriptPath (module-level, shared with repairWatchdog()) handles
744
+ // symlink-equivalent paths and deleted stale targets — see its comment.
745
+ const normalizedAgentScript = normalizeScriptPath(agentScript, realpath);
746
+ const isStale = (targetScript) => targetScript !== null && normalizeScriptPath(targetScript, realpath) !== normalizedAgentScript;
747
+ const results = [];
748
+ if (platform === 'darwin') {
749
+ const daemonPath = getLaunchDaemonPlistPath();
750
+ if (existsSync(daemonPath)) {
751
+ const pid = launchdPidFn(`system/${SERVICE_NAME}`);
752
+ const targetScript = parseScriptFromPlist(readSafe(daemonPath));
753
+ results.push({
754
+ kind: 'launchd-daemon',
755
+ path: daemonPath,
756
+ running: pid !== null,
757
+ pid,
758
+ atBoot: true,
759
+ targetScript: targetScript ?? '',
760
+ stale: isStale(targetScript),
761
+ });
762
+ }
763
+ const agentPath = getLaunchdPlistPath(homedir);
764
+ if (existsSync(agentPath)) {
765
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
766
+ const pid = launchdPidFn(`gui/${uid}/${SERVICE_NAME}`);
767
+ const targetScript = parseScriptFromPlist(readSafe(agentPath));
768
+ results.push({
769
+ kind: 'launchd-agent',
770
+ path: agentPath,
771
+ running: pid !== null,
772
+ pid,
773
+ atBoot: false,
774
+ targetScript: targetScript ?? '',
775
+ stale: isStale(targetScript),
776
+ });
777
+ }
778
+ }
779
+ else if (platform === 'linux') {
780
+ const systemUnitPath = getSystemdSystemUnitPath();
781
+ if (existsSync(systemUnitPath)) {
782
+ const { active, pid } = systemdActiveFn('system');
783
+ const targetScript = parseScriptFromUnit(readSafe(systemUnitPath));
784
+ results.push({
785
+ kind: 'systemd-system',
786
+ path: systemUnitPath,
787
+ running: active,
788
+ pid,
789
+ atBoot: true,
790
+ targetScript: targetScript ?? '',
791
+ stale: isStale(targetScript),
792
+ });
793
+ }
794
+ const userUnitPath = getSystemdUnitPath(homedir);
795
+ if (existsSync(userUnitPath)) {
796
+ const { active, pid } = systemdActiveFn('user');
797
+ const targetScript = parseScriptFromUnit(readSafe(userUnitPath));
798
+ results.push({
799
+ kind: 'systemd-user',
800
+ path: userUnitPath,
801
+ running: active,
802
+ pid,
803
+ atBoot: false,
804
+ targetScript: targetScript ?? '',
805
+ stale: isStale(targetScript),
806
+ });
807
+ }
808
+ }
809
+ if (isCronInstalledFn()) {
810
+ const watchdogPath = getWatchdogPath(homedir);
811
+ const targetScript = parseScriptFromWatchdog(readSafe(watchdogPath));
812
+ results.push({
813
+ kind: 'cron',
814
+ path: watchdogPath,
815
+ // Cron has no single long-lived process of its own to probe — its
816
+ // watchdog re-launches the agent on a schedule. Presence in the
817
+ // crontab (the only reason this entry exists at all) is what
818
+ // "running" means here.
819
+ running: true,
820
+ pid: null,
821
+ atBoot: false,
822
+ targetScript: targetScript ?? '',
823
+ stale: isStale(targetScript),
824
+ });
825
+ }
826
+ return results;
827
+ }
828
+ /**
829
+ * Backup filename that can't collide even if repair runs twice within the
830
+ * same second (or the same millisecond, under a mocked/frozen clock): start
831
+ * from an ISO-ish timestamp, then append an incrementing suffix until the
832
+ * candidate path doesn't already exist.
833
+ */
834
+ function nextBackupPath(watchdogPath) {
835
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
836
+ let candidate = `${watchdogPath}.bak-${stamp}`;
837
+ let n = 1;
838
+ while (fs.existsSync(candidate)) {
839
+ candidate = `${watchdogPath}.bak-${stamp}-${n}`;
840
+ n += 1;
841
+ }
842
+ return candidate;
843
+ }
844
+ // --- staleSupervisorAdvice: the correct next step for a stale supervisor ---
845
+ //
846
+ // repairWatchdog() above rewrites exactly one file: the cron watchdog's
847
+ // SCRIPT= line. It never touches a launchd plist or a systemd unit. So
848
+ // telling the operator to run "dialout repair" for a stale unit is a
849
+ // dead end — repair will report "nothing to repair" while the unit stays
850
+ // stale.
851
+ //
852
+ // installService() (this module) regenerates a unit's ExecStart /
853
+ // ProgramArguments from the current getAgentScript() on every call:
854
+ // installMacOSAgent() and installLinuxUser() do an unconditional
855
+ // fs.writeFileSync over any existing file; installMacOSDaemon() and
856
+ // installLinuxSystem() do an unconditional fs.copyFileSync (root path) or
857
+ // `sudo cp` (non-root path) over the existing target. None of the four
858
+ // check "does a file already exist" first — they always overwrite. So
859
+ // re-running the matching install-service variant (plain for a login-kind
860
+ // unit, --system for a boot-kind unit — see SupervisorInfo.atBoot) genuinely
861
+ // fixes a stale unit. uninstall-service is offered as the alternative for
862
+ // when the stale unit is a redundant extra supervisor that should just be
863
+ // removed instead of kept.
864
+ function staleSupervisorAdvice(s) {
865
+ if (s.kind === 'cron') {
866
+ return [
867
+ ` ⚠ ${s.path} points at a stale script (${s.targetScript}).`,
868
+ ' Run "dialout repair" to fix it.',
869
+ ];
870
+ }
871
+ const installCmd = s.atBoot ? 'dialout install-service --system' : 'dialout install-service';
872
+ return [
873
+ ` ⚠ ${s.path} points at a stale script (${s.targetScript}).`,
874
+ ` Run "${installCmd}" to regenerate it from the current build,`,
875
+ ' or "dialout uninstall-service" if this supervisor is redundant.',
876
+ ];
877
+ }
878
+ /**
879
+ * Rewrite `~/.dialout/watchdog.sh` if its SCRIPT= line has drifted from
880
+ * the current agent script. No-op (repaired: false) when the watchdog is
881
+ * absent or already correct — in particular, an already-correct watchdog is
882
+ * left byte-for-byte and mtime-untouched, and no backup is created.
883
+ */
884
+ function repairWatchdog() {
885
+ const watchdogPath = getWatchdogPath();
886
+ const agentScript = getAgentScript();
887
+ if (!fs.existsSync(watchdogPath)) {
888
+ return { repaired: false, from: '', to: agentScript };
889
+ }
890
+ const content = fs.readFileSync(watchdogPath, 'utf-8');
891
+ const currentTarget = parseScriptFromWatchdog(content);
892
+ const stale = currentTarget !== null && normalizeScriptPath(currentTarget) !== normalizeScriptPath(agentScript);
893
+ if (!stale) {
894
+ return { repaired: false, from: currentTarget ?? agentScript, to: agentScript };
895
+ }
896
+ // Preserve the stale file before touching it.
897
+ const backupPath = nextBackupPath(watchdogPath);
898
+ fs.copyFileSync(watchdogPath, backupPath);
899
+ // Reuse the one generator — this is the "single source of truth" the task
900
+ // requires, not a second hand-written copy of the watchdog body.
901
+ getWatchdogScript();
902
+ // Belt-and-suspenders: getWatchdogScript()'s write-then-rename already
903
+ // guarantees the final file is 0755 regardless of what mode the old,
904
+ // stale watchdog had (rename() carries the temp file's mode, not the
905
+ // replaced target's). Enforce it here too, explicitly, so repair's
906
+ // executable-bit guarantee does not rely solely on that implementation
907
+ // detail of the generator it happens to call.
908
+ fs.chmodSync(watchdogPath, 0o755);
909
+ return { repaired: true, from: currentTarget, to: agentScript };
910
+ }
911
+ //# sourceMappingURL=service-installer.js.map