@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
package/dist/cli.js ADDED
@@ -0,0 +1,1427 @@
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
+ const commander_1 = require("commander");
37
+ const fs = __importStar(require("fs"));
38
+ const os = __importStar(require("os"));
39
+ const path = __importStar(require("path"));
40
+ const readline = __importStar(require("readline"));
41
+ const https = __importStar(require("https"));
42
+ const http = __importStar(require("http"));
43
+ const config_1 = require("./config");
44
+ const websocket_1 = require("./websocket");
45
+ const service_installer_1 = require("./service-installer");
46
+ const update_check_1 = require("./update-check");
47
+ const cowork_1 = require("./cowork");
48
+ const ssh_local_network_1 = require("./ssh-local-network");
49
+ const terminal_detect_1 = require("./terminal-detect");
50
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
51
+ const program = new commander_1.Command();
52
+ // Shared between `status` (reporting) and `repair` (which reuses the same
53
+ // wording when it re-checks after fixing the watchdog).
54
+ const supervisorLabel = {
55
+ 'launchd-agent': 'launchd (user agent, at login)',
56
+ 'launchd-daemon': 'launchd (system daemon, at boot)',
57
+ 'systemd-user': 'systemd (user unit, at login)',
58
+ 'systemd-system': 'systemd (system unit, at boot)',
59
+ cron: 'cron watchdog',
60
+ };
61
+ program
62
+ .name('dialout')
63
+ .description(`Dialout Agent v${pkg.version} — connects your machine to the Dialout server
64
+
65
+ Enables remote port scanning, browser-based terminal sessions,
66
+ and filesystem browsing from the Dialout dashboard.
67
+
68
+ Quick Start:
69
+ $ dialout init # interactive setup (remote/local + API key)
70
+ $ dialout start # run in foreground (Ctrl+C to stop)
71
+ $ dialout start --daemon # run in background
72
+ $ dialout stop # stop background agent
73
+ $ dialout restart # restart background agent
74
+ $ dialout status # check agent & service status
75
+
76
+ Profiles (local + remote configs side by side):
77
+ $ dialout profiles # list saved profiles
78
+ $ dialout use local # switch active profile
79
+ $ dialout use remote # switch back
80
+ $ dialout start --profile local # one-off run, active profile unchanged
81
+
82
+ Service Management (auto-start on boot):
83
+ $ dialout install-service # install launchd (macOS) / systemd (Linux) service
84
+ $ dialout uninstall-service # remove OS service
85
+
86
+ Watchdog (auto-restart if agent dies):
87
+ $ dialout setup-cron # install cron watchdog (default: every 5 min)
88
+ $ dialout setup-cron -i 3 # check every 3 minutes
89
+ $ dialout remove-cron # remove cron watchdog
90
+ $ dialout repair # fix a stale watchdog (e.g. after a package rename)
91
+
92
+ Configuration:
93
+ $ dialout config show # print current config (API key masked)
94
+ $ dialout config set <key> <val> # set a config value
95
+ $ dialout config path # show config file path
96
+ $ dialout config reset # reset config to defaults (keeps API key)
97
+
98
+ Updates:
99
+ $ dialout update # update to latest version
100
+ $ dialout --version # show current version
101
+
102
+ Config file: ~/.dialout/config.json
103
+ Docs: https://www.dialout.dev/docs/agent`)
104
+ .version(pkg.version);
105
+ // Helper: readline question
106
+ function createRL() {
107
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
108
+ }
109
+ function ask(rl, q) {
110
+ return new Promise((resolve) => rl.question(q, resolve));
111
+ }
112
+ // Interactive arrow-key multi-select. Renders checklist.ts frames in raw mode.
113
+ // Returns the selected tokens, or null if the user cancels (Esc/q).
114
+ function promptChecklist(items) {
115
+ const { moveCursor, toggleAt, toggleAll, selectedTokens, renderChecklist } = require('./checklist');
116
+ const rl = require('readline');
117
+ let state = { items, cursor: 0 };
118
+ const legend = ' \x1b[2m↑/↓ move · Space toggle · a all · Enter confirm · Esc cancel\x1b[0m\n' +
119
+ ' \x1b[2mSelected = wrapped in tmux for remote (Shift/Fn+drag to copy). Unselected = fully native.\x1b[0m\n\n';
120
+ rl.emitKeypressEvents(process.stdin);
121
+ process.stdin.setRawMode(true);
122
+ process.stdin.resume();
123
+ let prevLines = 0;
124
+ const draw = () => {
125
+ const frame = renderChecklist(state);
126
+ if (prevLines > 0)
127
+ process.stdout.write(`\x1b[${prevLines}A`); // up N
128
+ process.stdout.write('\x1b[0J'); // clear to end of screen
129
+ process.stdout.write(frame + '\n');
130
+ prevLines = frame.split('\n').length;
131
+ };
132
+ process.stdout.write(legend);
133
+ draw();
134
+ return new Promise((resolve) => {
135
+ const onKey = (_s, key) => {
136
+ if (!key)
137
+ return;
138
+ if (key.ctrl && key.name === 'c') {
139
+ cleanup();
140
+ process.exit(130);
141
+ }
142
+ if (key.name === 'up')
143
+ state = moveCursor(state, -1);
144
+ else if (key.name === 'down')
145
+ state = moveCursor(state, 1);
146
+ else if (key.name === 'space')
147
+ state = toggleAt(state);
148
+ else if (key.name === 'a')
149
+ state = toggleAll(state);
150
+ else if (key.name === 'return')
151
+ return finish(selectedTokens(state));
152
+ else if (key.name === 'escape' || key.name === 'q')
153
+ return finish(null);
154
+ else
155
+ return;
156
+ draw();
157
+ };
158
+ const cleanup = () => {
159
+ process.stdin.off('keypress', onKey);
160
+ if (process.stdin.isTTY)
161
+ process.stdin.setRawMode(false);
162
+ process.stdin.pause();
163
+ };
164
+ const finish = (result) => {
165
+ cleanup();
166
+ process.stdout.write('\n');
167
+ resolve(result);
168
+ };
169
+ process.stdin.on('keypress', onKey);
170
+ });
171
+ }
172
+ // Helper: validate API key against the server
173
+ async function validateApiKey(serverUrl, apiKey) {
174
+ return new Promise((resolve) => {
175
+ try {
176
+ // Convert wss:// to https:// for the validation endpoint
177
+ const httpUrl = serverUrl
178
+ .replace(/^wss:\/\//, 'https://')
179
+ .replace(/^ws:\/\//, 'http://')
180
+ .replace(/\/ws\/?$/, '');
181
+ const url = `${httpUrl}/api/machines?userId=0`;
182
+ const mod = httpUrl.startsWith('https') ? https : http;
183
+ const req = mod.get(url, {
184
+ headers: { 'X-API-Key': apiKey },
185
+ timeout: 8000,
186
+ }, (res) => {
187
+ // The server doesn't validate API key on this endpoint directly,
188
+ // but we can test WebSocket connectivity by attempting a connection
189
+ resolve({ valid: true });
190
+ });
191
+ req.on('error', (err) => {
192
+ resolve({ valid: false, error: `Cannot reach server: ${err.message}` });
193
+ });
194
+ req.on('timeout', () => {
195
+ req.destroy();
196
+ resolve({ valid: false, error: 'Server connection timed out' });
197
+ });
198
+ }
199
+ catch (err) {
200
+ resolve({ valid: false, error: err.message });
201
+ }
202
+ });
203
+ }
204
+ // Helper: validate key via WebSocket handshake (most reliable)
205
+ async function validateKeyViaWS(serverUrl, apiKey) {
206
+ return new Promise((resolve) => {
207
+ try {
208
+ const WebSocket = require('ws');
209
+ const url = serverUrl.replace(/\/$/, '') + '/daemon';
210
+ const ws = new WebSocket(url, { headers: { 'X-API-Key': apiKey } });
211
+ const timeout = setTimeout(() => {
212
+ ws.close();
213
+ resolve({ valid: false, error: 'Connection timed out' });
214
+ }, 8000);
215
+ ws.on('open', () => {
216
+ // Connected — wait briefly for auth response
217
+ });
218
+ ws.on('message', (raw) => {
219
+ try {
220
+ const msg = JSON.parse(raw.toString());
221
+ if (msg.type === 'auth_ok') {
222
+ clearTimeout(timeout);
223
+ ws.close();
224
+ resolve({ valid: true });
225
+ }
226
+ else if (msg.type === 'auth_error' || msg.type === 'error') {
227
+ clearTimeout(timeout);
228
+ ws.close();
229
+ resolve({ valid: false, error: msg.error || 'Invalid API key' });
230
+ }
231
+ }
232
+ catch { }
233
+ });
234
+ ws.on('close', (code) => {
235
+ clearTimeout(timeout);
236
+ if (code === 4001 || code === 4003) {
237
+ resolve({ valid: false, error: 'Invalid API key — check your key and try again' });
238
+ }
239
+ // If we haven't resolved yet, connection closed unexpectedly
240
+ });
241
+ ws.on('error', (err) => {
242
+ clearTimeout(timeout);
243
+ resolve({ valid: false, error: `Connection failed: ${err.message}` });
244
+ });
245
+ }
246
+ catch (err) {
247
+ resolve({ valid: false, error: err.message });
248
+ }
249
+ });
250
+ }
251
+ // --- init ---
252
+ program
253
+ .command('init')
254
+ .description('Configure the agent with server URL and API key (interactive)')
255
+ .addHelpText('after', `
256
+ Steps:
257
+ 1. Go to Dialout web UI → MACHINES tab → click GENERATE KEY
258
+ 2. Copy the API key (shown only once)
259
+ 3. Run this command and paste the server URL + API key
260
+
261
+ Example:
262
+ $ dialout init
263
+ Server URL [wss://www.dialout.dev/ws]:
264
+ API Key: mch_K27F43P1HcN8AIO3EDHwkKvgZl5hkV1t`)
265
+ .action(async () => {
266
+ const rl = createRL();
267
+ const config = (0, config_1.loadConfig)();
268
+ console.log('');
269
+ console.log('\x1b[1mDialout Agent Setup\x1b[0m');
270
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
271
+ console.log('');
272
+ // Step 1: Which server? Saved as a named profile so local and remote
273
+ // configs coexist — switch anytime with "dialout use <profile>".
274
+ // Work out what "Remote" will actually default to BEFORE drawing the menu.
275
+ // Printing DEFAULT_SERVER_URL on the menu line and then defaulting the next
276
+ // prompt to a different saved URL is how someone ends up validating a key
277
+ // for one server against another and getting an unexplained 401.
278
+ const savedRemote = config.profiles?.remote?.serverUrl;
279
+ // A localhost URL saved in the "remote" profile is leftover from local
280
+ // testing — choosing Remote must always default to a real remote server.
281
+ const remoteDefault = savedRemote && !/^wss?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/.test(savedRemote)
282
+ ? savedRemote
283
+ : config_1.DEFAULT_SERVER_URL;
284
+ const remoteIsSaved = remoteDefault !== config_1.DEFAULT_SERVER_URL;
285
+ console.log('\x1b[1mWhere should this agent connect?\x1b[0m');
286
+ console.log('');
287
+ console.log(` \x1b[36m1)\x1b[0m Remote server — ${remoteDefault}` +
288
+ (remoteIsSaved ? ' \x1b[90m(saved)\x1b[0m' : ''));
289
+ if (remoteIsSaved) {
290
+ console.log(` \x1b[90mthis build ships with ${config_1.DEFAULT_SERVER_URL} — pick 3 to switch\x1b[0m`);
291
+ }
292
+ console.log(` \x1b[36m2)\x1b[0m Local dev — ${config_1.DEFAULT_LOCAL_SERVER_URL} (Dialout running on this machine)`);
293
+ console.log(' \x1b[36m3)\x1b[0m Custom URL');
294
+ console.log('');
295
+ const target = (await ask(rl, 'Choose [1-3, default 1]: ')).trim();
296
+ let profileName;
297
+ let defaultUrl;
298
+ if (target === '2') {
299
+ profileName = 'local';
300
+ defaultUrl = config.profiles?.local?.serverUrl || config_1.DEFAULT_LOCAL_SERVER_URL;
301
+ }
302
+ else if (target === '3') {
303
+ profileName = (await ask(rl, `Profile name [\x1b[36mcustom\x1b[0m]: `)).trim() || 'custom';
304
+ defaultUrl = config.profiles?.[profileName]?.serverUrl || config.serverUrl || config_1.DEFAULT_SERVER_URL;
305
+ }
306
+ else {
307
+ profileName = 'remote';
308
+ defaultUrl = remoteDefault;
309
+ }
310
+ const serverUrlInput = await ask(rl, `Server URL [\x1b[36m${defaultUrl}\x1b[0m]: `);
311
+ const serverUrl = (serverUrlInput.trim() || defaultUrl).replace(/\/$/, '');
312
+ // Step 2: API Key (per profile — a local server issues different keys
313
+ // than the remote one)
314
+ const existingKey = config.profiles?.[profileName]?.apiKey || '';
315
+ const keyPrompt = existingKey
316
+ ? `API Key [\x1b[90m****${existingKey.slice(-4)}\x1b[0m]: `
317
+ : 'API Key: ';
318
+ const apiKeyInput = (await ask(rl, keyPrompt)).trim();
319
+ const apiKey = apiKeyInput || existingKey;
320
+ if (!apiKey) {
321
+ console.error('\n\x1b[31mAPI key is required.\x1b[0m');
322
+ console.error('Generate one at: Dialout → MACHINES tab → GENERATE KEY');
323
+ rl.close();
324
+ process.exit(1);
325
+ }
326
+ // Step 3: Validate key
327
+ console.log('\n\x1b[90mValidating API key...\x1b[0m');
328
+ const result = await validateKeyViaWS(serverUrl, apiKey);
329
+ if (!result.valid) {
330
+ console.error(`\x1b[31mValidation failed: ${result.error}\x1b[0m`);
331
+ // A 401 here means the key is not registered on THIS server — not that
332
+ // the agent is misconfigured. It is almost always a key issued by one
333
+ // server being offered to another, so name the server that refused it
334
+ // rather than leaving the reader to guess from a bare status code.
335
+ if (/\b401\b/.test(result.error || '')) {
336
+ console.error('');
337
+ console.error(` The server at \x1b[36m${serverUrl}\x1b[0m does not recognise that key.`);
338
+ console.error(' Keys are issued per server, so a key from one Dialout server is');
339
+ console.error(' rejected by another. Check you are pointing at the right one, and');
340
+ console.error(' generate a key there under Settings → Machines.');
341
+ }
342
+ console.error('');
343
+ const retry = await ask(rl, 'Save config anyway? (y/N): ');
344
+ if (retry.toLowerCase() !== 'y') {
345
+ rl.close();
346
+ process.exit(1);
347
+ }
348
+ }
349
+ else {
350
+ console.log('\x1b[32mAPI key validated successfully!\x1b[0m');
351
+ }
352
+ const saved = (0, config_1.saveProfile)(profileName, { serverUrl, apiKey });
353
+ Object.assign(config, saved);
354
+ console.log(`\nSaved profile \x1b[36m${profileName}\x1b[0m (now active) → ${serverUrl}`);
355
+ console.log(`Config file: ${(0, config_1.getConfigPath)()}`);
356
+ const otherProfiles = Object.keys(saved.profiles || {}).filter((n) => n !== profileName);
357
+ if (otherProfiles.length > 0) {
358
+ console.log(`Switch anytime: dialout use ${otherProfiles[0]}`);
359
+ }
360
+ // Step 4: Ask how to start
361
+ console.log('');
362
+ console.log('\x1b[1mHow would you like to run the agent?\x1b[0m');
363
+ console.log('');
364
+ console.log(' \x1b[36m1)\x1b[0m Foreground — run now in this terminal (for testing)');
365
+ console.log(' \x1b[36m2)\x1b[0m Service — start automatically (launchd/systemd; asks boot vs login)');
366
+ console.log(' \x1b[36m3)\x1b[0m Cron Watchdog — auto-restart if it dies (cron job)');
367
+ console.log(' \x1b[36m4)\x1b[0m Skip — configure later');
368
+ console.log('');
369
+ const choice = await ask(rl, 'Choose [1-4]: ');
370
+ // Closed here because every branch below opens its own reader when it
371
+ // needs one; leaving this one open would keep the process alive.
372
+ rl.close();
373
+ switch (choice.trim()) {
374
+ case '1':
375
+ console.log('\n\x1b[90mStarting in foreground (Ctrl+C to stop)...\x1b[0m\n');
376
+ (0, websocket_1.connect)(config, () => {
377
+ console.log('[dialout] Connected and ready.');
378
+ });
379
+ process.on('SIGINT', () => { (0, websocket_1.disconnect)(); process.exit(0); });
380
+ process.on('SIGTERM', () => { (0, websocket_1.disconnect)(); process.exit(0); });
381
+ break;
382
+ case '2': {
383
+ // Same decision, and the same follow-up, as `install-service`.
384
+ const system = await chooseServiceScope();
385
+ (0, service_installer_1.installService)({ system });
386
+ applySshLocalNetworkWorkaround();
387
+ // Check if cron should also be set up
388
+ if (!(0, service_installer_1.isCronInstalled)()) {
389
+ console.log('\n\x1b[33mTip:\x1b[0m Add a cron watchdog for extra reliability:');
390
+ console.log(' dialout setup-cron');
391
+ }
392
+ break;
393
+ }
394
+ case '3': {
395
+ const intervalStr = await (async () => {
396
+ const rl2 = createRL();
397
+ const v = await ask(rl2, `Check interval in minutes [\x1b[36m5\x1b[0m]: `);
398
+ rl2.close();
399
+ return v;
400
+ })();
401
+ const interval = parseInt(intervalStr, 10) || config.cronInterval || 5;
402
+ config.cronInterval = interval;
403
+ (0, config_1.saveConfig)(config);
404
+ // Start daemon first
405
+ const { fork } = require('child_process');
406
+ const child = fork(path.resolve(__dirname, 'index.js'), [], {
407
+ detached: true,
408
+ stdio: 'ignore',
409
+ });
410
+ child.unref();
411
+ const pidFile = (0, config_1.getPidFile)();
412
+ fs.writeFileSync(pidFile, String(child.pid));
413
+ console.log(`\nAgent started in background (PID: ${child.pid})`);
414
+ // Install cron
415
+ (0, service_installer_1.installCron)(interval);
416
+ break;
417
+ }
418
+ case '4':
419
+ default:
420
+ console.log('\nRun "dialout start" when ready.');
421
+ break;
422
+ }
423
+ });
424
+ /**
425
+ * Decide whether a service install is a boot service or a per-user one.
426
+ *
427
+ * Shared by `init` and `install-service` on purpose. They used to disagree:
428
+ * `init` offered "Service — auto-start on boot" and then called
429
+ * installService() with no arguments, which installs the per-user LaunchAgent
430
+ * that only starts at LOGIN. Someone following the first-run prompt got a
431
+ * weaker service than the one they were promised, and only found out by
432
+ * rebooting and noticing the agent was not there.
433
+ */
434
+ async function chooseServiceScope() {
435
+ if (process.platform === 'linux' && (0, service_installer_1.defaultLinuxScope)() === 'system') {
436
+ // Root on Linux: no password to pay and nothing to weigh.
437
+ console.log('Running as root — installing the boot service (starts at boot, survives logout).');
438
+ console.log('Use --login for a per-user service instead.\n');
439
+ return true;
440
+ }
441
+ if (!process.stdin.isTTY) {
442
+ // Non-interactive: the per-user service is the only one installable
443
+ // without a password.
444
+ return false;
445
+ }
446
+ const rl = createRL();
447
+ const answer = await ask(rl, 'Start the agent at boot, before you log in? Requires admin password. [y/N]: ');
448
+ rl.close();
449
+ const system = /^y(es)?$/i.test(answer.trim());
450
+ if (!system && process.platform === 'linux') {
451
+ console.log('Installing the per-user service — lingering will be enabled so it survives logout.');
452
+ }
453
+ return system;
454
+ }
455
+ // --- profiles ---
456
+ program
457
+ .command('profiles')
458
+ .description('List saved connection profiles (local/remote/…)')
459
+ .action(() => {
460
+ const config = (0, config_1.loadConfig)();
461
+ const names = Object.keys(config.profiles || {});
462
+ if (names.length === 0) {
463
+ console.log('No profiles saved yet. Run: dialout init');
464
+ return;
465
+ }
466
+ console.log('');
467
+ console.log('Connection Profiles');
468
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
469
+ for (const name of names) {
470
+ const p = config.profiles[name];
471
+ const active = name === config.activeProfile;
472
+ const marker = active ? '\x1b[32m●\x1b[0m' : ' ';
473
+ console.log(` ${marker} ${name.padEnd(10)} ${p.serverUrl} \x1b[90m(key ****${p.apiKey.slice(-4)})\x1b[0m${active ? ' \x1b[32mactive\x1b[0m' : ''}`);
474
+ }
475
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
476
+ console.log('Switch with: dialout use <name>');
477
+ console.log('');
478
+ });
479
+ // --- use ---
480
+ program
481
+ .command('use <profile>')
482
+ .description('Switch the active connection profile (e.g. local, remote)')
483
+ .addHelpText('after', `
484
+ Examples:
485
+ $ dialout use local # point the agent at your local Dialout
486
+ $ dialout use remote # point it back at the live server
487
+ $ dialout restart # apply to a running background agent`)
488
+ .action((name) => {
489
+ const config = (0, config_1.loadConfig)();
490
+ const profile = config.profiles?.[name];
491
+ if (!profile) {
492
+ const names = Object.keys(config.profiles || {});
493
+ console.error(`Unknown profile: ${name}`);
494
+ console.error(names.length ? `Available: ${names.join(', ')}` : 'No profiles yet — run: dialout init');
495
+ process.exit(1);
496
+ }
497
+ (0, config_1.saveProfile)(name, profile, true);
498
+ console.log(`Active profile: \x1b[36m${name}\x1b[0m → ${profile.serverUrl}`);
499
+ console.log('If the agent is running, apply with: dialout restart');
500
+ });
501
+ // --- start ---
502
+ program
503
+ .command('start')
504
+ .description('Start the agent and connect to the Dialout server')
505
+ .option('--daemon', 'Run in background (fork process, survives terminal close)')
506
+ .option('--profile <name>', 'Use a saved profile for this run only (active profile unchanged)')
507
+ .addHelpText('after', `
508
+ Modes:
509
+ dialout start Foreground — logs to terminal, Ctrl+C to stop
510
+ dialout start --daemon Background — forks process, use "stop" to end
511
+ dialout install-service Permanent — auto-starts on boot (launchd/systemd)
512
+ dialout setup-cron Watchdog — cron checks every N minutes, restarts if dead
513
+
514
+ Examples:
515
+ $ dialout start # test connection in foreground
516
+ $ dialout start --profile local # one-off run against local Dialout
517
+ $ dialout start --daemon # run in background for this session
518
+ $ dialout stop # stop background agent`)
519
+ .action(async (opts) => {
520
+ await (0, update_check_1.checkForUpdate)();
521
+ let config = (0, config_1.loadConfig)();
522
+ if (opts.profile) {
523
+ if (!config.profiles?.[opts.profile]) {
524
+ console.error(`Unknown profile: ${opts.profile}`);
525
+ console.error(`Available: ${Object.keys(config.profiles || {}).join(', ') || '(none — run: dialout init)'}`);
526
+ process.exit(1);
527
+ }
528
+ config = (0, config_1.applyProfile)(config, opts.profile);
529
+ console.log(`[dialout] Using profile "${opts.profile}" → ${config.serverUrl}`);
530
+ }
531
+ if (!config.serverUrl || !config.apiKey) {
532
+ console.error('Not configured. Run: dialout init');
533
+ process.exit(1);
534
+ }
535
+ if (opts.daemon) {
536
+ const { fork } = require('child_process');
537
+ const child = fork(path.resolve(__dirname, 'index.js'), [], {
538
+ detached: true,
539
+ stdio: 'ignore',
540
+ env: opts.profile
541
+ ? { ...process.env, DEVDASH_AGENT_PROFILE: opts.profile }
542
+ : process.env,
543
+ });
544
+ child.unref();
545
+ const pidFile = (0, config_1.getPidFile)();
546
+ fs.writeFileSync(pidFile, String(child.pid));
547
+ // Verify before claiming. The child exits within milliseconds when
548
+ // another supervisor (a systemd/launchd service, another daemon) already
549
+ // holds the single-instance lock for this server URL — and its stdio is
550
+ // 'ignore', so the "Already running — exiting" line it prints goes
551
+ // nowhere. Reporting the fork's pid unconditionally announced success
552
+ // for a process that was already dead, which reads as "the daemon keeps
553
+ // dying" rather than "something else is already running it".
554
+ await new Promise((resolve) => setTimeout(resolve, 900));
555
+ const stillAlive = (() => {
556
+ try {
557
+ process.kill(child.pid, 0);
558
+ return true;
559
+ }
560
+ catch {
561
+ return false;
562
+ }
563
+ })();
564
+ if (!stillAlive) {
565
+ try {
566
+ fs.unlinkSync(pidFile);
567
+ }
568
+ catch { }
569
+ console.error('\x1b[33mThe agent exited immediately.\x1b[0m');
570
+ console.error('Usually this means another supervisor is already running it for this server URL.');
571
+ console.error('Check what is running: dialout status');
572
+ process.exit(1);
573
+ }
574
+ console.log(`Agent started in background (PID: ${child.pid})`);
575
+ console.log('Stop with: dialout stop');
576
+ // Suggest cron if not installed
577
+ if (!(0, service_installer_1.isCronInstalled)() && !(0, service_installer_1.isServiceInstalled)()) {
578
+ console.log('\n\x1b[33mTip:\x1b[0m Set up auto-restart with: dialout setup-cron');
579
+ }
580
+ process.exit(0);
581
+ }
582
+ // Foreground mode
583
+ console.log('[dialout] Starting in foreground (Ctrl+C to stop)...');
584
+ (0, websocket_1.connect)(config, () => {
585
+ console.log('[dialout] Connected and ready.');
586
+ });
587
+ process.on('SIGINT', () => {
588
+ console.log('\n[dialout] Stopping...');
589
+ (0, websocket_1.disconnect)();
590
+ process.exit(0);
591
+ });
592
+ process.on('SIGTERM', () => {
593
+ (0, websocket_1.disconnect)();
594
+ process.exit(0);
595
+ });
596
+ });
597
+ // --- stop ---
598
+ program
599
+ .command('stop')
600
+ .description('Stop the background agent (started with --daemon)')
601
+ .addHelpText('after', `
602
+ Stops the agent that was started with "dialout start --daemon".
603
+ Has no effect on service-installed agents — use uninstall-service for those.`)
604
+ .action(() => {
605
+ const pidFile = (0, config_1.getPidFile)();
606
+ if (!fs.existsSync(pidFile)) {
607
+ console.log('No running agent found.');
608
+ console.log('If running as service: dialout uninstall-service');
609
+ return;
610
+ }
611
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
612
+ try {
613
+ process.kill(pid, 'SIGTERM');
614
+ console.log(`Agent stopped (PID: ${pid})`);
615
+ }
616
+ catch (err) {
617
+ if (err.code === 'ESRCH') {
618
+ console.log('Agent is not running (stale PID file removed).');
619
+ }
620
+ else {
621
+ console.error('Failed to stop agent:', err.message);
622
+ }
623
+ }
624
+ fs.unlinkSync(pidFile);
625
+ });
626
+ // --- restart ---
627
+ program
628
+ .command('restart')
629
+ .description('Restart the background agent (stop + start --daemon)')
630
+ .addHelpText('after', `
631
+ Convenience command that stops any running daemon and starts a fresh one.
632
+ Equivalent to running "dialout stop && dialout start --daemon".
633
+
634
+ Examples:
635
+ $ dialout restart # restart background agent
636
+ $ dialout status # verify it's running`)
637
+ .action(async () => {
638
+ await (0, update_check_1.checkForUpdate)();
639
+ const config = (0, config_1.loadConfig)();
640
+ if (!config.serverUrl || !config.apiKey) {
641
+ console.error('Not configured. Run: dialout init');
642
+ process.exit(1);
643
+ }
644
+ // Stop existing daemon if running
645
+ const pidFile = (0, config_1.getPidFile)();
646
+ if (fs.existsSync(pidFile)) {
647
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
648
+ try {
649
+ process.kill(pid, 'SIGTERM');
650
+ console.log(`Stopped previous agent (PID: ${pid})`);
651
+ }
652
+ catch (err) {
653
+ if (err.code !== 'ESRCH') {
654
+ console.error('Failed to stop agent:', err.message);
655
+ }
656
+ }
657
+ fs.unlinkSync(pidFile);
658
+ }
659
+ // Start new daemon
660
+ const { fork } = require('child_process');
661
+ const child = fork(path.resolve(__dirname, 'index.js'), [], {
662
+ detached: true,
663
+ stdio: 'ignore',
664
+ });
665
+ child.unref();
666
+ fs.writeFileSync(pidFile, String(child.pid));
667
+ console.log(`Agent restarted in background (PID: ${child.pid})`);
668
+ process.exit(0);
669
+ });
670
+ // --- status ---
671
+ program
672
+ .command('status')
673
+ .description('Show agent connection status, config, and service state')
674
+ .addHelpText('after', `
675
+ Shows: server URL, API key (masked), config path, service state, cron state, process state.`)
676
+ .action(() => {
677
+ const config = (0, config_1.loadConfig)();
678
+ const pidFile = (0, config_1.getPidFile)();
679
+ const svc = (0, service_installer_1.getServiceStatus)();
680
+ const cronInstalled = (0, service_installer_1.isCronInstalled)();
681
+ const kindLabel = {
682
+ 'launchd-agent': 'launchd, at login',
683
+ 'launchd-daemon': 'launchd, at boot',
684
+ 'systemd-user': 'systemd, at login',
685
+ 'systemd-system': 'systemd, at boot',
686
+ };
687
+ console.log('');
688
+ console.log('Dialout Agent Status');
689
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
690
+ const profileNames = Object.keys(config.profiles || {});
691
+ console.log(` Profile: ${config.activeProfile ? `\x1b[36m${config.activeProfile}\x1b[0m` : '(none)'}${profileNames.length > 1 ? ` \x1b[90m(saved: ${profileNames.join(', ')})\x1b[0m` : ''}`);
692
+ console.log(` Server: ${config.serverUrl || '(not configured)'}`);
693
+ console.log(` API Key: ${config.apiKey ? '****' + config.apiKey.slice(-4) : '(not set)'}`);
694
+ console.log(` Config: ${(0, config_1.getConfigPath)()}`);
695
+ console.log(` Service: ${svc.installed ? `\x1b[32minstalled\x1b[0m${svc.kind ? ` (${kindLabel[svc.kind]})` : ''}` : 'not installed'}`);
696
+ console.log(` Cron: ${cronInstalled ? `\x1b[32minstalled\x1b[0m (every ${config.cronInterval || 5} min)` : 'not installed'}`);
697
+ // Every supervisor that could be launching this agent, not just the
698
+ // first match (getServiceStatus()/isServiceInstalled() intentionally
699
+ // stop at the first one). Detection only — this never stops a unit,
700
+ // kills a process, or edits the crontab.
701
+ const supervisors = (0, service_installer_1.listSupervisors)();
702
+ if (supervisors.length > 1) {
703
+ console.log('');
704
+ console.log('\x1b[33m ⚠ Multiple supervisors are managing this agent:\x1b[0m');
705
+ for (const s of supervisors) {
706
+ // "running" means a live process was found for launchd/systemd
707
+ // (svc.pid !== null); cron has no live process of its own to
708
+ // probe — it's a scheduled trigger, not a fourth daemon — so it
709
+ // gets its own word instead of borrowing "running"/"not running"
710
+ // and implying it's an active process racing the others.
711
+ const state = s.kind === 'cron'
712
+ ? `scheduled (fires every ${config.cronInterval || 5} min)`
713
+ : (s.pid !== null ? `running (PID ${s.pid})` : (s.running ? 'running' : 'not running'));
714
+ console.log(`\x1b[33m - ${supervisorLabel[s.kind]}: ${s.path} — ${state}${s.stale ? ' [STALE]' : ''}\x1b[0m`);
715
+ }
716
+ console.log('\x1b[33m More than one supervisor causes the connect/disconnect (1006) flapping you may be seeing —\x1b[0m');
717
+ console.log('\x1b[33m the single-instance lock will keep only one process alive and make all the others exit.\x1b[0m');
718
+ }
719
+ // A systemd user unit without lingering is a time bomb that `status`
720
+ // used to report as a healthy installed service: systemd-logind stops
721
+ // user@<uid>.service when the last session ends, so the agent dies on
722
+ // every logout and silently returns on the next login. Nothing else in
723
+ // this output would ever hint at it.
724
+ if (process.platform === 'linux' && supervisors.some((s) => s.kind === 'systemd-user')) {
725
+ const user = os.userInfo().username;
726
+ if (!(0, service_installer_1.isLingerEnabled)(user)) {
727
+ console.log('');
728
+ console.log('\x1b[33m ⚠ Installed as a systemd USER unit with lingering OFF.\x1b[0m');
729
+ console.log('\x1b[33m systemd stops it when your last session ends — the agent dies every time you log out\x1b[0m');
730
+ console.log('\x1b[33m and only comes back when you log in again.\x1b[0m');
731
+ console.log(`\x1b[33m Fix: sudo loginctl enable-linger ${user}\x1b[0m`);
732
+ console.log('\x1b[33m Or: dialout install-service --system (runs at boot, in system.slice)\x1b[0m');
733
+ }
734
+ }
735
+ for (const s of supervisors.filter((s) => s.stale)) {
736
+ console.log('');
737
+ for (const line of (0, service_installer_1.staleSupervisorAdvice)(s)) {
738
+ console.log(`\x1b[33m${line}\x1b[0m`);
739
+ }
740
+ }
741
+ if (svc.installed) {
742
+ // The service manager (launchd/systemd) owns the process — ask it, not the pidfile.
743
+ if (svc.running) {
744
+ console.log(` Process: \x1b[32mrunning\x1b[0m (PID: ${svc.pid}, managed by ${svc.kind?.startsWith('launchd') ? 'launchd' : 'systemd'})`);
745
+ }
746
+ else {
747
+ console.log(` Process: \x1b[31mnot running\x1b[0m (service installed but stopped — check logs in ~/.dialout/logs)`);
748
+ }
749
+ }
750
+ else if (fs.existsSync(pidFile)) {
751
+ // Manual background mode (dialout start --daemon)
752
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
753
+ try {
754
+ process.kill(pid, 0);
755
+ console.log(` Process: \x1b[32mrunning\x1b[0m (PID: ${pid}, background)`);
756
+ }
757
+ catch {
758
+ console.log(` Process: not running (stale PID)`);
759
+ }
760
+ }
761
+ else {
762
+ console.log(` Process: not running`);
763
+ }
764
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
765
+ console.log('');
766
+ });
767
+ // On macOS the tmux server is the process Local Network Privacy judges, so
768
+ // every tmux session on the machine — including ones the user opens from their
769
+ // own terminal app — is denied LAN access and cannot ssh to a local git remote
770
+ // (the measured long form is in ssh-local-network.ts). Installing the service
771
+ // is the moment the machine starts hosting tmux sessions, so it is also the
772
+ // moment to install the ssh_config default that works around it.
773
+ function applySshLocalNetworkWorkaround() {
774
+ if (!(0, ssh_local_network_1.sshLocalNetworkApplies)())
775
+ return;
776
+ const target = (0, ssh_local_network_1.defaultSshConfigPath)();
777
+ try {
778
+ const result = (0, ssh_local_network_1.installSshLocalNetworkBlock)(target);
779
+ // 'skipped-existing' means their own ConnectTimeout already wins;
780
+ // 'updated' means the block was already there. Neither is news.
781
+ if (result === 'skipped-existing' || result === 'updated')
782
+ return;
783
+ console.log(` ssh: added a ConnectTimeout default to ${target}`);
784
+ console.log(' (macOS denies local-network access to the tmux server, so without this');
785
+ console.log(' git/ssh to a LAN host fails inside every tmux session on this machine.');
786
+ console.log(' To fix it properly, allow tmux under System Settings >');
787
+ console.log(' Privacy & Security > Local Network.)');
788
+ }
789
+ catch (err) {
790
+ // Never fail an install over this — the service itself is still fine.
791
+ console.log(` ssh: could not update ${target} (${err.message})`);
792
+ }
793
+ }
794
+ function removeSshLocalNetworkWorkaround() {
795
+ const target = (0, ssh_local_network_1.defaultSshConfigPath)();
796
+ try {
797
+ if (!fs.existsSync(target))
798
+ return;
799
+ const before = fs.readFileSync(target, 'utf-8');
800
+ const after = (0, ssh_local_network_1.removeSshLocalNetworkBlock)(before);
801
+ if (after === before)
802
+ return;
803
+ fs.writeFileSync(target, after);
804
+ console.log(` ssh: removed the ConnectTimeout block from ${target}`);
805
+ }
806
+ catch {
807
+ // Best effort — a leftover block is harmless.
808
+ }
809
+ }
810
+ // --- install-service ---
811
+ program
812
+ .command('install-service')
813
+ .description('Install as OS service — auto-starts on login (or boot with --system)')
814
+ .option('--system', 'Run at boot, before login (LaunchDaemon / systemd system unit; needs sudo)')
815
+ .option('--login', 'Per-user service that starts at login (skip the interactive prompt)')
816
+ .addHelpText('after', `
817
+ Installs the agent as a system service so it starts automatically.
818
+
819
+ Default (per-user, starts at LOGIN):
820
+ macOS — LaunchAgent at ~/Library/LaunchAgents/com.dialout.agent.plist
821
+ Linux — systemd user service at ~/.config/systemd/user/dialout.service
822
+
823
+ --system (starts at BOOT, before anyone logs in — requires sudo):
824
+ macOS — LaunchDaemon at /Library/LaunchDaemons/com.dialout.agent.plist
825
+ Linux — systemd system unit at /etc/systemd/system/dialout.service
826
+
827
+ Must run "dialout init" first to configure server URL and API key.
828
+
829
+ With no flag on an interactive terminal, it asks whether to start at boot
830
+ (sudo) or at login.
831
+
832
+ Examples:
833
+ $ dialout install-service # ask: boot or login
834
+ $ dialout install-service --system # start at boot (prompts for sudo)
835
+ $ dialout install-service --login # start at login, no prompt
836
+
837
+ To remove: dialout uninstall-service`)
838
+ .action(async (opts) => {
839
+ const config = (0, config_1.loadConfig)();
840
+ if (!config.serverUrl || !config.apiKey) {
841
+ console.error('Not configured. Run "dialout init" first.');
842
+ process.exit(1);
843
+ }
844
+ // Explicit flags win; otherwise the same decision `init` makes, from the
845
+ // same helper, so the two prompts cannot drift apart again.
846
+ const system = opts.system ? true : opts.login ? false : await chooseServiceScope();
847
+ (0, service_installer_1.installService)({ system });
848
+ applySshLocalNetworkWorkaround();
849
+ process.exit(0);
850
+ });
851
+ // --- uninstall-service ---
852
+ program
853
+ .command('uninstall-service')
854
+ .description('Remove OS service — stops auto-start on boot')
855
+ .addHelpText('after', `
856
+ Removes the service installed by "install-service".
857
+ The agent will no longer start automatically on login/boot.
858
+
859
+ A boot service (install-service --system) is owned by root, so this may
860
+ prompt for your password — the same way installing it did.
861
+
862
+ This removes the SERVICE only. If a cron watchdog or a manual daemon is also
863
+ running the agent, it keeps running; the command says so and names the fix.`)
864
+ .action(() => {
865
+ const result = (0, service_installer_1.uninstallService)();
866
+ // Exiting 0 after removing nothing is what made this look like it worked
867
+ // while the daemon kept running. Fail loudly instead.
868
+ if (result.pending.length > 0) {
869
+ console.error('\n\x1b[31mService NOT removed.\x1b[0m Run the commands above, then: dialout status');
870
+ process.exitCode = 1;
871
+ return;
872
+ }
873
+ removeSshLocalNetworkWorkaround();
874
+ if (!result.removed)
875
+ return;
876
+ // The service is gone, but it is not the only thing that can start the
877
+ // agent. Point at the leftovers rather than letting `status` surprise them.
878
+ const leftovers = (0, service_installer_1.listSupervisors)().filter((s) => s.kind === 'cron');
879
+ if (leftovers.length > 0) {
880
+ console.log('\n\x1b[33mA cron watchdog is still installed and will restart the agent.\x1b[0m');
881
+ console.log(' Remove it with: dialout remove-cron');
882
+ }
883
+ if (fs.existsSync((0, config_1.getPidFile)())) {
884
+ const pid = parseInt(fs.readFileSync((0, config_1.getPidFile)(), 'utf-8').trim(), 10);
885
+ let alive = false;
886
+ try {
887
+ process.kill(pid, 0);
888
+ alive = true;
889
+ }
890
+ catch { /* stale */ }
891
+ if (alive) {
892
+ console.log(`\n\x1b[33mA manually started agent is still running (PID ${pid}).\x1b[0m`);
893
+ console.log(' Stop it with: dialout stop');
894
+ }
895
+ }
896
+ });
897
+ // --- setup-cron ---
898
+ program
899
+ .command('setup-cron')
900
+ .description('Install cron watchdog — auto-restarts agent if it dies')
901
+ .option('-i, --interval <minutes>', 'Check interval in minutes (default: 5)', '5')
902
+ .addHelpText('after', `
903
+ Creates a cron job that checks if the agent is running every N minutes.
904
+ If the agent is dead, the watchdog restarts it automatically.
905
+
906
+ The default interval is 5 minutes. You can customize it:
907
+ $ dialout setup-cron # every 5 minutes (default)
908
+ $ dialout setup-cron -i 3 # every 3 minutes
909
+ $ dialout setup-cron --interval 10 # every 10 minutes
910
+
911
+ To remove: dialout remove-cron`)
912
+ .action((opts) => {
913
+ const config = (0, config_1.loadConfig)();
914
+ if (!config.serverUrl || !config.apiKey) {
915
+ console.error('Not configured. Run "dialout init" first.');
916
+ process.exit(1);
917
+ }
918
+ // A launchd/systemd service already supervises the agent and auto-restarts
919
+ // it on death. Adding a cron watchdog on top means TWO independent
920
+ // supervisors — the exact configuration that used to spawn duplicate
921
+ // daemons (the service's process isn't tracked in daemon.pid, so the
922
+ // watchdog thinks nothing is running and forks a second one). Refuse.
923
+ const svc = (0, service_installer_1.getServiceStatus)();
924
+ if (svc.installed) {
925
+ console.log('An OS service is already installed — it auto-restarts the agent on its own.');
926
+ console.log('A cron watchdog would be a second, conflicting supervisor, so this is a no-op.');
927
+ console.log('To use cron instead, run: dialout uninstall-service first.');
928
+ process.exit(0);
929
+ }
930
+ const interval = parseInt(opts.interval, 10) || 5;
931
+ config.cronInterval = interval;
932
+ (0, config_1.saveConfig)(config);
933
+ // Start daemon if not running. (Even if a duplicate slips through here, the
934
+ // agent's per-URL single-instance lock makes the extra process exit at once.)
935
+ const pidFile = (0, config_1.getPidFile)();
936
+ let isRunning = false;
937
+ if (fs.existsSync(pidFile)) {
938
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
939
+ try {
940
+ process.kill(pid, 0);
941
+ isRunning = true;
942
+ }
943
+ catch { }
944
+ }
945
+ if (!isRunning) {
946
+ const { fork } = require('child_process');
947
+ const child = fork(path.resolve(__dirname, 'index.js'), [], {
948
+ detached: true,
949
+ stdio: 'ignore',
950
+ });
951
+ child.unref();
952
+ fs.writeFileSync(pidFile, String(child.pid));
953
+ console.log(`Agent started in background (PID: ${child.pid})`);
954
+ }
955
+ (0, service_installer_1.installCron)(interval);
956
+ process.exit(0);
957
+ });
958
+ // --- remove-cron ---
959
+ program
960
+ .command('remove-cron')
961
+ .description('Remove cron watchdog — stops auto-restart')
962
+ .addHelpText('after', `
963
+ Removes the cron job installed by "setup-cron".
964
+ The agent will no longer auto-restart if it dies.
965
+ Note: this does NOT stop the currently running agent.`)
966
+ .action(() => {
967
+ (0, service_installer_1.uninstallCron)();
968
+ });
969
+ // --- repair ---
970
+ program
971
+ .command('repair')
972
+ .description('Rewrite a stale cron watchdog left by an old install/rename')
973
+ .addHelpText('after', `
974
+ Fixes exactly one thing: a cron watchdog (~/.dialout/watchdog.sh)
975
+ whose SCRIPT= line still points at an old, renamed, or uninstalled
976
+ package build — a machine can end up resurrecting a deprecated version
977
+ every few minutes, fighting the real daemon for the same registration.
978
+
979
+ Repair rewrites ONLY that path. It never deletes a unit file, kills a
980
+ process, or edits the crontab. When more than one supervisor (launchd /
981
+ systemd / cron) is managing this agent, repair explains what to remove
982
+ and stops there — it does not choose for you.
983
+
984
+ Examples:
985
+ $ dialout repair
986
+ $ dialout status # see current supervisor state`)
987
+ .action(() => {
988
+ let repairResult;
989
+ try {
990
+ repairResult = (0, service_installer_1.repairWatchdog)();
991
+ }
992
+ catch (err) {
993
+ const message = err instanceof Error ? err.message : String(err);
994
+ console.log('');
995
+ console.log('Dialout Agent Repair');
996
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
997
+ console.log(`\x1b[31m ✗ Could not repair the watchdog: ${message}\x1b[0m`);
998
+ console.log(' Nothing was changed. Check that ~/.dialout/watchdog.sh is a regular,');
999
+ console.log(' readable file you have permission to modify, then try again.');
1000
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
1001
+ console.log('');
1002
+ process.exitCode = 1;
1003
+ return;
1004
+ }
1005
+ const { repaired, from, to } = repairResult;
1006
+ console.log('');
1007
+ console.log('Dialout Agent Repair');
1008
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
1009
+ if (repaired) {
1010
+ console.log('\x1b[32m ✓ Rewrote stale watchdog:\x1b[0m ~/.dialout/watchdog.sh');
1011
+ console.log(` from: ${from}`);
1012
+ console.log(` to: ${to}`);
1013
+ console.log(' (previous version backed up alongside it as watchdog.sh.bak-<timestamp>)');
1014
+ }
1015
+ else {
1016
+ console.log(' Watchdog check: \x1b[32mnothing to repair\x1b[0m (absent, or SCRIPT= already correct).');
1017
+ }
1018
+ // Detection only, same as `status` — repair never removes a supervisor
1019
+ // itself (Constraint 3). This just re-reports the full picture after
1020
+ // fixing the one thing it's safe to fix automatically.
1021
+ const supervisors = (0, service_installer_1.listSupervisors)();
1022
+ const staleRemaining = supervisors.filter((s) => s.stale);
1023
+ if (supervisors.length > 1) {
1024
+ console.log('');
1025
+ console.log('\x1b[33m ⚠ Multiple supervisors are managing this agent:\x1b[0m');
1026
+ for (const s of supervisors) {
1027
+ const state = s.kind === 'cron'
1028
+ ? 'scheduled watchdog'
1029
+ : (s.pid !== null ? `running (PID ${s.pid})` : (s.running ? 'running' : 'not running'));
1030
+ console.log(`\x1b[33m - ${supervisorLabel[s.kind]}: ${s.path} — ${state}${s.stale ? ' [STALE]' : ''}\x1b[0m`);
1031
+ }
1032
+ console.log('\x1b[33m Repair only fixes the watchdog\x27s SCRIPT= path — it does not remove a supervisor you may still depend on.\x1b[0m');
1033
+ // A remaining stale unit needs its own fix command — "repair" cannot
1034
+ // touch it, and this listing alone would otherwise leave the operator
1035
+ // with a [STALE] tag and no next step for that specific supervisor.
1036
+ for (const s of staleRemaining) {
1037
+ console.log('');
1038
+ for (const line of (0, service_installer_1.staleSupervisorAdvice)(s)) {
1039
+ console.log(`\x1b[33m${line}\x1b[0m`);
1040
+ }
1041
+ }
1042
+ console.log('');
1043
+ console.log(' Remove the extra one(s) yourself:');
1044
+ if (supervisors.some((s) => s.kind === 'cron')) {
1045
+ console.log(' dialout remove-cron # drop the cron watchdog');
1046
+ }
1047
+ if (supervisors.some((s) => s.kind !== 'cron')) {
1048
+ console.log(' dialout uninstall-service # drop the launchd/systemd service');
1049
+ }
1050
+ }
1051
+ else if (staleRemaining.length > 0) {
1052
+ // Exactly one supervisor total, and it's still stale (e.g. a lone
1053
+ // stale systemd/launchd unit — repair only ever touches the watchdog).
1054
+ // This is NOT a clean state: give the same actionable advice `status`
1055
+ // would, instead of a false "no conflicting supervisors" all-clear.
1056
+ console.log('');
1057
+ for (const s of staleRemaining) {
1058
+ for (const line of (0, service_installer_1.staleSupervisorAdvice)(s)) {
1059
+ console.log(`\x1b[33m${line}\x1b[0m`);
1060
+ }
1061
+ }
1062
+ }
1063
+ else {
1064
+ console.log('');
1065
+ console.log(' No conflicting supervisors detected.');
1066
+ }
1067
+ console.log('\x1b[90m' + '─'.repeat(44) + '\x1b[0m');
1068
+ console.log('');
1069
+ });
1070
+ // --- setup-cowork ---
1071
+ program
1072
+ .command('setup-cowork')
1073
+ .description('Choose which terminal app(s) auto-wrap into tmux for Dialout remote access')
1074
+ .option('--remove', 'Uninstall the wrapper, clear the allowlist, and go fully native')
1075
+ .option('--terminals <csv>', 'Non-interactive: comma-separated terminal tokens to allow')
1076
+ .option('--yes', 'Auto-confirm installing tmux if it is missing')
1077
+ .option('--adopt-all', 'Adopt all existing tmux sessions non-interactively')
1078
+ .option('--no-adopt', 'Skip adopting existing sessions')
1079
+ .action(async (opts) => {
1080
+ const { tmuxAvailable, listSessions } = require('./tmux-manager');
1081
+ const { execFileSync } = require('child_process');
1082
+ const { hasCommand } = require('./has-command');
1083
+ const config = (0, config_1.loadConfig)();
1084
+ const rcFiles = [path.join(os.homedir(), '.zshrc'), path.join(os.homedir(), '.bashrc')];
1085
+ // --remove: strip the block, clear the allowlist, go native.
1086
+ if (opts.remove) {
1087
+ for (const rc of rcFiles) {
1088
+ if (!fs.existsSync(rc))
1089
+ continue;
1090
+ fs.writeFileSync(rc, (0, cowork_1.removeCoworkBlock)(fs.readFileSync(rc, 'utf-8')));
1091
+ console.log(` removed wrapper from ${rc}`);
1092
+ }
1093
+ removeSshLocalNetworkWorkaround();
1094
+ config.cowork = false;
1095
+ config.coworkTerminals = [];
1096
+ (0, config_1.saveConfig)(config);
1097
+ console.log('Reverted to native terminals. Open terminals are unaffected; new shells start normally.');
1098
+ return;
1099
+ }
1100
+ // tmux check → offer to install.
1101
+ if (!(await tmuxAvailable())) {
1102
+ const plan = (0, cowork_1.pickTmuxInstall)(process.platform, hasCommand);
1103
+ if (!plan.canAuto) {
1104
+ console.log('\x1b[33mtmux is not installed.\x1b[0m');
1105
+ console.log(` ${plan.manual}`);
1106
+ process.exitCode = 1;
1107
+ return;
1108
+ }
1109
+ console.log('\x1b[33mtmux is not installed.\x1b[0m It will be installed with:');
1110
+ console.log(` ${plan.command}`);
1111
+ let go = opts.yes === true;
1112
+ if (!go) {
1113
+ const rl = createRL();
1114
+ const ans = (await ask(rl, 'Run this now? [y/N]: ')).trim().toLowerCase();
1115
+ rl.close();
1116
+ go = ans === 'y' || ans === 'yes';
1117
+ }
1118
+ if (!go) {
1119
+ console.log(`Declined. Install tmux manually (${plan.command}) then re-run: dialout setup-cowork`);
1120
+ process.exitCode = 1;
1121
+ return;
1122
+ }
1123
+ try {
1124
+ execFileSync('/bin/sh', ['-c', plan.command], { stdio: 'inherit' });
1125
+ }
1126
+ catch {
1127
+ console.log('\x1b[31mtmux install failed.\x1b[0m Install it manually then re-run setup-cowork.');
1128
+ process.exitCode = 1;
1129
+ return;
1130
+ }
1131
+ if (!(await tmuxAvailable())) {
1132
+ console.log('\x1b[31mtmux still not found after install.\x1b[0m Install it manually then re-run setup-cowork.');
1133
+ process.exitCode = 1;
1134
+ return;
1135
+ }
1136
+ }
1137
+ // Terminal selection.
1138
+ const detected = (0, terminal_detect_1.detectTerminals)();
1139
+ // Guard rail against the OTHER reported silent-success trap: a headless
1140
+ // SSH-only server showed a 16-row checklist, every row "(not installed)",
1141
+ // none current — and even a perfect pick there could never activate
1142
+ // cowork, since renderCoworkBlock gates the whole wrapper on
1143
+ // `[ -z "$SSH_TTY" ]`. Explicit --terminals still gets the warn-don't-
1144
+ // refuse treatment (dotfile-syncing users keep working, matching
1145
+ // unmatchableTokens below); the interactive checklist is refused outright
1146
+ // since no selection there could ever help.
1147
+ const viability = (0, cowork_1.coworkViability)(process.env, detected.some((t) => t.installed));
1148
+ if (!viability.usable) {
1149
+ for (const reason of viability.reasons) {
1150
+ if (reason === 'ssh-session') {
1151
+ console.log('\x1b[33mThis is an SSH session — the cowork wrapper deliberately skips SSH logins, so no selection here can ever activate it.\x1b[0m');
1152
+ }
1153
+ else {
1154
+ console.log('\x1b[33mNo terminal emulator is installed on this machine — cowork wraps the app you open a terminal FROM, which runs on your local machine, not this server.\x1b[0m');
1155
+ }
1156
+ }
1157
+ console.log('Instead, open a terminal from the Dialout panel: it creates a tmux session on this machine directly and needs no cowork.');
1158
+ if (typeof opts.terminals !== 'string') {
1159
+ process.exitCode = 1;
1160
+ return;
1161
+ }
1162
+ }
1163
+ let selected;
1164
+ if (typeof opts.terminals === 'string') {
1165
+ selected = (0, cowork_1.sanitizeTokens)(opts.terminals.split(',').map((t) => t.trim()).filter(Boolean));
1166
+ }
1167
+ else {
1168
+ // installed apps first; current terminal is NOT pre-ticked (only prior picks are).
1169
+ const ordered = [...detected].sort((a, b) => Number(b.installed) - Number(a.installed));
1170
+ const prev = new Set(config.coworkTerminals ?? []);
1171
+ console.log('');
1172
+ console.log('Which terminal app(s) should be exposed to Dialout remote (auto-wrap into tmux)?');
1173
+ console.log('All other terminals stay native (full OS text selection + scrolling).');
1174
+ if (process.stdin.isTTY) {
1175
+ const items = ordered.map((t) => ({
1176
+ label: t.name,
1177
+ token: t.token,
1178
+ hint: [t.installed ? '' : 'not installed', t.current ? 'this terminal' : ''].filter(Boolean).join(', ') || undefined,
1179
+ checked: prev.has(t.token),
1180
+ }));
1181
+ console.log('');
1182
+ const picked = await promptChecklist(items);
1183
+ if (picked === null) {
1184
+ console.log('Cancelled — no changes made.');
1185
+ return;
1186
+ }
1187
+ selected = (0, cowork_1.sanitizeTokens)(picked);
1188
+ }
1189
+ else {
1190
+ // Non-TTY (piped) fallback: numbered toggle prompt.
1191
+ const ticks = ordered.map((t) => prev.has(t.token));
1192
+ ordered.forEach((t, i) => {
1193
+ const box = ticks[i] ? '[x]' : '[ ]';
1194
+ const tags = [t.installed ? '' : 'not installed', t.current ? 'this terminal' : ''].filter(Boolean).join(', ');
1195
+ console.log(` ${i + 1}) ${box} ${t.name}${tags ? ` (${tags})` : ''}`);
1196
+ });
1197
+ const rl = createRL();
1198
+ const ans = (await ask(rl, '\nToggle by number (space/comma separated), Enter to confirm: ')).trim();
1199
+ rl.close();
1200
+ for (const tok of ans.split(/[, ]+/).filter(Boolean)) {
1201
+ const idx = parseInt(tok, 10) - 1;
1202
+ if (idx >= 0 && idx < ticks.length)
1203
+ ticks[idx] = !ticks[idx];
1204
+ }
1205
+ selected = (0, cowork_1.sanitizeTokens)(ordered.filter((_, i) => ticks[i]).map((t) => t.token));
1206
+ }
1207
+ }
1208
+ // Guard rail against the reported silent no-op: warn (loudly, not
1209
+ // silently) when a selected token cannot possibly match on this platform
1210
+ // — e.g. picking "Apple_Terminal" on linux, or a Linux-only marker token
1211
+ // on darwin. The rc block is still written below (dotfiles may be synced
1212
+ // across machines); this only changes what's printed and the exit code.
1213
+ const badTokens = (0, cowork_1.unmatchableTokens)(selected, process.platform);
1214
+ for (const tok of badTokens) {
1215
+ console.log(`\x1b[33mWarning: "${tok}" cannot match on ${process.platform} — that terminal does not run here.\x1b[0m`);
1216
+ }
1217
+ if (selected.length > 0 && badTokens.length === selected.length) {
1218
+ console.log('\x1b[33mNone of the selected terminal(s) can ever match on this machine — cowork will never activate.\x1b[0m');
1219
+ process.exitCode = 1;
1220
+ }
1221
+ // Adopt existing tmux sessions (unchanged behavior).
1222
+ const all = await listSessions();
1223
+ const orphans = all.filter((s) => s.origin !== 'browser' && !s.folder);
1224
+ if (orphans.length > 0 && opts.adopt !== false) {
1225
+ console.log('');
1226
+ console.log(`Found ${orphans.length} existing tmux session(s) not managed by Dialout:`);
1227
+ orphans.forEach((s, i) => console.log(` ${i + 1}) ${s.name} (${s.termProgram})`));
1228
+ let pick;
1229
+ if (opts.adoptAll) {
1230
+ pick = 'a';
1231
+ }
1232
+ else {
1233
+ const rl = createRL();
1234
+ pick = (await ask(rl, 'Bind which to Dialout? [1,2,… / a=all / n=none]: ')).trim().toLowerCase();
1235
+ rl.close();
1236
+ }
1237
+ let chosen = [];
1238
+ if (pick === 'a')
1239
+ chosen = orphans;
1240
+ else if (pick && pick !== 'n') {
1241
+ const idx = pick.split(/[, ]+/).map((x) => parseInt(x, 10) - 1).filter((n) => n >= 0 && n < orphans.length);
1242
+ chosen = idx.map((i) => orphans[i]);
1243
+ }
1244
+ for (const s of chosen) {
1245
+ const tmux = (args) => { try {
1246
+ execFileSync('tmux', args, { timeout: 5000, stdio: 'pipe' });
1247
+ }
1248
+ catch { } };
1249
+ let cwd = '';
1250
+ try {
1251
+ cwd = execFileSync('tmux', ['display-message', '-p', '-t', s.name, '#{pane_current_path}'], { timeout: 5000, stdio: 'pipe' }).toString().trim();
1252
+ }
1253
+ catch { }
1254
+ const base = cwd.split('/').filter(Boolean).pop() || '';
1255
+ tmux(['set-option', '-t', s.name, '@devdash_origin', 'native']);
1256
+ if (s.termProgram === 'unknown')
1257
+ tmux(['set-option', '-t', s.name, '@term_program', 'unknown']);
1258
+ if (base)
1259
+ tmux(['set-option', '-t', s.name, '@devdash_folder', base]);
1260
+ if (cwd)
1261
+ tmux(['set-option', '-t', s.name, '@devdash_folder_path', cwd]);
1262
+ }
1263
+ if (chosen.length > 0)
1264
+ console.log(`\x1b[32m✓ Adopted ${chosen.length} session(s)\x1b[0m (live after the agent's next poll)`);
1265
+ }
1266
+ // Persist the allowlist and write / remove the block.
1267
+ config.coworkTerminals = selected;
1268
+ if (selected.length === 0) {
1269
+ for (const rc of rcFiles) {
1270
+ if (!fs.existsSync(rc))
1271
+ continue;
1272
+ fs.writeFileSync(rc, (0, cowork_1.removeCoworkBlock)(fs.readFileSync(rc, 'utf-8')));
1273
+ console.log(` removed wrapper from ${rc}`);
1274
+ }
1275
+ config.cowork = false;
1276
+ (0, config_1.saveConfig)(config);
1277
+ console.log('');
1278
+ console.log('No remote terminal selected — all terminals are native (OS selection + scrolling).');
1279
+ return;
1280
+ }
1281
+ const existing = rcFiles.filter((f) => fs.existsSync(f));
1282
+ const targets = existing.length > 0
1283
+ ? existing
1284
+ : [(process.env.SHELL || '').includes('bash') ? rcFiles[1] : rcFiles[0]];
1285
+ for (const rc of targets) {
1286
+ const result = (0, cowork_1.installCoworkBlock)(rc, selected);
1287
+ applySshLocalNetworkWorkaround();
1288
+ console.log(` ${result}: wrapper in ${rc}`);
1289
+ }
1290
+ config.cowork = true;
1291
+ (0, config_1.saveConfig)(config);
1292
+ console.log('');
1293
+ console.log(`\x1b[32mCowork enabled.\x1b[0m Remote app(s): ${selected.join(', ')}`);
1294
+ console.log('These auto-wrap into tmux and appear in Dialout → Terminals.');
1295
+ console.log('Every other terminal stays native (OS text selection + native scrolling).');
1296
+ if ((0, terminal_detect_1.currentTerminalToken)() === '') {
1297
+ console.log('\x1b[33mNote:\x1b[0m setup ran inside tmux, so "this terminal" could not be pre-ticked.');
1298
+ console.log('Re-run from a native terminal window if the checklist missed your app.');
1299
+ }
1300
+ console.log('Restart the agent to start reporting sessions: dialout restart');
1301
+ });
1302
+ // --- update ---
1303
+ program
1304
+ .command('update')
1305
+ .description('Update Dialout to the latest version')
1306
+ .addHelpText('after', `
1307
+ Checks the IndiaNIC registry for a newer version and installs it.
1308
+ If running as a service, you'll be prompted to restart it.
1309
+
1310
+ Examples:
1311
+ $ dialout update # update to latest
1312
+ $ dialout --version # check current version`)
1313
+ .action(async () => {
1314
+ await (0, update_check_1.performUpdate)();
1315
+ });
1316
+ // --- config ---
1317
+ const configCmd = program
1318
+ .command('config')
1319
+ .description('View or modify agent configuration')
1320
+ .addHelpText('after', `
1321
+ Subcommands:
1322
+ dialout config show Print current config (API key masked)
1323
+ dialout config path Show config file location
1324
+ dialout config reset Reset to defaults (keeps API key + server)
1325
+ dialout config set <key> <value> Set a config value
1326
+
1327
+ Config keys:
1328
+ serverUrl WebSocket URL (e.g., wss://dialout.example.com/ws)
1329
+ apiKey Machine API key (e.g., mch_xxxx)
1330
+ heartbeatInterval Keep-alive interval in ms (default: 30000)
1331
+ cronInterval Watchdog check interval in minutes (default: 5)
1332
+ scanPorts Comma-separated ports (e.g., 3000,8080,5173)
1333
+ scanRange Port range (e.g., 3000-9000)
1334
+
1335
+ Examples:
1336
+ $ dialout config show
1337
+ $ dialout config set scanPorts 3000,4200,8080
1338
+ $ dialout config reset`);
1339
+ configCmd
1340
+ .command('show')
1341
+ .description('Print current configuration (API key masked)')
1342
+ .action(() => {
1343
+ const config = (0, config_1.loadConfig)();
1344
+ const display = { ...config, apiKey: config.apiKey ? '****' + config.apiKey.slice(-4) : '' };
1345
+ console.log(JSON.stringify(display, null, 2));
1346
+ });
1347
+ configCmd
1348
+ .command('path')
1349
+ .description('Show config file location')
1350
+ .action(() => {
1351
+ console.log((0, config_1.getConfigPath)());
1352
+ });
1353
+ configCmd
1354
+ .command('reset')
1355
+ .description('Reset config to defaults (keeps API key and server URL)')
1356
+ .addHelpText('after', `
1357
+ Resets all configuration values to defaults while preserving:
1358
+ - serverUrl (your server connection)
1359
+ - apiKey (your machine authentication)
1360
+
1361
+ This is useful if you've changed scan ports, intervals, etc. and want to start fresh.
1362
+
1363
+ Examples:
1364
+ $ dialout config reset # reset to defaults
1365
+ $ dialout config show # verify the result`)
1366
+ .action(() => {
1367
+ const config = (0, config_1.loadConfig)();
1368
+ const { serverUrl, apiKey } = config;
1369
+ const defaults = {
1370
+ serverUrl,
1371
+ apiKey,
1372
+ scanPorts: [3000, 3001, 4200, 5173, 5174, 8000, 8080, 8081, 9000],
1373
+ scanRange: { from: 3000, to: 9000 },
1374
+ heartbeatInterval: 30000,
1375
+ cronInterval: 5,
1376
+ };
1377
+ (0, config_1.saveConfig)(defaults);
1378
+ console.log('Config reset to defaults (serverUrl and apiKey preserved).');
1379
+ console.log(`Config file: ${(0, config_1.getConfigPath)()}`);
1380
+ });
1381
+ configCmd
1382
+ .command('set <key> <value>')
1383
+ .description('Set a configuration value')
1384
+ .addHelpText('after', `
1385
+ Available keys:
1386
+ serverUrl WebSocket URL (e.g., wss://dialout.example.com/ws)
1387
+ apiKey Machine API key (e.g., mch_xxxx)
1388
+ heartbeatInterval Keep-alive interval in ms (default: 30000)
1389
+ cronInterval Watchdog check interval in minutes (default: 5)
1390
+ scanPorts Comma-separated ports (e.g., 3000,8080,5173)
1391
+ scanRange Port range (e.g., 3000-9000)
1392
+
1393
+ Examples:
1394
+ $ dialout config set serverUrl wss://new-server.com/ws
1395
+ $ dialout config set cronInterval 3
1396
+ $ dialout config set scanPorts 3000,4200,8080
1397
+ $ dialout config set scanRange 3000-9000
1398
+ $ dialout config set heartbeatInterval 60000`)
1399
+ .action((key, value) => {
1400
+ const config = (0, config_1.loadConfig)();
1401
+ if (!(key in config)) {
1402
+ console.error(`Unknown config key: ${key}`);
1403
+ console.error(`Valid keys: ${Object.keys(config).join(', ')}`);
1404
+ process.exit(1);
1405
+ }
1406
+ if (key === 'heartbeatInterval' || key === 'cronInterval') {
1407
+ config[key] = parseInt(value, 10);
1408
+ }
1409
+ else if (key === 'scanPorts') {
1410
+ config[key] = value.split(',').map((p) => parseInt(p.trim(), 10));
1411
+ }
1412
+ else if (key === 'scanRange') {
1413
+ const [from, to] = value.split('-').map((p) => parseInt(p.trim(), 10));
1414
+ config[key] = { from, to };
1415
+ }
1416
+ else {
1417
+ config[key] = value;
1418
+ }
1419
+ // Keep the active profile in sync when connection values change
1420
+ if ((key === 'serverUrl' || key === 'apiKey') && config.activeProfile && config.profiles?.[config.activeProfile]) {
1421
+ config.profiles[config.activeProfile][key] = config[key];
1422
+ }
1423
+ (0, config_1.saveConfig)(config);
1424
+ console.log(`Set ${key} = ${JSON.stringify(config[key])}`);
1425
+ });
1426
+ program.parse();
1427
+ //# sourceMappingURL=cli.js.map