aptunnel 1.0.0

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,225 @@
1
+ import { spawnSync, spawn } from 'child_process';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { promisify } from 'util';
4
+ import { exec } from 'child_process';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ /**
9
+ * Detect the current operating system.
10
+ * @returns {'linux' | 'macos' | 'windows' | 'wsl'}
11
+ */
12
+ export function detectOS() {
13
+ if (process.platform === 'win32') return 'windows';
14
+
15
+ if (process.platform === 'linux') {
16
+ // WSL detection: /proc/version contains "microsoft" or "WSL"
17
+ try {
18
+ const version = readFileSync('/proc/version', 'utf8').toLowerCase();
19
+ if (version.includes('microsoft') || version.includes('wsl')) {
20
+ return 'wsl';
21
+ }
22
+ } catch {
23
+ // /proc/version not readable — treat as regular Linux
24
+ }
25
+ return 'linux';
26
+ }
27
+
28
+ if (process.platform === 'darwin') return 'macos';
29
+
30
+ return 'linux'; // fallback
31
+ }
32
+
33
+ /**
34
+ * Check if a port is currently in use.
35
+ * @param {number} port
36
+ * @returns {{ inUse: boolean, pid: number | null }}
37
+ */
38
+ export function isPortInUse(port) {
39
+ const os = detectOS();
40
+
41
+ if (os === 'windows') {
42
+ const result = spawnSync('netstat', ['-ano'], { encoding: 'utf8' });
43
+ if (result.status !== 0 || !result.stdout) return { inUse: false, pid: null };
44
+
45
+ const lines = result.stdout.split('\n');
46
+ for (const line of lines) {
47
+ if (line.includes(`:${port}`) && (line.includes('LISTENING') || line.includes('ESTABLISHED'))) {
48
+ const parts = line.trim().split(/\s+/);
49
+ const pid = parseInt(parts[parts.length - 1], 10);
50
+ return { inUse: true, pid: isNaN(pid) ? null : pid };
51
+ }
52
+ }
53
+ return { inUse: false, pid: null };
54
+ }
55
+
56
+ // Linux / macOS / WSL
57
+ const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8' });
58
+ if (result.status !== 0 || !result.stdout.trim()) {
59
+ return { inUse: false, pid: null };
60
+ }
61
+
62
+ const pid = parseInt(result.stdout.trim().split('\n')[0], 10);
63
+ return { inUse: true, pid: isNaN(pid) ? null : pid };
64
+ }
65
+
66
+ /**
67
+ * Get info about a running process by PID.
68
+ * @param {number} pid
69
+ * @returns {{ running: boolean, command: string | null }}
70
+ */
71
+ export function getProcessInfo(pid) {
72
+ const os = detectOS();
73
+
74
+ if (os === 'windows') {
75
+ // tasklist is available on all Windows versions; wmic is deprecated/removed in modern Windows.
76
+ const result = spawnSync(
77
+ 'tasklist',
78
+ ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
79
+ { encoding: 'utf8' }
80
+ );
81
+ if (result.status !== 0 || !result.stdout.trim()) return { running: false, command: null };
82
+ // tasklist outputs "INFO: No tasks..." when PID not found; CSV lines otherwise.
83
+ const lines = result.stdout.trim().split('\n')
84
+ .filter(l => l.trim() && !l.toLowerCase().startsWith('info'));
85
+ if (lines.length === 0) return { running: false, command: null };
86
+ return { running: true, command: lines[0].trim() };
87
+ }
88
+
89
+ // Linux / macOS / WSL
90
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' });
91
+ if (result.status !== 0 || !result.stdout.trim()) {
92
+ return { running: false, command: null };
93
+ }
94
+ return { running: true, command: result.stdout.trim() };
95
+ }
96
+
97
+ /**
98
+ * Kill a process by PID.
99
+ * @param {number} pid
100
+ */
101
+ export function killProcess(pid) {
102
+ const os = detectOS();
103
+
104
+ if (os === 'windows') {
105
+ spawnSync('taskkill', ['/PID', String(pid), '/F'], { encoding: 'utf8' });
106
+ return;
107
+ }
108
+
109
+ try {
110
+ process.kill(pid, 'SIGTERM');
111
+ } catch {
112
+ // Process may already be gone — ignore
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Get how long a process has been running.
118
+ * @param {number} pid
119
+ * @returns {{ hours: number, minutes: number, seconds: number } | null}
120
+ */
121
+ export function getProcessUptime(pid) {
122
+ const os = detectOS();
123
+
124
+ if (os === 'windows') {
125
+ // Use PowerShell to get process start time (wmic is deprecated/removed in modern Windows).
126
+ const result = spawnSync(
127
+ 'powershell',
128
+ ['-NoProfile', '-NonInteractive', '-Command',
129
+ `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { $p.StartTime.ToUniversalTime().ToString('o') }`],
130
+ { encoding: 'utf8' }
131
+ );
132
+ if (result.status !== 0 || !result.stdout.trim()) return null;
133
+ const startDate = new Date(result.stdout.trim());
134
+ if (isNaN(startDate.getTime())) return null;
135
+ return computeUptime(startDate);
136
+ }
137
+
138
+ // Linux / macOS — both use `ps -o lstart=`
139
+ // NOTE: Never use `date -d` (GNU only). We use `new Date()` to parse.
140
+ const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' });
141
+ if (result.status !== 0 || !result.stdout.trim()) return null;
142
+
143
+ const startDate = new Date(result.stdout.trim());
144
+ if (isNaN(startDate.getTime())) return null;
145
+
146
+ return computeUptime(startDate);
147
+ }
148
+
149
+ function computeUptime(startDate) {
150
+ const diffMs = Date.now() - startDate.getTime();
151
+ if (diffMs < 0) return null;
152
+
153
+ const totalSeconds = Math.floor(diffMs / 1000);
154
+ const hours = Math.floor(totalSeconds / 3600);
155
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
156
+ const seconds = totalSeconds % 60;
157
+ return { hours, minutes, seconds };
158
+ }
159
+
160
+ /**
161
+ * Format uptime as "HHhMMmSSs" string.
162
+ * @param {{ hours: number, minutes: number, seconds: number } | null} uptime
163
+ * @returns {string}
164
+ */
165
+ export function formatUptime(uptime) {
166
+ if (!uptime) return '-';
167
+ const h = String(uptime.hours).padStart(2, '0');
168
+ const m = String(uptime.minutes).padStart(2, '0');
169
+ const s = String(uptime.seconds).padStart(2, '0');
170
+ return `${h}h${m}m${s}s`;
171
+ }
172
+
173
+ /**
174
+ * Open a URL in the system browser.
175
+ * @param {string} url
176
+ */
177
+ export function openUrl(url) {
178
+ const os = detectOS();
179
+
180
+ if (os === 'macos') {
181
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
182
+ return;
183
+ }
184
+
185
+ if (os === 'windows') {
186
+ spawn('cmd', ['/c', 'start', url], { detached: true, stdio: 'ignore' }).unref();
187
+ return;
188
+ }
189
+
190
+ if (os === 'wsl') {
191
+ // Try wslview first, fall back to cmd.exe
192
+ const which = spawnSync('which', ['wslview'], { encoding: 'utf8' });
193
+ if (which.status === 0) {
194
+ spawn('wslview', [url], { detached: true, stdio: 'ignore' }).unref();
195
+ } else {
196
+ spawn('cmd.exe', ['/c', 'start', url], { detached: true, stdio: 'ignore' }).unref();
197
+ }
198
+ return;
199
+ }
200
+
201
+ // Linux
202
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
203
+ }
204
+
205
+ /**
206
+ * Install instructions per OS for a missing tool.
207
+ * @param {'aptible'} tool
208
+ * @returns {string}
209
+ */
210
+ export function installInstructions(tool) {
211
+ const os = detectOS();
212
+
213
+ if (tool === 'aptible') {
214
+ if (os === 'macos') {
215
+ return 'Install Aptible CLI:\n brew install aptible/aptible/aptible\n or: https://www.aptible.com/docs/cli';
216
+ }
217
+ if (os === 'windows' || os === 'wsl') {
218
+ return 'Install Aptible CLI:\n Download from: https://www.aptible.com/docs/cli\n or use WSL with the Linux install method';
219
+ }
220
+ // Linux
221
+ return 'Install Aptible CLI:\n curl -s https://toolbelt.aptible.com/install.sh | bash\n or: https://www.aptible.com/docs/cli';
222
+ }
223
+
224
+ return `Please install ${tool} and try again.`;
225
+ }
@@ -0,0 +1,119 @@
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { tmpdir } from 'os';
4
+ import { getProcessInfo, getProcessUptime } from './platform.js';
5
+
6
+ // Allow tests to redirect temp files to an isolated directory
7
+ function getTempDir() {
8
+ return process.env.APTUNNEL_TEMP_DIR ?? tmpdir();
9
+ }
10
+
11
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
12
+
13
+ export function pidFilePath(identifier) { return join(getTempDir(), `aptunnel-${identifier}.pid`); }
14
+ export function logFilePath(identifier) { return join(getTempDir(), `aptunnel-${identifier}.log`); }
15
+ export function connFilePath(identifier) { return join(getTempDir(), `aptunnel-${identifier}.conn.json`); }
16
+
17
+ // ─── PID management ───────────────────────────────────────────────────────────
18
+
19
+ export function savePid(identifier, pid) {
20
+ writeFileSync(pidFilePath(identifier), String(pid), { mode: 0o600 });
21
+ }
22
+
23
+ export function readPid(identifier) {
24
+ const path = pidFilePath(identifier);
25
+ if (!existsSync(path)) return null;
26
+ const val = parseInt(readFileSync(path, 'utf8').trim(), 10);
27
+ return isNaN(val) ? null : val;
28
+ }
29
+
30
+ export function removePid(identifier) {
31
+ const path = pidFilePath(identifier);
32
+ if (existsSync(path)) unlinkSync(path);
33
+ }
34
+
35
+ /**
36
+ * Check if the process for an identifier is still alive.
37
+ * @param {string} identifier
38
+ * @returns {boolean}
39
+ */
40
+ export function isRunning(identifier) {
41
+ const pid = readPid(identifier);
42
+ if (!pid) return false;
43
+ const info = getProcessInfo(pid);
44
+ return info.running;
45
+ }
46
+
47
+ // ─── Connection info ──────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * @param {string} identifier
51
+ * @param {{ url: string, host: string, port: number, user: string, password: string, dbName: string }} info
52
+ */
53
+ export function saveConnectionInfo(identifier, info) {
54
+ writeFileSync(connFilePath(identifier), JSON.stringify(info, null, 2), { mode: 0o600 });
55
+ }
56
+
57
+ /**
58
+ * @param {string} identifier
59
+ * @returns {object | null}
60
+ */
61
+ export function readConnectionInfo(identifier) {
62
+ const path = connFilePath(identifier);
63
+ if (!existsSync(path)) return null;
64
+ try {
65
+ return JSON.parse(readFileSync(path, 'utf8'));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function removeConnectionInfo(identifier) {
72
+ const path = connFilePath(identifier);
73
+ if (existsSync(path)) unlinkSync(path);
74
+ }
75
+
76
+ // ─── Global state ─────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Scan /tmp for all aptunnel-*.pid files and return status for each.
80
+ * @returns {{ identifier: string, pid: number, running: boolean, uptime: object | null, conn: object | null }[]}
81
+ */
82
+ export function getAllRunningTunnels() {
83
+ let files;
84
+ try {
85
+ files = readdirSync(getTempDir());
86
+ } catch {
87
+ return [];
88
+ }
89
+
90
+ return files
91
+ .filter(f => f.startsWith('aptunnel-') && f.endsWith('.pid'))
92
+ .map((f) => {
93
+ const identifier = f.replace(/^aptunnel-/, '').replace(/\.pid$/, '');
94
+ const pid = readPid(identifier);
95
+ if (!pid) return null;
96
+
97
+ const info = getProcessInfo(pid);
98
+ const uptime = info.running ? getProcessUptime(pid) : null;
99
+ const conn = readConnectionInfo(identifier);
100
+
101
+ return { identifier, pid, running: info.running, uptime, conn };
102
+ })
103
+ .filter(Boolean);
104
+ }
105
+
106
+ /**
107
+ * Clean up PID + conn files for a given identifier.
108
+ */
109
+ export function cleanup(identifier) {
110
+ removePid(identifier);
111
+ removeConnectionInfo(identifier);
112
+ }
113
+
114
+ /**
115
+ * Sanitize a db handle/alias into a safe identifier for filenames.
116
+ */
117
+ export function toIdentifier(str) {
118
+ return str.replace(/[^a-zA-Z0-9-_]/g, '-');
119
+ }
@@ -0,0 +1,17 @@
1
+ # aptunnel configuration — generated by `aptunnel init`
2
+ # Edit manually or use `aptunnel config` commands.
3
+ version: 1
4
+
5
+ credentials:
6
+ email: ""
7
+ # Password is stored separately in ~/.aptunnel/.credentials (mode 600)
8
+
9
+ defaults:
10
+ environment: ""
11
+ lifetime: 7d
12
+
13
+ environments: {}
14
+
15
+ tunnel_defaults:
16
+ start_port: 55550
17
+ port_increment: 1