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,244 @@
1
+ import { logger } from '../lib/logger.js';
2
+ import { isInstalled, openTunnel, login } from '../lib/aptible.js';
3
+ import {
4
+ getDatabase, getAllTunnelTargets, getDefaultEnv, readPassword, load, getEnvironment,
5
+ } from '../lib/config-manager.js';
6
+ import { isPortInUse, killProcess } from '../lib/platform.js';
7
+ import {
8
+ isRunning, readPid, readConnectionInfo, saveConnectionInfo, savePid,
9
+ cleanup, toIdentifier, logFilePath,
10
+ } from '../lib/process-manager.js';
11
+ import chalk from 'chalk';
12
+
13
+ // ─── Entry point ──────────────────────────────────────────────────────────────
14
+
15
+ export async function runTunnel(args) {
16
+ if (!isInstalled()) {
17
+ logger.error('Aptible CLI not found. Run `aptunnel init` first.');
18
+ process.exit(1);
19
+ }
20
+
21
+ const target = args[0]; // db alias or "all"
22
+ const doClose = args.includes('--close');
23
+ const doForce = args.includes('--force');
24
+ const portArg = parseFlag(args, '--port');
25
+ const envArg = parseFlag(args, '--env');
26
+
27
+ if (target === 'all') {
28
+ await handleAll({ doClose, envArg, doForce });
29
+ } else {
30
+ await handleOne({ alias: target, doClose, doForce, portOverride: portArg ? Number(portArg) : null, envOverride: envArg });
31
+ }
32
+ }
33
+
34
+ // ─── Single tunnel ────────────────────────────────────────────────────────────
35
+
36
+ async function handleOne({ alias, doClose, doForce, portOverride, envOverride }) {
37
+ const db = getDatabase(alias);
38
+ if (!db) {
39
+ logger.error(`Unknown database: "${alias}". Run \`aptunnel status\` or \`aptunnel --help\` to see available aliases.`);
40
+ process.exit(1);
41
+ }
42
+
43
+ // Environment override
44
+ const environment = envOverride
45
+ ? (getEnvironment(envOverride) ?? envOverride)
46
+ : db.environment;
47
+
48
+ const port = portOverride ?? db.port;
49
+ const id = toIdentifier(db.alias);
50
+
51
+ if (doClose) {
52
+ await closeTunnel(id, port);
53
+ return;
54
+ }
55
+
56
+ await openOneTunnel({ db, environment, port, id, doForce });
57
+ }
58
+
59
+ // ─── All tunnels ──────────────────────────────────────────────────────────────
60
+
61
+ async function handleAll({ doClose, envArg, doForce }) {
62
+ const config = load();
63
+ const envHandle = envArg
64
+ ? (getEnvironment(envArg) ?? envArg)
65
+ : (config.defaults?.environment ?? null);
66
+
67
+ if (!envHandle) {
68
+ logger.error('No default environment configured. Use --env=<alias> or run `aptunnel init`.');
69
+ process.exit(1);
70
+ }
71
+
72
+ const targets = getAllTunnelTargets(envHandle);
73
+ if (targets.length === 0) {
74
+ logger.warn(`No databases configured for environment: ${envHandle}`);
75
+ return;
76
+ }
77
+
78
+ if (doClose) {
79
+ for (const db of targets) {
80
+ const id = toIdentifier(db.alias);
81
+ await closeTunnel(id, db.port);
82
+ }
83
+ return;
84
+ }
85
+
86
+ logger.info(`Opening ${targets.length} tunnel(s) for environment: ${envHandle}`);
87
+ console.log('');
88
+
89
+ const results = [];
90
+ let sessionValid = false;
91
+
92
+ for (const db of targets) {
93
+ const id = toIdentifier(db.alias);
94
+ const result = await openOneTunnel({
95
+ db,
96
+ environment: envHandle,
97
+ port: db.port,
98
+ id,
99
+ doForce,
100
+ skipRelogin: sessionValid,
101
+ silent: false,
102
+ });
103
+ if (result?.success) sessionValid = true;
104
+ results.push({ db, ...result });
105
+ }
106
+
107
+ // Summary table
108
+ console.log('');
109
+ logger.section('Summary');
110
+ console.log('');
111
+ const labelW = Math.max(...results.map(r => r.db.alias.length), 8);
112
+ const header = `${'ALIAS'.padEnd(labelW)} ${'PORT'.padEnd(6)} STATUS`;
113
+ logger.plain(chalk.bold(header));
114
+ logger.plain('─'.repeat(header.length));
115
+ for (const r of results) {
116
+ const status = r.success ? chalk.green('UP') : chalk.red('FAILED');
117
+ logger.plain(`${r.db.alias.padEnd(labelW)} ${String(r.db.port).padEnd(6)} ${status}`);
118
+ }
119
+ }
120
+
121
+ // ─── Core open logic ──────────────────────────────────────────────────────────
122
+
123
+ async function openOneTunnel({ db, environment, port, id, doForce, skipRelogin = false, silent = false }) {
124
+ // Already running?
125
+ if (isRunning(id)) {
126
+ const conn = readConnectionInfo(id);
127
+ logger.info(`${db.alias} tunnel already running (PID ${readPid(id)})`);
128
+ if (conn) printConnectionInfo(db.alias, port, conn);
129
+ return { success: true };
130
+ }
131
+
132
+ // Port in use by something else?
133
+ const portState = isPortInUse(port);
134
+ if (portState.inUse) {
135
+ if (doForce) {
136
+ logger.warn(`Killing process ${portState.pid} occupying port ${port}…`);
137
+ killProcess(portState.pid);
138
+ await sleep(500);
139
+ } else {
140
+ logger.warn(`Port ${port} is already in use (PID ${portState.pid}). Use --force to kill it or --port=<N> to use a different port.`);
141
+ return { success: false };
142
+ }
143
+ }
144
+
145
+ const ora = (await import('ora')).default;
146
+ const spinner = ora(`Opening tunnel to ${db.alias}…`).start();
147
+
148
+ async function attempt(isRetry) {
149
+ try {
150
+ const result = await openTunnel({ dbHandle: db.handle, environment, port });
151
+
152
+ savePid(id, result.pid);
153
+ saveConnectionInfo(id, {
154
+ url: result.connectionUrl,
155
+ host: result.credentials.host,
156
+ port: result.credentials.port ?? port,
157
+ user: result.credentials.user,
158
+ password: result.credentials.password,
159
+ dbName: result.credentials.dbName,
160
+ });
161
+
162
+ spinner.succeed(`${db.alias} tunnel opened`);
163
+ printConnectionInfo(db.alias, port, readConnectionInfo(id));
164
+ return { success: true };
165
+ } catch (err) {
166
+ if (err.message === 'AUTH_EXPIRED' && !isRetry && !skipRelogin) {
167
+ spinner.warn('Token expired. Re-authenticating…');
168
+ const password = readPassword();
169
+ const config = load();
170
+ const email = config.credentials?.email;
171
+ if (!email || !password) {
172
+ spinner.fail('Cannot re-authenticate: credentials not found. Run `aptunnel login`.');
173
+ return { success: false };
174
+ }
175
+ const ok = await login({ email, password });
176
+ if (!ok) {
177
+ spinner.fail('Re-authentication failed. Run `aptunnel login`.');
178
+ return { success: false };
179
+ }
180
+ return attempt(true);
181
+ }
182
+
183
+ if (err.message === 'PORT_IN_USE') {
184
+ spinner.fail(`Port ${port} is in use. Use --port=<N> to specify a different port.`);
185
+ return { success: false };
186
+ }
187
+
188
+ spinner.fail(`Failed to open tunnel to ${db.alias}: ${err.message}`);
189
+ logger.dim(` Log: ${logFilePath(id)}`);
190
+ return { success: false };
191
+ }
192
+ }
193
+
194
+ return attempt(false);
195
+ }
196
+
197
+ // ─── Close logic ──────────────────────────────────────────────────────────────
198
+
199
+ async function closeTunnel(id, port) {
200
+ const pid = readPid(id);
201
+
202
+ if (!pid) {
203
+ logger.warn(`No tunnel found for ${id}.`);
204
+ return;
205
+ }
206
+
207
+ killProcess(pid);
208
+ await sleep(300);
209
+
210
+ // Verify port is freed
211
+ const portState = isPortInUse(port);
212
+ cleanup(id);
213
+
214
+ if (portState.inUse && portState.pid !== pid) {
215
+ logger.warn(`Port ${port} still in use by PID ${portState.pid} (not aptunnel).`);
216
+ } else {
217
+ logger.success(`${id} tunnel closed.`);
218
+ }
219
+ }
220
+
221
+ // ─── Print helpers ────────────────────────────────────────────────────────────
222
+
223
+ function printConnectionInfo(alias, port, conn) {
224
+ if (!conn) return;
225
+ console.log('');
226
+ logger.detail('Port:', String(conn.port ?? port));
227
+ logger.detail('Host:', conn.host ?? 'localhost.aptible.in');
228
+ logger.detail('User:', conn.user ?? 'aptible');
229
+ logger.detail('Password:', conn.password ? chalk.dim(conn.password) : chalk.dim('(not parsed)'));
230
+ logger.detail('URL:', conn.url ? chalk.cyan(conn.url) : chalk.dim('(not parsed)'));
231
+ logger.detail('PID:', String(readPid(toIdentifier(alias)) ?? '?'));
232
+ console.log('');
233
+ }
234
+
235
+ // ─── Utilities ────────────────────────────────────────────────────────────────
236
+
237
+ function parseFlag(args, flag) {
238
+ const entry = args.find(a => a.startsWith(`${flag}=`));
239
+ return entry ? entry.slice(flag.length + 1) : null;
240
+ }
241
+
242
+ function sleep(ms) {
243
+ return new Promise(r => setTimeout(r, ms));
244
+ }
package/src/index.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * aptunnel — CLI router
3
+ *
4
+ * Parses argv and dispatches to the appropriate command handler.
5
+ * No third-party arg-parsing library — manual, minimal, fast.
6
+ */
7
+
8
+ import { createRequire } from 'module';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, resolve } from 'path';
11
+ import { logger } from './lib/logger.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const require = createRequire(import.meta.url);
15
+ const pkg = require(resolve(__dirname, '../package.json'));
16
+
17
+ // ─── Signal handling — clean up tunnels on exit ───────────────────────────────
18
+
19
+ async function gracefulShutdown(signal) {
20
+ // Only run cleanup if tunnels are open
21
+ try {
22
+ const { getAllRunningTunnels, cleanup } = await import('./lib/process-manager.js');
23
+ const { killProcess } = await import('./lib/platform.js');
24
+ const tunnels = getAllRunningTunnels();
25
+ if (tunnels.length > 0) {
26
+ process.stderr.write(`\n[aptunnel] Caught ${signal}. Closing ${tunnels.length} tunnel(s)…\n`);
27
+ for (const t of tunnels) {
28
+ if (t.running) killProcess(t.pid);
29
+ cleanup(t.identifier);
30
+ }
31
+ }
32
+ } catch { /* ignore errors during shutdown */ }
33
+ process.exit(0);
34
+ }
35
+
36
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
37
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
38
+ if (process.platform === 'win32') {
39
+ process.on('SIGBREAK', () => gracefulShutdown('SIGBREAK'));
40
+ }
41
+
42
+ // ─── Main router ──────────────────────────────────────────────────────────────
43
+
44
+ const argv = process.argv.slice(2);
45
+ const [command, ...rest] = argv;
46
+
47
+ // Global flags
48
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
49
+ const { runHelp } = await import('./commands/help.js');
50
+ runHelp();
51
+ process.exit(0);
52
+ }
53
+
54
+ if (command === '--version' || command === '-v') {
55
+ console.log(`aptunnel v${pkg.version}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ // Route to command handlers
60
+ try {
61
+ switch (command) {
62
+ case 'init': {
63
+ const { runInit } = await import('./commands/init.js');
64
+ await runInit(rest);
65
+ break;
66
+ }
67
+
68
+ case 'login': {
69
+ const { runLogin } = await import('./commands/login.js');
70
+ await runLogin(rest);
71
+ break;
72
+ }
73
+
74
+ case 'status': {
75
+ const { runStatus } = await import('./commands/status.js');
76
+ runStatus();
77
+ break;
78
+ }
79
+
80
+ case 'config': {
81
+ const { runConfig } = await import('./commands/config.js');
82
+ await runConfig(rest);
83
+ break;
84
+ }
85
+
86
+ case 'completions': {
87
+ const { runCompletions } = await import('./commands/completions.js');
88
+ await runCompletions(rest);
89
+ break;
90
+ }
91
+
92
+ default: {
93
+ // Any other command is treated as a db alias (or "all")
94
+ const { runTunnel } = await import('./commands/tunnel.js');
95
+ await runTunnel([command, ...rest]);
96
+ break;
97
+ }
98
+ }
99
+ } catch (err) {
100
+ // Detect config-missing errors and give a friendly message
101
+ if (err.message?.includes('Run `aptunnel init`')) {
102
+ logger.error(err.message);
103
+ } else if (err.message?.includes('Config file is corrupted')) {
104
+ logger.error(err.message);
105
+ } else {
106
+ logger.error(`Unexpected error: ${err.message}`);
107
+ if (process.env.DEBUG) console.error(err.stack);
108
+ }
109
+ process.exit(1);
110
+ }
@@ -0,0 +1,335 @@
1
+ import { spawnSync, spawn } from 'child_process';
2
+ import { readFileSync, existsSync, writeFileSync, openSync, closeSync } from 'fs';
3
+ import { homedir, tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ // On Windows, .cmd wrappers require a shell to be resolved by CreateProcess.
7
+ const SHELL_OPT = process.platform === 'win32' ? { shell: true } : {};
8
+
9
+ function getTempDir() {
10
+ return process.env.APTUNNEL_TEMP_DIR ?? tmpdir();
11
+ }
12
+
13
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
14
+
15
+ function run(args, opts = {}) {
16
+ // Destructure to avoid `...opts` overwriting the merged env object below
17
+ const { env: envOverrides = {}, ...spawnOpts } = opts;
18
+ const env = { ...process.env, ...envOverrides };
19
+ return spawnSync('aptible', args, { encoding: 'utf8', env, ...SHELL_OPT, ...spawnOpts });
20
+ }
21
+
22
+ function parseJson(raw) {
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // ─── Exports ─────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Check if the `aptible` binary is present in PATH.
34
+ * @returns {boolean}
35
+ */
36
+ export function isInstalled() {
37
+ const result = spawnSync('aptible', ['version'], { encoding: 'utf8', ...SHELL_OPT });
38
+ return result.status === 0 && !result.error;
39
+ }
40
+
41
+ /**
42
+ * @returns {string} version string, e.g. "aptible-toolbelt v0.20.0"
43
+ */
44
+ export function getVersion() {
45
+ const result = run(['version']);
46
+ return result.stdout?.trim() ?? 'unknown';
47
+ }
48
+
49
+ /**
50
+ * Log in to Aptible. Uses stdio: 'inherit' so 2FA prompts reach the terminal.
51
+ * @param {{ email: string, password: string, lifetime?: string, otp?: string }} opts
52
+ * @returns {Promise<boolean>}
53
+ */
54
+ export function login({ email, password, lifetime = '7d', otp } = {}) {
55
+ return new Promise((resolve) => {
56
+ const args = ['login', `--lifetime=${lifetime}`];
57
+ if (email) args.push(`--email=${email}`);
58
+ if (password) args.push(`--password=${password}`);
59
+ if (otp) args.push(`--otp=${otp}`);
60
+
61
+ // Resume stdin so aptible can receive input (e.g. 2FA OTP prompt).
62
+ // readline.close() pauses process.stdin; we must unpause it before handing
63
+ // it to a child process, otherwise the child's read() never returns.
64
+ process.stdin.resume();
65
+
66
+ // stdio: 'inherit' is critical — aptible prompts for 2FA interactively
67
+ const child = spawn('aptible', args, { stdio: 'inherit', ...SHELL_OPT });
68
+
69
+ child.on('close', (code) => resolve(code === 0));
70
+ child.on('error', () => resolve(false));
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Read and decode the current Aptible token from ~/.aptible/tokens.json
76
+ * @returns {{ email: string, issuedAt: Date, expiresAt: Date, remainingHours: number, isExpired: boolean } | null}
77
+ */
78
+ export function getTokenInfo() {
79
+ const tokensPath = join(homedir(), '.aptible', 'tokens.json');
80
+ if (!existsSync(tokensPath)) return null;
81
+
82
+ let tokens;
83
+ try {
84
+ tokens = JSON.parse(readFileSync(tokensPath, 'utf8'));
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ // tokens.json is an object keyed by URL; pick the first entry
90
+ const entries = Object.values(tokens);
91
+ if (!entries.length) return null;
92
+
93
+ const token = entries[0];
94
+
95
+ // Decode JWT payload (middle segment, base64url encoded)
96
+ try {
97
+ const parts = token.split('.');
98
+ if (parts.length !== 3) return null;
99
+
100
+ // base64url → base64
101
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
102
+ const padded = payload + '='.repeat((4 - payload.length % 4) % 4);
103
+ const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
104
+
105
+ const issuedAt = decoded.iat ? new Date(decoded.iat * 1000) : null;
106
+ const expiresAt = new Date(decoded.exp * 1000);
107
+ const now = new Date();
108
+
109
+ const remainingMs = expiresAt - now;
110
+ const remainingHours = Math.max(0, Math.floor(remainingMs / 3_600_000));
111
+ const isExpired = remainingMs <= 0;
112
+
113
+ return {
114
+ email: decoded.email ?? decoded.sub ?? 'unknown',
115
+ issuedAt,
116
+ expiresAt,
117
+ remainingHours,
118
+ isExpired,
119
+ };
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * List all Aptible environments the current user has access to.
127
+ * @returns {{ id: string, handle: string }[]}
128
+ */
129
+ export function listEnvironments() {
130
+ const result = run(['environment:list'], {
131
+ env: { APTIBLE_OUTPUT_FORMAT: 'json' },
132
+ });
133
+
134
+ if (result.status !== 0) return [];
135
+
136
+ const data = parseJson(result.stdout);
137
+ if (!Array.isArray(data)) return [];
138
+
139
+ return data.map((e) => ({ id: String(e.id ?? e.ID ?? ''), handle: e.handle ?? e.Handle ?? '' }));
140
+ }
141
+
142
+ /**
143
+ * List databases for a given environment.
144
+ * @param {string} environmentHandle
145
+ * @returns {{ id: string, handle: string, type: string, status: string }[]}
146
+ */
147
+ export function listDatabases(environmentHandle) {
148
+ const result = run(['db:list', '--environment', environmentHandle], {
149
+ env: { APTIBLE_OUTPUT_FORMAT: 'json' },
150
+ });
151
+
152
+ if (result.status !== 0) return [];
153
+
154
+ const data = parseJson(result.stdout);
155
+ if (!Array.isArray(data)) return [];
156
+
157
+ return data.map((db) => ({
158
+ id: String(db.id ?? db.ID ?? ''),
159
+ handle: db.handle ?? db.Handle ?? '',
160
+ type: db.type ?? db.Type ?? 'unknown',
161
+ status: db.status ?? db.Status ?? 'unknown',
162
+ }));
163
+ }
164
+
165
+ /**
166
+ * Open a tunnel to a database in the background.
167
+ *
168
+ * Spawns `aptible db:tunnel` detached, writes stdout to a log file,
169
+ * saves PID to a PID file, waits a few seconds, then parses connection info.
170
+ *
171
+ * @param {{ dbHandle: string, environment: string, port: number }} opts
172
+ * @returns {Promise<{ pid: number, port: number, connectionUrl: string, credentials: object }>}
173
+ */
174
+ export function openTunnel({ dbHandle, environment, port }) {
175
+ return new Promise((resolve, reject) => {
176
+ const identifier = sanitize(dbHandle);
177
+ const logFile = join(getTempDir(), `aptunnel-${identifier}.log`);
178
+
179
+ // Open the log file synchronously so we have a real fd to hand to spawn.
180
+ // createWriteStream has fd=null until 'open' fires; spawn rejects that.
181
+ const logFd = openSync(logFile, 'w');
182
+
183
+ const args = ['db:tunnel', dbHandle, '--environment', environment, '--port', String(port)];
184
+ const child = spawn('aptible', args, {
185
+ detached: true,
186
+ stdio: ['ignore', logFd, logFd],
187
+ ...SHELL_OPT,
188
+ });
189
+
190
+ // Parent no longer needs the fd — the child has its own dup'd copy
191
+ closeSync(logFd);
192
+
193
+ child.unref();
194
+
195
+ // Poll the log file until aptible prints "Connect at" or an error, or we time out.
196
+ // A fixed 5s wait was too short for slow SSH connections.
197
+ const POLL_INTERVAL_MS = 500;
198
+ const TIMEOUT_MS = 60_000; // 60 seconds max
199
+ let elapsed = 0;
200
+
201
+ const poll = setInterval(() => {
202
+ elapsed += POLL_INTERVAL_MS;
203
+
204
+ let logContent = '';
205
+ try { logContent = readFileSync(logFile, 'utf8'); } catch { /* file not yet created */ }
206
+
207
+ const lower = logContent.toLowerCase();
208
+
209
+ // Fatal errors — fail immediately
210
+ if (lower.includes('unauthorized') || lower.includes('token has expired') || lower.includes('not authenticated')) {
211
+ clearInterval(poll);
212
+ reject(new Error('AUTH_EXPIRED'));
213
+ return;
214
+ }
215
+ if (lower.includes('already in use') || lower.includes('address already in use')) {
216
+ clearInterval(poll);
217
+ reject(new Error('PORT_IN_USE'));
218
+ return;
219
+ }
220
+
221
+ // Process died unexpectedly
222
+ try { process.kill(child.pid, 0); } catch {
223
+ clearInterval(poll);
224
+ reject(new Error(`Tunnel process died. Log:\n${logContent.slice(-500)}`));
225
+ return;
226
+ }
227
+
228
+ // Success: aptible printed the connection URL
229
+ if (lower.includes('connect at') || lower.includes('connected.')) {
230
+ clearInterval(poll);
231
+ const conn = parseConnectionInfo(logContent, port);
232
+ resolve({ pid: child.pid, port, ...conn });
233
+ return;
234
+ }
235
+
236
+ // Timed out
237
+ if (elapsed >= TIMEOUT_MS) {
238
+ clearInterval(poll);
239
+ reject(new Error(`Tunnel timed out after ${TIMEOUT_MS / 1000}s. Log:\n${logContent.slice(-500)}`));
240
+ }
241
+ }, POLL_INTERVAL_MS);
242
+
243
+ child.on('error', (err) => {
244
+ clearInterval(poll);
245
+ reject(new Error(`Failed to spawn aptible: ${err.message}`));
246
+ });
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Parse aptible db:tunnel output for connection info.
252
+ * Aptible prints something like:
253
+ * Connect at postgresql://aptible:PASSWORD@localhost.aptible.in:PORT/db
254
+ * @param {string} log
255
+ * @param {number} port
256
+ * @returns {{ connectionUrl: string, credentials: { host, port, user, password, dbName } }}
257
+ */
258
+ function parseConnectionInfo(log, port) {
259
+ // Try to find a connection URL in the log
260
+ const urlMatch = log.match(/Connect at (\S+:\/\/\S+)/i)
261
+ ?? log.match(/(postgresql|mysql|redis|mongodb):\/\/[^\s]+/i);
262
+
263
+ const connectionUrl = urlMatch?.[1] ?? '';
264
+
265
+ let credentials = { host: 'localhost.aptible.in', port, user: 'aptible', password: null, dbName: null };
266
+
267
+ if (connectionUrl) {
268
+ try {
269
+ const parsed = new URL(connectionUrl);
270
+ credentials = {
271
+ host: parsed.hostname,
272
+ port: parsed.port ? Number(parsed.port) : port,
273
+ user: parsed.username ?? 'aptible',
274
+ password: parsed.password ?? null,
275
+ dbName: parsed.pathname?.replace(/^\//, '') ?? null,
276
+ };
277
+ } catch { /* keep defaults */ }
278
+ } else {
279
+ // Fallback: extract individual fields from log lines
280
+ const hostMatch = log.match(/host[:\s]+([a-z0-9.-]+\.aptible\.in)/i);
281
+ const passMatch = log.match(/password[:\s]+([^\s]+)/i);
282
+ const userMatch = log.match(/user(?:name)?[:\s]+([^\s]+)/i);
283
+ if (hostMatch) credentials.host = hostMatch[1];
284
+ if (passMatch) credentials.password = passMatch[1];
285
+ if (userMatch) credentials.user = userMatch[1];
286
+ }
287
+
288
+ return { connectionUrl, credentials };
289
+ }
290
+
291
+ /**
292
+ * List apps for an environment.
293
+ * @param {string} environmentHandle
294
+ * @returns {{ id: string, handle: string, status: string }[]}
295
+ */
296
+ export function listApps(environmentHandle) {
297
+ const result = run(['apps', '--environment', environmentHandle], {
298
+ env: { APTIBLE_OUTPUT_FORMAT: 'json' },
299
+ });
300
+
301
+ if (result.status !== 0) return [];
302
+
303
+ const data = parseJson(result.stdout);
304
+ if (!Array.isArray(data)) return [];
305
+
306
+ return data.map((app) => ({
307
+ id: String(app.id ?? app.ID ?? ''),
308
+ handle: app.handle ?? app.Handle ?? '',
309
+ status: app.status ?? app.Status ?? 'unknown',
310
+ }));
311
+ }
312
+
313
+ /**
314
+ * Stream logs for an app to the terminal (blocking).
315
+ * @param {{ appHandle: string, environment: string }} opts
316
+ * @returns {Promise<void>}
317
+ */
318
+ export function getLogs({ appHandle, environment }) {
319
+ return new Promise((resolve, reject) => {
320
+ const child = spawn(
321
+ 'aptible',
322
+ ['logs', '--app', appHandle, '--environment', environment],
323
+ { stdio: 'inherit', ...SHELL_OPT }
324
+ );
325
+ child.on('close', resolve);
326
+ child.on('error', reject);
327
+ });
328
+ }
329
+
330
+ // ─── Utilities ────────────────────────────────────────────────────────────────
331
+
332
+ function sanitize(str) {
333
+ return str.replace(/[^a-zA-Z0-9-_]/g, '-');
334
+ }
335
+