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.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/aptunnel.js +23 -0
- package/package.json +38 -0
- package/src/commands/completions.js +24 -0
- package/src/commands/config.js +119 -0
- package/src/commands/help.js +106 -0
- package/src/commands/init.js +300 -0
- package/src/commands/login.js +118 -0
- package/src/commands/status.js +99 -0
- package/src/commands/tunnel.js +244 -0
- package/src/index.js +110 -0
- package/src/lib/aptible.js +335 -0
- package/src/lib/completions.js +216 -0
- package/src/lib/config-manager.js +247 -0
- package/src/lib/logger.js +26 -0
- package/src/lib/platform.js +225 -0
- package/src/lib/process-manager.js +119 -0
- package/src/templates/config.yaml +17 -0
|
@@ -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
|
+
|