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,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
|
+
}
|