@ts47andres/exeggutor 1.1.2

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.
@@ -0,0 +1,414 @@
1
+ import * as pty from 'node-pty';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs';
5
+ import { exec } from 'child_process';
6
+ import * as db from './workspaceDb'; // Reference to local sessions JSON database.
7
+
8
+ export interface TerminalSession {
9
+ id: string; // The unique tab ID associated with this terminal process.
10
+ workspaceId: string; // The ID of the workspace that owns this session.
11
+ ptyProcess: pty.IPty; // The live node-pty process instance.
12
+ outputBuffer: string[]; // Accumulated recent terminal output lines.
13
+ lastOutputTime: number; // Unix timestamp of the last stdout chunk received.
14
+ status: 'Active' | 'Waiting' | 'Idle' | 'Errored'; // Real-time state of the terminal process.
15
+ lastLinePreview: string; // The single most recent line of terminal output for sidebar observers.
16
+ onData: (data: string) => void; // Event dispatcher callback for new stdout streams.
17
+ activeSocket?: any; // The active WebSocket connection associated with this session.
18
+ broadcastCallback?: () => void; // Optional callback to notify observer of state changes.
19
+ }
20
+
21
+ const sessions = new Map<string, TerminalSession>(); // Global map cache linking tab IDs to their active persistent TerminalSessions.
22
+ let statusCheckInterval: NodeJS.Timeout | null = null; // Background interval reference for running periodic status audits.
23
+ let spawnLock: Promise<void> | null = null; // Mutex promise ensuring only one PTY spawn executes at a time on Windows.
24
+ let killLock: Promise<void> | null = null; // Mutex promise ensuring only one PTY kill executes at a time on Windows.
25
+
26
+ // Queries the operating system to recursively find all active child and descendant process IDs of the specified parent process.
27
+ function getDescendants(parentPid: number): Promise<Array<{ pid: number; name: string }>> {
28
+ console.log(`[PTY] getDescendants(parentPid=${parentPid})`);
29
+ const p = new Promise<Array<{ pid: number; name: string }>>((resolve) => {
30
+ const isWin = process.platform === 'win32'; // Flag denoting if the host operating system is Windows.
31
+ const cmd = isWin ? 'wmic process get ParentProcessId,ProcessId,Name' : 'ps -A -o ppid,pid,comm'; // System command to run.
32
+ console.log(`[PTY] getDescendants -> running: "${cmd}"`);
33
+ const runOptions = { windowsHide: true }; // Hide window option.
34
+ exec(cmd, runOptions, (err, stdout) => {
35
+ if (err || !stdout) {
36
+ resolve([]);
37
+ return;
38
+ }
39
+ const lines = stdout.split(/\r?\n/); // Process lines array.
40
+ const procList: Array<{ pid: number; ppid: number; name: string }> = []; // Registry of all active system processes.
41
+ for (const line of lines) {
42
+ const parts = line.trim().split(/\s+/); // Splitted columns.
43
+ if (parts.length >= 3) {
44
+ if (isWin) {
45
+ const ppid = parseInt(parts[0], 10); // Parent process identifier.
46
+ const pid = parseInt(parts[1], 10); // Target process identifier.
47
+ const name = parts.slice(2).join(' '); // Process executable name.
48
+ if (!isNaN(pid) && !isNaN(ppid)) {
49
+ procList.push({ pid, ppid, name });
50
+ }
51
+ } else {
52
+ const ppid = parseInt(parts[0], 10); // Parent process identifier.
53
+ const pid = parseInt(parts[1], 10); // Target process identifier.
54
+ const name = parts.slice(2).join(' '); // Executable name or command line.
55
+ if (!isNaN(pid) && !isNaN(ppid)) {
56
+ procList.push({ pid, ppid, name });
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ const descendants: Array<{ pid: number; name: string }> = []; // Flat registry of descendant processes.
63
+ const queue = [parentPid]; // Queue container for breath-first traversal.
64
+ const visited = new Set<number>(); // Visited PID tracker to prevent circular reference locks.
65
+ while (queue.length > 0) {
66
+ const current = queue.shift()!; // Dequeued parent PID.
67
+ if (visited.has(current)) {
68
+ continue;
69
+ }
70
+ visited.add(current);
71
+ const children = procList.filter(p => p.ppid === current); // Direct children matching current process.
72
+ for (const child of children) {
73
+ descendants.push({ pid: child.pid, name: child.name });
74
+ queue.push(child.pid);
75
+ }
76
+ }
77
+ console.log(`[PTY] getDescendants(${parentPid}) -> ${descendants.length} descendants: [${descendants.map(d => `${d.name}(${d.pid})`).join(', ')}]`);
78
+ resolve(descendants);
79
+ });
80
+ }); // Spawns execution promise handle.
81
+ return p;
82
+ }
83
+
84
+ // Audits the activity state of all terminal sessions to update active vs idle/waiting states.
85
+ export function startStatusAuditor(broadcastCallback: () => void): void {
86
+ if (statusCheckInterval) {
87
+ return;
88
+ }
89
+ statusCheckInterval = setInterval(async () => {
90
+ const now = Date.now(); // The current high resolution timestamp.
91
+ let changed = false; // Flag to indicate if any session state changed during this audit cycle.
92
+
93
+ const sessionPromises = Array.from(sessions.values()).map(async (session) => {
94
+ const descendants = await getDescendants(session.ptyProcess.pid); // Retrieved process descendants of the PTY.
95
+ const utilityNames = new Set([
96
+ 'winpty-agent.exe', 'conhost.exe', 'powershell.exe', 'cmd.exe',
97
+ 'wmic.exe', 'openconsole.exe', 'conconsole.exe', 'bash', 'zsh', 'sh', 'ps'
98
+ ]); // Process utility names set.
99
+
100
+ const hasActiveProcess = descendants.some(d => {
101
+ const nameLower = d.name.toLowerCase(); // Lowercased process name.
102
+ const isUtility = Array.from(utilityNames).some(u => nameLower.includes(u.toLowerCase())); // Flag identifying utility processes.
103
+ return !isUtility;
104
+ }); // Verification flag identifying if any non-utility process is running.
105
+
106
+ if (!hasActiveProcess) {
107
+ if (session.status !== 'Idle') {
108
+ session.status = 'Idle';
109
+ changed = true;
110
+ }
111
+ } else {
112
+ if (session.status === 'Active' && now - session.lastOutputTime > 2000) {
113
+ const fullOutput = session.outputBuffer.join(''); // Combined stdout stream of the session.
114
+ const lines = fullOutput.split(/\r?\n/); // Splitted chunk array.
115
+ const nonLines = lines.map(l => l.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim()); // Cleaned line array without ANSI.
116
+ const nonZeroLines = nonLines.filter(l => l.length > 0); // Non-empty cleaned lines.
117
+ const lastFewLines = nonZeroLines.slice(-5); // Last 5 non-empty output lines.
118
+ const lastFewTextLower = lastFewLines.join(' ').toLowerCase(); // Combined lowercased string of recent lines.
119
+ const interactiveGuides = ['tab', 'select', 'enter', 'confirm', 'dismiss', 'arrow', 'esc', 'up/down', '[y/n]']; // Interactive keyboard control terms.
120
+ const promptIndicators = ['password:', 'password for', 'enter passphrase', 'input:', 'confirm:', 'proceed?']; // Standard CLI input indicators.
121
+ const hasInteractiveGuides = interactiveGuides.some(guide => lastFewTextLower.includes(guide)); // Flag identifying key guides in output.
122
+ const hasPromptIndicators = promptIndicators.some(indicator => lastFewTextLower.includes(indicator)); // Flag identifying standard prompts in output.
123
+ const lastLine = nonZeroLines[nonZeroLines.length - 1] || ''; // The single last line of output.
124
+ const endsWithQuestion = lastLine.endsWith('?') || lastFewLines.some(l => l.endsWith('?')); // Flag verifying if any recent lines end with a question mark.
125
+ const isWaiting = hasInteractiveGuides || hasPromptIndicators || endsWithQuestion; // Combined check for form prompt or question.
126
+ const nextStatus = isWaiting ? 'Waiting' : 'Idle'; // Computed target status mapping.
127
+ session.status = nextStatus;
128
+ changed = true;
129
+ }
130
+ }
131
+ }); // Spawns concurrent auditing sessions.
132
+
133
+ await Promise.all(sessionPromises);
134
+ if (changed) {
135
+ broadcastCallback();
136
+ }
137
+ }, 5000); // Trigger auditor check every 5 seconds to reduce wmic process spawn frequency on Windows.
138
+ }
139
+
140
+ // Spawns a persistent terminal process or retrieves an existing one, matching it to the tab.
141
+ // Uses an async spawn lock to serialize concurrent node-pty process creation on Windows.
142
+ export async function getOrCreatePtySession(
143
+ workspaceId: string,
144
+ tabId: string,
145
+ cwd: string,
146
+ shellPath?: string,
147
+ broadcastCallback?: () => void
148
+ ): Promise<TerminalSession> {
149
+ const existing = sessions.get(tabId); // Attempt to retrieve an existing session from the cache.
150
+ if (existing) {
151
+ console.log(`[PTY] getOrCreatePtySession -> returning existing session for tab=${tabId} PID=${existing.ptyProcess.pid}`);
152
+ const foundSession = existing; // Explicit reference to the cached session.
153
+ return foundSession;
154
+ }
155
+
156
+ console.log(`[PTY] getOrCreatePtySession(workspaceId=${workspaceId}, tabId=${tabId}, cwd=${cwd}, shellPath=${shellPath})`);
157
+
158
+ const defaultShell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; // Choose shell path depending on underlying server OS.
159
+ const targetShell = shellPath || defaultShell; // Evaluated shell executable to run.
160
+
161
+ const ptyArgs: string[] = []; // Arguments passed to the spawned shell.
162
+ const ptyEnv = { ...process.env } as Record<string, string>; // Environment variables for the spawned process.
163
+
164
+ const isWin = os.platform() === 'win32'; // Flag indicating Windows platform.
165
+ if (isWin) {
166
+ const guardScript = path.resolve(__dirname, '../scripts/git-guard.ps1');
167
+ if (fs.existsSync(guardScript)) {
168
+ ptyArgs.push('-NoLogo', '-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', `. '${guardScript}'`);
169
+ }
170
+ } else {
171
+ const wrapperDir = path.resolve(__dirname, '../git-wrapper');
172
+ if (fs.existsSync(path.join(wrapperDir, 'git'))) {
173
+ ptyEnv.PATH = `${wrapperDir}${path.delimiter}${ptyEnv.PATH || ''}`;
174
+ }
175
+ }
176
+
177
+ // Wait for any in-flight PTY spawn to finish before creating a new one to avoid ConPTY conflicts on Windows.
178
+ while (spawnLock) {
179
+ console.log(`[PTY] Spawn lock held, waiting for tab=${tabId}`);
180
+ await spawnLock;
181
+ }
182
+ let releaseLock: () => void = () => {}; // Callback to release the spawn mutex after creation completes, default no-op to avoid TS usage-before-init.
183
+ spawnLock = new Promise((resolve) => { releaseLock = resolve; });
184
+
185
+ console.log(`[PTY] Spawning shell: targetShell="${targetShell}" args=[${ptyArgs.join(', ')}] useConpty=true`);
186
+ let ptyProcess: pty.IPty; // Reference to the successfully spawned node-pty process.
187
+ try {
188
+ ptyProcess = pty.spawn(targetShell, ptyArgs, {
189
+ name: 'xterm-256color',
190
+ cols: 80,
191
+ rows: 24,
192
+ cwd: cwd,
193
+ env: ptyEnv,
194
+ useConpty: true, // Enables the Windows Pseudo Console API for accurate dimension resizes.
195
+ }); // Spawn the process via node-pty.
196
+ console.log(`[PTY] Spawned PID=${ptyProcess.pid} for tab=${tabId}`);
197
+ } catch (err: any) {
198
+ console.log(`[PTY] Spawn FAILED for tab=${tabId}: ${err.message}. Stack: ${err.stack}`);
199
+ const spawnErr = new Error(`PTY spawn failed for tab ${tabId}: ${err.message}`); // Error wrapping the spawn failure.
200
+ spawnLock = null;
201
+ releaseLock();
202
+ throw spawnErr;
203
+ }
204
+ spawnLock = null;
205
+ releaseLock();
206
+
207
+ const newSession: TerminalSession = {
208
+ id: tabId,
209
+ workspaceId: workspaceId,
210
+ ptyProcess: ptyProcess,
211
+ outputBuffer: [],
212
+ lastOutputTime: Date.now(),
213
+ status: 'Idle',
214
+ lastLinePreview: '',
215
+ onData: () => {},
216
+ broadcastCallback: broadcastCallback,
217
+ }; // The new terminal session schema instance mapping this tab.
218
+
219
+ sessions.set(tabId, newSession);
220
+
221
+ // Save process PID to database for future orphan recovery.
222
+ try {
223
+ db.updateTerminalTab(workspaceId, tabId, { pid: ptyProcess.pid });
224
+ } catch (err) {
225
+ // Safe ignore database update failure.
226
+ }
227
+
228
+ ptyProcess.onData(data => {
229
+ newSession.lastOutputTime = Date.now();
230
+
231
+ const maxBufferLines = 1000; // Limit total cached rows in memory to save memory usage.
232
+ newSession.outputBuffer.push(data);
233
+ if (newSession.outputBuffer.length > maxBufferLines) {
234
+ newSession.outputBuffer.shift();
235
+ }
236
+
237
+ const lines = data.split(/\r?\n/); // Splitted chunk array.
238
+ const lastNonEmpty = lines.filter(l => l.trim().length > 0).pop(); // The last non-empty line of text.
239
+ if (lastNonEmpty) {
240
+ const cleanLine = lastNonEmpty.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); // Remove ANSI terminal escape codes.
241
+ if (cleanLine.length > 0) {
242
+ newSession.lastLinePreview = cleanLine.substring(0, 100);
243
+ }
244
+ }
245
+
246
+ const oldStatus = newSession.status; // Preserve original status before computation.
247
+ newSession.status = 'Active'; // Mark status as active immediately when receiving new data.
248
+
249
+ newSession.onData(data);
250
+
251
+ if (oldStatus !== newSession.status && broadcastCallback) {
252
+ broadcastCallback();
253
+ }
254
+ });
255
+
256
+ ptyProcess.onExit(res => {
257
+ newSession.status = res.exitCode === 0 ? 'Idle' : 'Errored';
258
+ if (broadcastCallback) {
259
+ broadcastCallback();
260
+ }
261
+ });
262
+
263
+ const createdSession = newSession; // Explicit reference to return.
264
+ return createdSession;
265
+ }
266
+
267
+ // Resizes the terminal process row and column dimensions.
268
+ export function resizePtySession(tabId: string, cols: number, rows: number): void {
269
+ const session = sessions.get(tabId); // Look up session by ID.
270
+ if (session && typeof cols === 'number' && typeof rows === 'number' && cols > 0 && rows > 0) {
271
+ const parsedCols = Math.max(Math.floor(cols), 40); // Ensures PTY size is at least 40 columns wide.
272
+ const parsedRows = Math.max(Math.floor(rows), 10); // Ensures PTY size is at least 10 rows high.
273
+ if (parsedCols > 0 && parsedRows > 0) {
274
+ session.ptyProcess.resize(parsedCols, parsedRows);
275
+ }
276
+ }
277
+ }
278
+
279
+ // Write input data directly to the active terminal process.
280
+ // Does not change the session status — the periodic auditor handles status transitions,
281
+ // preventing rapid Idle↔Active flickering that occurred on every keystroke.
282
+ export function writeToPtySession(tabId: string, data: string): void {
283
+ const session = sessions.get(tabId); // Look up session by ID.
284
+ if (session) {
285
+ session.ptyProcess.write(data);
286
+ }
287
+ }
288
+
289
+ // Forces the termination of a terminal session and frees system process resources.
290
+ // Serializes concurrent kills on Windows to avoid overlapping conhost termination window flashes.
291
+ export async function killPtySession(tabId: string): Promise<void> {
292
+ const session = sessions.get(tabId); // Find session by tab ID.
293
+ if (session) {
294
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> killing PID=${session.ptyProcess.pid}`);
295
+ // Wait for any in-flight PTY kill to finish before terminating this one to avoid ConPTY conflicts on Windows.
296
+ while (killLock) {
297
+ console.log(`[PTY] Kill lock held, waiting for tab=${tabId}`);
298
+ await killLock;
299
+ }
300
+ let releaseKillLock: () => void = () => {}; // Callback to release the kill mutex after termination completes.
301
+ killLock = new Promise((resolve) => { releaseKillLock = resolve; });
302
+
303
+ try {
304
+ session.ptyProcess.kill();
305
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> kill OK`);
306
+ } catch (err: any) {
307
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> kill error: ${err.message}`);
308
+ } finally {
309
+ killLock = null;
310
+ releaseKillLock();
311
+ }
312
+ try {
313
+ db.updateTerminalTab(session.workspaceId, tabId, { pid: undefined }); // Clear the PID record from the database.
314
+ } catch (err) {
315
+ // Safe ignore database write failure.
316
+ }
317
+ sessions.delete(tabId);
318
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> session removed from map`);
319
+ } else {
320
+ console.log(`[PTY] killPtySession(tabId=${tabId}) -> no active session found`);
321
+ }
322
+ }
323
+
324
+ // Retrieves all terminal session descriptors currently managed by the backend.
325
+ export function getAllSessions(): Array<{ id: string; workspaceId: string; status: string; preview: string }> {
326
+ const list: Array<{ id: string; workspaceId: string; status: string; preview: string }> = []; // Return sessions descriptors.
327
+ sessions.forEach(s => {
328
+ list.push({
329
+ id: s.id,
330
+ workspaceId: s.workspaceId,
331
+ status: s.status,
332
+ preview: s.lastLinePreview,
333
+ });
334
+ });
335
+ return list;
336
+ }
337
+
338
+ // Verifies that a given PID belongs to an expected Exeggutor-managed process to avoid killing unrelated recycled PIDs.
339
+ function verifyPidOwnership(pid: number): Promise<boolean> {
340
+ console.log(`[PTY] verifyPidOwnership(pid=${pid})`);
341
+ return new Promise((resolve) => {
342
+ const isWin = process.platform === 'win32'; // Flag indicating Windows platform.
343
+ if (isWin) {
344
+ console.log(`[PTY] verifyPidOwnership -> running: tasklist /FI "PID eq ${pid}"`);
345
+ exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (err, stdout) => {
346
+ if (err || !stdout) {
347
+ resolve(false);
348
+ return;
349
+ }
350
+ const nameMatch = stdout.match(/"([^"]+)"/); // Capture the process executable name from CSV output.
351
+ if (!nameMatch) {
352
+ resolve(false);
353
+ return;
354
+ }
355
+ const name = nameMatch[1].toLowerCase(); // Process name from tasklist.
356
+ const knownNames = ['node.exe', 'powershell.exe', 'cmd.exe', 'bash.exe', 'wmic.exe', 'conhost.exe'];
357
+ const result = knownNames.some(k => name.includes(k));
358
+ console.log(`[PTY] verifyPidOwnership(${pid}) -> process="${name}" known=${result}`);
359
+ resolve(result);
360
+ }); // Spawn tasklist to verify process identity.
361
+ } else {
362
+ try {
363
+ const comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); // Process command name from proc filesystem.
364
+ const knownNames = ['node', 'bash', 'zsh', 'sh', 'dash', 'powershell'];
365
+ const result = knownNames.includes(comm);
366
+ console.log(`[PTY] verifyPidOwnership(${pid}) -> process="${comm}" known=${result}`);
367
+ resolve(result);
368
+ } catch {
369
+ console.log(`[PTY] verifyPidOwnership(${pid}) -> error reading /proc/${pid}/comm`);
370
+ resolve(false);
371
+ }
372
+ }
373
+ }); // Verifies that the PID matches an expected Exeggutor-managed process.
374
+ }
375
+
376
+ // Scans the database for previously persisted terminal tab process IDs and forcefully terminates any orphaned instances.
377
+ export async function cleanOrphanedPtyProcesses(): Promise<void> {
378
+ try {
379
+ const workspaces = db.getWorkspaces(); // Load workspaces from database.
380
+ console.log(`[PTY] cleanOrphanedPtyProcesses() -> scanning ${workspaces.length} workspaces`);
381
+ for (const ws of workspaces) {
382
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> workspace=${ws.id} (${ws.name}) has ${ws.tabs.length} tabs`);
383
+ for (const tab of ws.tabs) {
384
+ if (tab.pid) {
385
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> checking tab=${tab.id} PID=${tab.pid}`);
386
+ const ownsPid = await verifyPidOwnership(tab.pid); // Flag confirming the PID belongs to an expected process.
387
+ if (!ownsPid) {
388
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} not owned, clearing record`);
389
+ try {
390
+ db.updateTerminalTab(ws.id, tab.id, { pid: undefined }); // Clear stale PID record without killing.
391
+ } catch (err) {
392
+ // Safe ignore database write failure.
393
+ }
394
+ continue;
395
+ }
396
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} owned, sending SIGKILL`);
397
+ try {
398
+ process.kill(tab.pid, 'SIGKILL'); // Force terminate the orphaned shell process.
399
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} killed`);
400
+ } catch (err: any) {
401
+ console.log(`[PTY] cleanOrphanedPtyProcesses -> tab=${tab.id} PID=${tab.pid} kill error: ${err.message}`);
402
+ }
403
+ try {
404
+ db.updateTerminalTab(ws.id, tab.id, { pid: undefined }); // Clear the PID record from the database.
405
+ } catch (err) {
406
+ // Safe ignore database write failure.
407
+ }
408
+ }
409
+ }
410
+ }
411
+ } catch (err: any) {
412
+ console.log(`[PTY] cleanOrphanedPtyProcesses() -> error: ${err.message}`);
413
+ }
414
+ }
@@ -0,0 +1,138 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+
6
+ // Describes the parsed result of `tailscale status --json` for the local node.
7
+ export interface TailscaleInfo {
8
+ ip: string; // Tailscale IPv4 address of the local machine.
9
+ dnsName: string; // MagicDNS hostname (e.g. "hostname.tailnet.ts.net").
10
+ online: boolean; // Whether this node is currently connected to the tailnet.
11
+ tailnetName: string; // Human-readable tailnet name from status.
12
+ }
13
+
14
+ // Resolved path to the tailscale binary, or null if not found.
15
+ let _tailscalePath: string | null | undefined = undefined;
16
+
17
+ // Common installation paths for the tailscale CLI across platforms.
18
+ function getCommonTailscalePaths(): string[] {
19
+ const platform = os.platform(); // Operating system identifier.
20
+ const paths: string[] = [];
21
+ if (platform === 'win32') {
22
+ paths.push('C:\\Program Files\\Tailscale\\tailscale.exe');
23
+ const programFilesX86 = process.env['ProgramFiles(x86)'];
24
+ if (programFilesX86) {
25
+ paths.push(path.join(programFilesX86, 'Tailscale', 'tailscale.exe'));
26
+ }
27
+ } else if (platform === 'darwin') {
28
+ paths.push('/Applications/Tailscale.app/Contents/MacOS/Tailscale');
29
+ paths.push('/usr/local/bin/tailscale');
30
+ } else {
31
+ paths.push('/usr/bin/tailscale');
32
+ paths.push('/usr/local/bin/tailscale');
33
+ paths.push('/opt/tailscale/tailscale');
34
+ }
35
+ return paths;
36
+ }
37
+
38
+ // Locates the tailscale binary by checking common install paths in addition to PATH.
39
+ function findTailscaleBinary(): string | null {
40
+ if (_tailscalePath !== undefined) {
41
+ return _tailscalePath;
42
+ }
43
+ try {
44
+ execSync('tailscale version', { stdio: 'ignore', timeout: 3000 });
45
+ _tailscalePath = 'tailscale';
46
+ return _tailscalePath;
47
+ } catch {
48
+ // Not in PATH, check common install locations.
49
+ }
50
+ for (const candidate of getCommonTailscalePaths()) {
51
+ if (existsSync(candidate)) {
52
+ _tailscalePath = candidate;
53
+ return _tailscalePath;
54
+ }
55
+ }
56
+ _tailscalePath = null;
57
+ return null;
58
+ }
59
+
60
+ // Returns true if the `tailscale` CLI binary is found on the system.
61
+ export function isTailscaleInstalled(): boolean {
62
+ return findTailscaleBinary() !== null;
63
+ }
64
+
65
+ // Resolves the tailscale CLI binary path for executing commands.
66
+ function tailscaleBin(): string {
67
+ const bin = findTailscaleBinary(); // Located tailscale binary path.
68
+ if (!bin) {
69
+ throw new Error('Tailscale CLI not found');
70
+ }
71
+ return bin;
72
+ }
73
+
74
+ // Parses the local node state from `tailscale status --json`.
75
+ // Returns null if Tailscale is not running or the binary is unavailable.
76
+ export function getTailscaleInfo(): TailscaleInfo | null {
77
+ if (!isTailscaleInstalled()) {
78
+ return null;
79
+ }
80
+ try {
81
+ const raw = execSync(`"${tailscaleBin()}" status --json`, {
82
+ encoding: 'utf8',
83
+ timeout: 5000,
84
+ }); // Raw JSON output from the tailscale status command.
85
+
86
+ const parsed = JSON.parse(raw); // Parsed status payload from the tailscale daemon.
87
+
88
+ const self = parsed.Self; // The local node descriptor from the status object.
89
+ if (!self) {
90
+ return null;
91
+ }
92
+
93
+ const ipv4 = (self.TailscaleIPs || []).find((addr: string) =>
94
+ addr.startsWith('100.')
95
+ ) as string | undefined; // First 100.x.x.x Tailscale IP assigned to this node.
96
+
97
+ const dnsNameRaw = self.DNSName || ''; // Raw DNS name with trailing dot.
98
+ const dnsName = dnsNameRaw.replace(/\.$/, ''); // Strip the trailing dot.
99
+
100
+ const tailnetName = dnsName.split('.').slice(1).join('.') || 'unknown'; // Tailnet identifier extracted from the DNS name.
101
+
102
+ return {
103
+ ip: ipv4 || 'unknown',
104
+ dnsName: dnsName || 'unknown',
105
+ online: self.Online !== false,
106
+ tailnetName,
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // Returns the Tailscale IPv4 address of this node, or null if unavailable.
114
+ export function getTailscaleIP(): string | null {
115
+ const info = getTailscaleInfo(); // Parsed Tailscale status for the local node.
116
+ return info ? info.ip : null;
117
+ }
118
+
119
+ // Returns the MagicDNS hostname (e.g. "mybox.tailnet.ts.net") or null.
120
+ export function getMagicDNSName(): string | null {
121
+ const info = getTailscaleInfo(); // Parsed Tailscale status for the local node.
122
+ return info ? info.dnsName : null;
123
+ }
124
+
125
+ // Returns the full URL to access Exeggutor via Tailscale, or null.
126
+ export function getTailscaleURL(port: number): string | null {
127
+ const dnsName = getMagicDNSName(); // MagicDNS hostname of the local node.
128
+ if (dnsName) {
129
+ return `https://${dnsName}:${port}`;
130
+ }
131
+ const ip = getTailscaleIP(); // Tailscale IP of the local node.
132
+ if (ip) {
133
+ return `http://${ip}:${port}`;
134
+ }
135
+ return null;
136
+ }
137
+
138
+