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,300 @@
1
+ import readline from 'readline';
2
+ import { createInterface } from 'readline';
3
+ import { logger } from '../lib/logger.js';
4
+ import { isInstalled, login, listEnvironments, listDatabases } from '../lib/aptible.js';
5
+ import { installInstructions, detectOS } from '../lib/platform.js';
6
+ import {
7
+ exists, save, savePassword, getConfigDir, getConfigPath, nextAvailablePort,
8
+ } from '../lib/config-manager.js';
9
+
10
+ // ─── Entry point ──────────────────────────────────────────────────────────────
11
+
12
+ export async function runInit(args) {
13
+ logger.section('aptunnel init — Setup Wizard');
14
+ console.log('');
15
+
16
+ // 1. Check aptible CLI
17
+ if (!isInstalled()) {
18
+ logger.error('Aptible CLI not found in PATH.');
19
+ console.log('');
20
+ console.log(installInstructions('aptible'));
21
+ process.exit(1);
22
+ }
23
+
24
+ // 2. Check existing config
25
+ if (exists()) {
26
+ const reinit = await ask('Config already exists. Reinitialize? (y/N) ');
27
+ if (!reinit.match(/^y(es)?$/i)) {
28
+ logger.info('Aborted.');
29
+ return;
30
+ }
31
+ console.log('');
32
+ }
33
+
34
+ // 3. Collect credentials
35
+ const email = await ask('Aptible email: ');
36
+ const password = await askSecret('Aptible password: ');
37
+ console.log('');
38
+
39
+ // 4. Login (interactive — handles 2FA via stdio: inherit)
40
+ // Close readline BEFORE spawning aptible so stdin is fully available to the child
41
+ closeRL();
42
+ const spinner = (await import('ora')).default({ text: 'Logging in to Aptible…' }).start();
43
+ const ok = await login({ email, password });
44
+ if (!ok) {
45
+ spinner.fail('Login failed. Please check your credentials.');
46
+ process.exit(1);
47
+ }
48
+ spinner.succeed('Logged in successfully.');
49
+ console.log('');
50
+
51
+ // 5. Discover environments
52
+ const envSpinner = (await import('ora')).default({ text: 'Discovering environments…' }).start();
53
+ const environments = listEnvironments();
54
+ envSpinner.succeed(`Found ${environments.length} environment(s).`);
55
+
56
+ if (environments.length === 0) {
57
+ logger.warn('No environments found for this account.');
58
+ process.exit(1);
59
+ }
60
+
61
+ // 6. Select environments
62
+ console.log('');
63
+ console.log('Available environments:');
64
+ environments.forEach((env, i) => {
65
+ console.log(` [${i + 1}] ${env.handle}`);
66
+ });
67
+ console.log('');
68
+
69
+ const selection = await ask(
70
+ `Select environments to configure (comma-separated numbers, or "all") [all]: `
71
+ );
72
+
73
+ let selectedEnvs;
74
+ if (!selection.trim() || selection.trim().toLowerCase() === 'all') {
75
+ selectedEnvs = environments;
76
+ } else {
77
+ const indices = selection.split(',').map(s => parseInt(s.trim(), 10) - 1);
78
+ selectedEnvs = indices
79
+ .filter(i => i >= 0 && i < environments.length)
80
+ .map(i => environments[i]);
81
+ }
82
+
83
+ if (selectedEnvs.length === 0) {
84
+ logger.error('No valid environments selected.');
85
+ process.exit(1);
86
+ }
87
+
88
+ // 7. Set default environment
89
+ let defaultEnvHandle = selectedEnvs[0].handle;
90
+ if (selectedEnvs.length > 1) {
91
+ console.log('');
92
+ selectedEnvs.forEach((env, i) => console.log(` [${i + 1}] ${env.handle}`));
93
+ const defChoice = await ask(`Default environment [1]: `);
94
+ const defIdx = parseInt(defChoice.trim() || '1', 10) - 1;
95
+ if (defIdx >= 0 && defIdx < selectedEnvs.length) {
96
+ defaultEnvHandle = selectedEnvs[defIdx].handle;
97
+ }
98
+ }
99
+
100
+ // 8. For each environment, discover databases and assign ports
101
+ const configEnvironments = {};
102
+ let portCounter = 55550;
103
+
104
+ for (const env of selectedEnvs) {
105
+ console.log('');
106
+ const dbSpinner = (await import('ora')).default({ text: `Fetching databases for ${env.handle}…` }).start();
107
+ const databases = listDatabases(env.handle);
108
+ dbSpinner.succeed(`Found ${databases.length} database(s) in ${env.handle}.`);
109
+
110
+ if (databases.length === 0) continue;
111
+
112
+ // Auto-assign ports
113
+ const assignedDbs = databases.map((db) => ({
114
+ ...db,
115
+ assignedPort: portCounter++,
116
+ suggestedAlias: suggestDbAlias(db.handle, databases),
117
+ }));
118
+
119
+ // Show proposed config
120
+ console.log('');
121
+ console.log(` Databases in ${env.handle}:`);
122
+ assignedDbs.forEach((db, i) => {
123
+ console.log(` [${i + 1}] ${db.handle} → alias: ${db.suggestedAlias} port: ${db.assignedPort} (${db.type})`);
124
+ });
125
+
126
+ // 9. Customize ports?
127
+ const customizePorts = await ask(' Customize ports? (y/N) ');
128
+ if (customizePorts.match(/^y(es)?$/i)) {
129
+ for (const db of assignedDbs) {
130
+ const newPort = await ask(` Port for ${db.handle} [${db.assignedPort}]: `);
131
+ if (newPort.trim()) db.assignedPort = parseInt(newPort.trim(), 10);
132
+ }
133
+ }
134
+
135
+ // 10. Customize aliases?
136
+ const customizeAliases = await ask(' Customize aliases? (y/N) ');
137
+ if (customizeAliases.match(/^y(es)?$/i)) {
138
+ for (const db of assignedDbs) {
139
+ const newAlias = await ask(` Alias for ${db.handle} [${db.suggestedAlias}]: `);
140
+ if (newAlias.trim()) db.suggestedAlias = deduplicateAlias(newAlias.trim(), assignedDbs);
141
+ }
142
+ }
143
+
144
+ // Build environment alias
145
+ const envAlias = suggestEnvAlias(env.handle);
146
+ const confirmedEnvAlias = await ask(` Alias for this environment [${envAlias}]: `);
147
+
148
+ const databasesConfig = {};
149
+ for (const db of assignedDbs) {
150
+ databasesConfig[db.handle] = {
151
+ alias: db.suggestedAlias,
152
+ port: db.assignedPort,
153
+ type: db.type,
154
+ };
155
+ }
156
+
157
+ configEnvironments[env.handle] = {
158
+ alias: confirmedEnvAlias.trim() || envAlias,
159
+ databases: databasesConfig,
160
+ };
161
+ }
162
+
163
+ // 11. Write config
164
+ const config = {
165
+ version: 1,
166
+ credentials: { email },
167
+ defaults: {
168
+ environment: defaultEnvHandle,
169
+ lifetime: '7d',
170
+ },
171
+ environments: configEnvironments,
172
+ tunnel_defaults: {
173
+ start_port: 55550,
174
+ port_increment: 1,
175
+ },
176
+ };
177
+
178
+ // All prompts done — close readline before exiting
179
+ closeRL();
180
+
181
+ save(config);
182
+ savePassword(password);
183
+
184
+ console.log('');
185
+ logger.success(`Config written to ${getConfigPath()}`);
186
+ logger.success('Credentials stored in ' + getConfigDir() + '/.credentials (mode 600)');
187
+ console.log('');
188
+ logger.section('Next steps');
189
+ console.log(' aptunnel status — view all tunnel states');
190
+ console.log(' aptunnel <alias> — open a tunnel');
191
+ console.log(' aptunnel all — open all tunnels');
192
+ console.log(' aptunnel --help — full command reference');
193
+ console.log('');
194
+ }
195
+
196
+ // ─── Alias generation ─────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Suggest a short alias for an environment handle.
200
+ * e.g. "ekare-inc-development-gfpkcova" → "dev"
201
+ */
202
+ function suggestEnvAlias(handle) {
203
+ const words = handle.toLowerCase().split(/[-_]/);
204
+
205
+ const envWords = { development: 'dev', staging: 'staging', production: 'prod', test: 'test', testing: 'test' };
206
+ for (const word of words) {
207
+ if (envWords[word]) return envWords[word];
208
+ }
209
+
210
+ // Use the most informative non-hash word
211
+ const meaningful = words.filter(w => w.length > 2 && !/^[a-z0-9]{6,}$/.test(w) && !['inc', 'com', 'the'].includes(w));
212
+ return meaningful[meaningful.length - 1] ?? words[0];
213
+ }
214
+
215
+ /**
216
+ * Suggest a short alias for a database handle.
217
+ * e.g. "ekaredb-dev" → "dev-db", "my-company-redis" → "redis"
218
+ */
219
+ function suggestDbAlias(handle, allDbs) {
220
+ const lower = handle.toLowerCase();
221
+
222
+ // Detect DB type from handle
223
+ let type = '';
224
+ if (lower.includes('redis')) type = 'redis';
225
+ if (lower.includes('postgres') || lower.includes('pg')) type = 'pg';
226
+ if (lower.includes('mysql')) type = 'mysql';
227
+ if (lower.includes('mongo')) type = 'mongo';
228
+
229
+ // Detect environment part
230
+ let env = '';
231
+ const envWords = { dev: 'dev', development: 'dev', staging: 'stg', prod: 'prod', production: 'prod', test: 'test' };
232
+ for (const [word, alias] of Object.entries(envWords)) {
233
+ if (lower.includes(word)) { env = alias; break; }
234
+ }
235
+
236
+ if (type && env) return `${env}-${type}`;
237
+ if (type) return type;
238
+ if (env) return `${env}-db`;
239
+
240
+ // Fallback: strip common company prefixes, take last meaningful segment
241
+ const parts = lower.split(/[-_]/).filter(p => p.length > 1 && !/^\d+$/.test(p));
242
+ return parts[parts.length - 1] ?? handle;
243
+ }
244
+
245
+ function deduplicateAlias(alias, allDbs) {
246
+ const used = new Set(allDbs.map(d => d.suggestedAlias));
247
+ if (!used.has(alias)) return alias;
248
+ let i = 2;
249
+ while (used.has(`${alias}${i}`)) i++;
250
+ return `${alias}${i}`;
251
+ }
252
+
253
+ // ─── Readline helpers ─────────────────────────────────────────────────────────
254
+ // We use a SINGLE readline interface throughout init and only close it at the
255
+ // very end, right before handing stdin back to the aptible child process.
256
+ // Opening/closing multiple readline interfaces causes process.stdin to be
257
+ // paused between calls, which makes subsequent prompts appear to hang.
258
+
259
+ let _rl = null;
260
+
261
+ function getRL() {
262
+ if (!_rl) {
263
+ _rl = createInterface({ input: process.stdin, output: process.stdout });
264
+ // Prevent readline from keeping the event loop alive indefinitely
265
+ _rl.on('close', () => { _rl = null; });
266
+ }
267
+ return _rl;
268
+ }
269
+
270
+ function closeRL() {
271
+ if (_rl) {
272
+ _rl.close();
273
+ _rl = null;
274
+ }
275
+ // Ensure stdin is resumed so child processes can read from it (e.g. aptible 2FA)
276
+ process.stdin.resume();
277
+ }
278
+
279
+ function ask(prompt) {
280
+ return new Promise((resolve) => {
281
+ getRL().question(prompt, (answer) => resolve(answer));
282
+ });
283
+ }
284
+
285
+ function askSecret(prompt) {
286
+ return new Promise((resolve) => {
287
+ const rl = getRL();
288
+ process.stdout.write(prompt);
289
+ rl._writeToOutput = function(str) {
290
+ if (!this.stdoutMuted) this.output.write(str);
291
+ };
292
+ rl.stdoutMuted = true;
293
+ rl.question('', (answer) => {
294
+ rl.stdoutMuted = false;
295
+ rl._writeToOutput = function(str) { this.output.write(str); };
296
+ process.stdout.write('\n');
297
+ resolve(answer);
298
+ });
299
+ });
300
+ }
@@ -0,0 +1,118 @@
1
+ import { createInterface } from 'readline';
2
+ import chalk from 'chalk';
3
+ import { logger } from '../lib/logger.js';
4
+ import { isInstalled, login, getTokenInfo } from '../lib/aptible.js';
5
+ import { exists, load, savePassword } from '../lib/config-manager.js';
6
+ import { installInstructions } from '../lib/platform.js';
7
+
8
+ export async function runLogin(args) {
9
+ if (!isInstalled()) {
10
+ logger.error('Aptible CLI not found.');
11
+ console.log(installInstructions('aptible'));
12
+ process.exit(1);
13
+ }
14
+
15
+ // --status only
16
+ if (args.includes('--status')) {
17
+ printTokenStatus();
18
+ return;
19
+ }
20
+
21
+ // Collect credentials
22
+ let email = parseFlag(args, '--email');
23
+ let password = parseFlag(args, '--password');
24
+ const lifetime = parseFlag(args, '--lifetime') ?? '7d';
25
+
26
+ // Fall back to saved config
27
+ if (!email || !password) {
28
+ if (exists()) {
29
+ const config = load();
30
+ if (!email) email = config.credentials?.email ?? null;
31
+ if (!password) password = (await import('../lib/config-manager.js')).readPassword();
32
+ }
33
+ }
34
+
35
+ // Interactive prompts for missing values
36
+ if (!email) email = await ask('Aptible email: ');
37
+ if (!password) password = await askSecret('Aptible password: ');
38
+
39
+ // Close readline before handing stdin to aptible (2FA prompt needs raw terminal)
40
+ closeRL();
41
+
42
+ const ok = await login({ email, password, lifetime });
43
+
44
+ if (!ok) {
45
+ logger.error('Login failed.');
46
+ process.exit(1);
47
+ }
48
+
49
+ // Persist updated password if it changed
50
+ if (password) savePassword(password);
51
+
52
+ logger.success('Logged in successfully.');
53
+ printTokenStatus();
54
+ }
55
+
56
+ // ─── Token status display ─────────────────────────────────────────────────────
57
+
58
+ function printTokenStatus() {
59
+ const token = getTokenInfo();
60
+ if (!token) {
61
+ logger.warn('No token found. Run `aptunnel login` to authenticate.');
62
+ return;
63
+ }
64
+
65
+ console.log('');
66
+ logger.detail('User:', chalk.cyan(token.email));
67
+ if (token.issuedAt) logger.detail('Issued:', token.issuedAt.toLocaleString());
68
+ logger.detail('Expires:', token.expiresAt.toLocaleString());
69
+
70
+ if (token.isExpired) {
71
+ logger.detail('Status:', chalk.red('EXPIRED'));
72
+ } else {
73
+ const d = Math.floor(token.remainingHours / 24);
74
+ const h = token.remainingHours % 24;
75
+ const remaining = d > 0 ? `${d}d ${h}h` : `${h}h`;
76
+ logger.detail('Status:', chalk.green(`valid (expires in ${remaining})`));
77
+ }
78
+ console.log('');
79
+ }
80
+
81
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
82
+
83
+ function parseFlag(args, flag) {
84
+ const entry = args.find(a => a.startsWith(`${flag}=`));
85
+ return entry ? entry.slice(flag.length + 1) : null;
86
+ }
87
+
88
+ // Single shared readline — avoids stdin pause issues between consecutive prompts
89
+ let _rl = null;
90
+ function getRL() {
91
+ if (!_rl) _rl = createInterface({ input: process.stdin, output: process.stdout });
92
+ return _rl;
93
+ }
94
+ function closeRL() {
95
+ if (_rl) { _rl.close(); _rl = null; }
96
+ process.stdin.resume();
97
+ }
98
+
99
+ function ask(prompt) {
100
+ return new Promise((resolve) => {
101
+ getRL().question(prompt, (answer) => resolve(answer));
102
+ });
103
+ }
104
+
105
+ function askSecret(prompt) {
106
+ return new Promise((resolve) => {
107
+ const rl = getRL();
108
+ process.stdout.write(prompt);
109
+ rl._writeToOutput = function(str) { if (!this.stdoutMuted) this.output.write(str); };
110
+ rl.stdoutMuted = true;
111
+ rl.question('', (answer) => {
112
+ rl.stdoutMuted = false;
113
+ rl._writeToOutput = function(str) { this.output.write(str); };
114
+ process.stdout.write('\n');
115
+ resolve(answer);
116
+ });
117
+ });
118
+ }
@@ -0,0 +1,99 @@
1
+ import chalk from 'chalk';
2
+ import { logger } from '../lib/logger.js';
3
+ import { exists, getAllDatabases, load } from '../lib/config-manager.js';
4
+ import { isRunning, readPid, readConnectionInfo, toIdentifier } from '../lib/process-manager.js';
5
+ import { getProcessUptime, formatUptime } from '../lib/platform.js';
6
+ import { getTokenInfo } from '../lib/aptible.js';
7
+
8
+ export function runStatus() {
9
+ if (!exists()) {
10
+ logger.warn('No config found. Run `aptunnel init` to set up.');
11
+ return;
12
+ }
13
+
14
+ // ── Login status ──────────────────────────────────────────────────────────
15
+ logger.section('LOGIN STATUS');
16
+ const token = getTokenInfo();
17
+ if (!token) {
18
+ logger.warn(' Token: not found — run `aptunnel login`');
19
+ } else {
20
+ console.log(` User: ${chalk.cyan(token.email)}`);
21
+ if (token.isExpired) {
22
+ console.log(` Token: ${chalk.red('EXPIRED')} — run \`aptunnel login\` to re-authenticate`);
23
+ } else {
24
+ const d = Math.floor(token.remainingHours / 24);
25
+ const h = token.remainingHours % 24;
26
+ const remaining = d > 0 ? `${d}d ${h}h` : `${h}h`;
27
+ console.log(` Token: ${chalk.green('valid')} (expires in ${remaining})`);
28
+ }
29
+ }
30
+
31
+ // ── Tunnel table ──────────────────────────────────────────────────────────
32
+ console.log('');
33
+ logger.section('TUNNELS');
34
+ console.log('');
35
+
36
+ const dbs = getAllDatabases();
37
+ if (dbs.length === 0) {
38
+ logger.dim(' No databases configured.');
39
+ return;
40
+ }
41
+
42
+ // Compute column widths
43
+ const cols = {
44
+ env: Math.max(11, ...dbs.map(d => d.envAlias.length)),
45
+ db: Math.max(8, ...dbs.map(d => d.handle.length)),
46
+ alias: Math.max(5, ...dbs.map(d => d.alias.length)),
47
+ port: 6,
48
+ status: 6,
49
+ uptime: 11,
50
+ pid: 6,
51
+ url: 3,
52
+ };
53
+
54
+ const header = [
55
+ 'ENVIRONMENT'.padEnd(cols.env),
56
+ 'DATABASE'.padEnd(cols.db),
57
+ 'ALIAS'.padEnd(cols.alias),
58
+ 'PORT'.padEnd(cols.port),
59
+ 'STATUS'.padEnd(cols.status),
60
+ 'UPTIME'.padEnd(cols.uptime),
61
+ 'PID'.padEnd(cols.pid),
62
+ 'CONNECTION URL',
63
+ ].join(' ');
64
+
65
+ console.log(chalk.bold(header));
66
+ console.log(chalk.dim('─'.repeat(Math.min(header.length, 120))));
67
+
68
+ for (const db of dbs) {
69
+ const id = toIdentifier(db.alias);
70
+ const running = isRunning(id);
71
+ const pid = running ? readPid(id) : null;
72
+ const uptime = pid ? getProcessUptime(pid) : null;
73
+ const conn = running ? readConnectionInfo(id) : null;
74
+
75
+ const statusLabel = running ? 'UP' : 'DOWN';
76
+ const statusStr = running ? chalk.green('UP') : chalk.dim('DOWN');
77
+ const uptimeStr = running ? formatUptime(uptime) : '-';
78
+ const pidStr = pid ? String(pid) : '-';
79
+ const urlStr = conn?.url ? chalk.dim(conn.url) : '-';
80
+
81
+ // padEnd doesn't account for invisible ANSI escape chars — pad manually
82
+ const statusPad = ' '.repeat(Math.max(0, cols.status - statusLabel.length));
83
+
84
+ const row = [
85
+ db.envAlias.padEnd(cols.env),
86
+ db.handle.padEnd(cols.db),
87
+ db.alias.padEnd(cols.alias),
88
+ String(db.port).padEnd(cols.port),
89
+ statusStr + statusPad,
90
+ uptimeStr.padEnd(cols.uptime),
91
+ pidStr.padEnd(cols.pid),
92
+ urlStr,
93
+ ].join(' ');
94
+
95
+ console.log(row);
96
+ }
97
+
98
+ console.log('');
99
+ }