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,216 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import { getConfigPath } from './config-manager.js';
|
|
7
|
+
|
|
8
|
+
const STATIC_CMDS = ['init', 'login', 'status', 'config', 'completions', 'help', 'all'];
|
|
9
|
+
const STATIC_FLAGS = ['--close', '--force', '--help', '--version', '--port=', '--env='];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate bash completion script.
|
|
13
|
+
* Reads aliases at runtime from config.yaml using awk (no YAML parser needed in bash).
|
|
14
|
+
*/
|
|
15
|
+
export function bashScript() {
|
|
16
|
+
const configPath = getConfigPath();
|
|
17
|
+
return `#!/usr/bin/env bash
|
|
18
|
+
# aptunnel bash completion
|
|
19
|
+
# Source this file or add to ~/.bashrc:
|
|
20
|
+
# source <(aptunnel completions bash)
|
|
21
|
+
|
|
22
|
+
_aptunnel_completions() {
|
|
23
|
+
local cur prev
|
|
24
|
+
COMPREPLY=()
|
|
25
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
26
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
27
|
+
|
|
28
|
+
# Get aliases from config at runtime
|
|
29
|
+
local config="${configPath}"
|
|
30
|
+
local db_aliases=""
|
|
31
|
+
local env_aliases=""
|
|
32
|
+
if [ -f "$config" ]; then
|
|
33
|
+
db_aliases=$(awk '/^ [a-z]/{if(alias) print alias} /alias:/{alias=$2}' "$config" 2>/dev/null | sort -u)
|
|
34
|
+
env_aliases=$(awk '/^[a-z].*:$/{env=substr($0,1,length($0)-1)} / alias:/{print $2}' "$config" 2>/dev/null | sort -u)
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
local all_cmds="${STATIC_CMDS.join(' ')} $db_aliases"
|
|
38
|
+
|
|
39
|
+
case "$prev" in
|
|
40
|
+
--env)
|
|
41
|
+
COMPREPLY=( $(compgen -W "$env_aliases" -- "$cur") )
|
|
42
|
+
return 0
|
|
43
|
+
;;
|
|
44
|
+
--port)
|
|
45
|
+
# No completion for port numbers
|
|
46
|
+
return 0
|
|
47
|
+
;;
|
|
48
|
+
esac
|
|
49
|
+
|
|
50
|
+
if [[ "$cur" == --* ]]; then
|
|
51
|
+
COMPREPLY=( $(compgen -W "${STATIC_FLAGS.join(' ')}" -- "$cur") )
|
|
52
|
+
return 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
COMPREPLY=( $(compgen -W "$all_cmds" -- "$cur") )
|
|
56
|
+
return 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
complete -F _aptunnel_completions aptunnel
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate zsh completion script.
|
|
65
|
+
*/
|
|
66
|
+
export function zshScript() {
|
|
67
|
+
const configPath = getConfigPath();
|
|
68
|
+
return `#compdef aptunnel
|
|
69
|
+
# aptunnel zsh completion
|
|
70
|
+
# Add to ~/.zshrc:
|
|
71
|
+
# source <(aptunnel completions zsh)
|
|
72
|
+
# or copy to a directory in your $fpath.
|
|
73
|
+
|
|
74
|
+
_aptunnel() {
|
|
75
|
+
local config="${configPath}"
|
|
76
|
+
local -a db_aliases env_aliases
|
|
77
|
+
|
|
78
|
+
if [[ -f "$config" ]]; then
|
|
79
|
+
db_aliases=( \${(f)"$(awk '/alias:/{print $2}' "$config" 2>/dev/null | sort -u)"} )
|
|
80
|
+
env_aliases=( \${(f)"$(awk '/^ alias:/{print $2}' "$config" 2>/dev/null | sort -u)"} )
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
local -a cmds
|
|
84
|
+
cmds=(
|
|
85
|
+
'init:Setup wizard'
|
|
86
|
+
'login:Login to Aptible or show token status'
|
|
87
|
+
'status:Show all tunnel statuses'
|
|
88
|
+
'config:View or modify configuration'
|
|
89
|
+
'completions:Print shell completion script'
|
|
90
|
+
'all:Open all tunnels for an environment'
|
|
91
|
+
'help:Show help'
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Add db aliases as commands
|
|
95
|
+
for alias in $db_aliases; do
|
|
96
|
+
cmds+=("$alias:Open tunnel to $alias")
|
|
97
|
+
done
|
|
98
|
+
|
|
99
|
+
_arguments -C \\
|
|
100
|
+
'(-h --help)'{-h,--help}'[Show help]' \\
|
|
101
|
+
'(-v --version)'{-v,--version}'[Show version]' \\
|
|
102
|
+
'--close[Close tunnel(s)]' \\
|
|
103
|
+
'--force[Kill existing process on port conflict]' \\
|
|
104
|
+
'--port=[Override port]:port:' \\
|
|
105
|
+
"--env=[Target environment]:env:($env_aliases)" \\
|
|
106
|
+
'1: :->cmd' \\
|
|
107
|
+
'*:: :->args'
|
|
108
|
+
|
|
109
|
+
case $state in
|
|
110
|
+
cmd)
|
|
111
|
+
_describe 'command' cmds
|
|
112
|
+
;;
|
|
113
|
+
esac
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_aptunnel "$@"
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Generate fish completion script.
|
|
122
|
+
*/
|
|
123
|
+
export function fishScript() {
|
|
124
|
+
const configPath = getConfigPath();
|
|
125
|
+
return `# aptunnel fish completion
|
|
126
|
+
# Copy to ~/.config/fish/completions/aptunnel.fish
|
|
127
|
+
# or run: aptunnel completions install
|
|
128
|
+
|
|
129
|
+
set -l config_path "${configPath}"
|
|
130
|
+
|
|
131
|
+
# Helper: extract aliases from config
|
|
132
|
+
function __aptunnel_db_aliases
|
|
133
|
+
if test -f $config_path
|
|
134
|
+
awk '/alias:/{print $2}' $config_path 2>/dev/null | sort -u
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
function __aptunnel_env_aliases
|
|
139
|
+
if test -f $config_path
|
|
140
|
+
awk '/^ alias:/{print $2}' $config_path 2>/dev/null | sort -u
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Main commands
|
|
145
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a 'init' -d 'Setup wizard'
|
|
146
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a 'login' -d 'Login to Aptible'
|
|
147
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a 'status' -d 'Show tunnel statuses'
|
|
148
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a 'config' -d 'View/modify configuration'
|
|
149
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a 'completions' -d 'Print completion script'
|
|
150
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a 'all' -d 'Open all tunnels'
|
|
151
|
+
|
|
152
|
+
# Dynamic db aliases
|
|
153
|
+
complete -c aptunnel -f -n '__fish_use_subcommand' -a '(__aptunnel_db_aliases)'
|
|
154
|
+
|
|
155
|
+
# Flags
|
|
156
|
+
complete -c aptunnel -l close -d 'Close tunnel(s)'
|
|
157
|
+
complete -c aptunnel -l force -d 'Kill existing process on port conflict'
|
|
158
|
+
complete -c aptunnel -l port -d 'Override port' -r
|
|
159
|
+
complete -c aptunnel -l env -d 'Target environment' -r -a '(__aptunnel_env_aliases)'
|
|
160
|
+
complete -c aptunnel -s h -l help -d 'Show help'
|
|
161
|
+
complete -c aptunnel -s v -l version -d 'Show version'
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Auto-detect shell and install the completion script.
|
|
167
|
+
*/
|
|
168
|
+
export function installCompletions() {
|
|
169
|
+
const shell = detectShell();
|
|
170
|
+
|
|
171
|
+
if (shell === 'bash') {
|
|
172
|
+
const rcFile = join(homedir(), '.bashrc');
|
|
173
|
+
const line = '\n# aptunnel completions\nsource <(aptunnel completions bash)\n';
|
|
174
|
+
if (existsSync(rcFile) && readFileSync(rcFile, 'utf8').includes('aptunnel completions')) {
|
|
175
|
+
logger.info('Bash completions already installed in ~/.bashrc.');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
writeFileSync(rcFile, readFileSync(rcFile, 'utf8') + line);
|
|
179
|
+
logger.success('Bash completions installed. Restart your shell or run: source ~/.bashrc');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (shell === 'zsh') {
|
|
184
|
+
const rcFile = join(homedir(), '.zshrc');
|
|
185
|
+
const line = '\n# aptunnel completions\nsource <(aptunnel completions zsh)\n';
|
|
186
|
+
if (existsSync(rcFile) && readFileSync(rcFile, 'utf8').includes('aptunnel completions')) {
|
|
187
|
+
logger.info('Zsh completions already installed in ~/.zshrc.');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
writeFileSync(rcFile, readFileSync(rcFile, 'utf8') + line);
|
|
191
|
+
logger.success('Zsh completions installed. Restart your shell or run: source ~/.zshrc');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (shell === 'fish') {
|
|
196
|
+
const fishDir = join(homedir(), '.config', 'fish', 'completions');
|
|
197
|
+
const fishFile = join(fishDir, 'aptunnel.fish');
|
|
198
|
+
mkdirSync(fishDir, { recursive: true });
|
|
199
|
+
writeFileSync(fishFile, fishScript());
|
|
200
|
+
logger.success(`Fish completions installed to ${fishFile}. Restart fish to activate.`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
logger.warn(`Could not detect shell (SHELL=${process.env.SHELL ?? 'unset'}). Use one of:`);
|
|
205
|
+
logger.plain(' aptunnel completions bash — bash');
|
|
206
|
+
logger.plain(' aptunnel completions zsh — zsh');
|
|
207
|
+
logger.plain(' aptunnel completions fish — fish');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function detectShell() {
|
|
211
|
+
const shell = process.env.SHELL ?? '';
|
|
212
|
+
if (shell.includes('zsh')) return 'zsh';
|
|
213
|
+
if (shell.includes('bash')) return 'bash';
|
|
214
|
+
if (shell.includes('fish')) return 'fish';
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
|
|
2
|
+
import { homedir, platform } from 'os';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
// Allow tests to redirect config to a temp directory via APTUNNEL_CONFIG_HOME
|
|
8
|
+
function getConfigHome() {
|
|
9
|
+
return process.env.APTUNNEL_CONFIG_HOME ?? join(homedir(), '.aptunnel');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export function getConfigDir() { return getConfigHome(); }
|
|
15
|
+
export function getConfigPath() { return join(getConfigHome(), 'config.yaml'); }
|
|
16
|
+
export function getCredsPath() { return join(getConfigHome(), '.credentials'); }
|
|
17
|
+
|
|
18
|
+
export function exists() {
|
|
19
|
+
return existsSync(getConfigPath());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Load / Save ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load and parse ~/.aptunnel/config.yaml
|
|
26
|
+
* @returns {object} parsed config
|
|
27
|
+
* @throws if file is missing or unparseable
|
|
28
|
+
*/
|
|
29
|
+
export function load() {
|
|
30
|
+
if (!existsSync(getConfigPath())) {
|
|
31
|
+
throw new Error(`Config not found. Run \`aptunnel init\` to set up.`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(getConfigPath(), 'utf8');
|
|
35
|
+
const config = yaml.load(raw);
|
|
36
|
+
if (!config || typeof config !== 'object') {
|
|
37
|
+
throw new Error('Config file is empty or invalid.');
|
|
38
|
+
}
|
|
39
|
+
return config;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e.message.startsWith('Config')) throw e;
|
|
42
|
+
throw new Error(`Config file is corrupted: ${e.message}. Run \`aptunnel init\` to reinitialize.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write config object back to ~/.aptunnel/config.yaml
|
|
48
|
+
* @param {object} config
|
|
49
|
+
*/
|
|
50
|
+
export function save(config) {
|
|
51
|
+
ensureConfigDir();
|
|
52
|
+
const raw = yaml.dump(config, { lineWidth: 120, noRefs: true });
|
|
53
|
+
writeFileSync(getConfigPath(), raw, { mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Credentials ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read password from ~/.aptunnel/.credentials
|
|
60
|
+
* @returns {string | null}
|
|
61
|
+
*/
|
|
62
|
+
export function readPassword() {
|
|
63
|
+
if (!existsSync(getCredsPath())) return null;
|
|
64
|
+
try {
|
|
65
|
+
const content = readFileSync(getCredsPath(), 'utf8');
|
|
66
|
+
const match = content.match(/^APTUNNEL_PASSWORD=(.+)$/m);
|
|
67
|
+
return match?.[1]?.trim() ?? null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Save password to ~/.aptunnel/.credentials with restricted permissions.
|
|
75
|
+
* @param {string} password
|
|
76
|
+
*/
|
|
77
|
+
export function savePassword(password) {
|
|
78
|
+
ensureConfigDir();
|
|
79
|
+
const credsPath = getCredsPath();
|
|
80
|
+
writeFileSync(credsPath, `APTUNNEL_PASSWORD=${password}\n`, { mode: 0o600 });
|
|
81
|
+
|
|
82
|
+
if (platform() === 'win32') {
|
|
83
|
+
// On Windows chmod doesn't restrict access — use icacls
|
|
84
|
+
try {
|
|
85
|
+
const username = process.env.USERNAME ?? process.env.USER ?? '';
|
|
86
|
+
if (username) {
|
|
87
|
+
spawnSync('icacls', [credsPath, '/inheritance:r', '/grant:r', `${username}:(R,W)`], { encoding: 'utf8' });
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// icacls unavailable — warn via caller
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Environment helpers ───────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve an environment alias or handle to its full handle.
|
|
99
|
+
* @param {string} aliasOrHandle
|
|
100
|
+
* @returns {string | null}
|
|
101
|
+
*/
|
|
102
|
+
export function getEnvironment(aliasOrHandle) {
|
|
103
|
+
const config = load();
|
|
104
|
+
const envs = config.environments ?? {};
|
|
105
|
+
|
|
106
|
+
// Direct match by handle
|
|
107
|
+
if (envs[aliasOrHandle]) return aliasOrHandle;
|
|
108
|
+
|
|
109
|
+
// Match by alias
|
|
110
|
+
for (const [handle, env] of Object.entries(envs)) {
|
|
111
|
+
if (env.alias === aliasOrHandle) return handle;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a database alias or handle across all environments.
|
|
119
|
+
* @param {string} aliasOrHandle
|
|
120
|
+
* @returns {{ handle: string, environment: string, port: number, type: string, alias: string } | null}
|
|
121
|
+
*/
|
|
122
|
+
export function getDatabase(aliasOrHandle) {
|
|
123
|
+
const config = load();
|
|
124
|
+
|
|
125
|
+
for (const [envHandle, env] of Object.entries(config.environments ?? {})) {
|
|
126
|
+
const dbs = env.databases ?? {};
|
|
127
|
+
|
|
128
|
+
for (const [dbHandle, db] of Object.entries(dbs)) {
|
|
129
|
+
if (dbHandle === aliasOrHandle || db.alias === aliasOrHandle) {
|
|
130
|
+
return {
|
|
131
|
+
handle: dbHandle,
|
|
132
|
+
environment: envHandle,
|
|
133
|
+
port: db.port,
|
|
134
|
+
type: db.type ?? 'unknown',
|
|
135
|
+
alias: db.alias ?? dbHandle,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return the default environment handle.
|
|
146
|
+
* @returns {string | null}
|
|
147
|
+
*/
|
|
148
|
+
export function getDefaultEnv() {
|
|
149
|
+
const config = load();
|
|
150
|
+
return config.defaults?.environment ?? null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Update the port for a database.
|
|
155
|
+
* @param {string} aliasOrHandle
|
|
156
|
+
* @param {number} port
|
|
157
|
+
*/
|
|
158
|
+
export function setPort(aliasOrHandle, port) {
|
|
159
|
+
const config = load();
|
|
160
|
+
|
|
161
|
+
for (const [, env] of Object.entries(config.environments ?? {})) {
|
|
162
|
+
const dbs = env.databases ?? {};
|
|
163
|
+
for (const [dbHandle, db] of Object.entries(dbs)) {
|
|
164
|
+
if (dbHandle === aliasOrHandle || db.alias === aliasOrHandle) {
|
|
165
|
+
db.port = port;
|
|
166
|
+
save(config);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new Error(`Database not found: ${aliasOrHandle}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Return all databases for an environment (resolved by alias or handle).
|
|
177
|
+
* @param {string} envAliasOrHandle
|
|
178
|
+
* @returns {{ handle: string, alias: string, port: number, type: string }[]}
|
|
179
|
+
*/
|
|
180
|
+
export function getAllTunnelTargets(envAliasOrHandle) {
|
|
181
|
+
const config = load();
|
|
182
|
+
const envHandle = getEnvironment(envAliasOrHandle) ?? envAliasOrHandle;
|
|
183
|
+
const env = config.environments?.[envHandle];
|
|
184
|
+
|
|
185
|
+
if (!env) return [];
|
|
186
|
+
|
|
187
|
+
return Object.entries(env.databases ?? {}).map(([handle, db]) => ({
|
|
188
|
+
handle,
|
|
189
|
+
alias: db.alias ?? handle,
|
|
190
|
+
port: db.port,
|
|
191
|
+
type: db.type ?? 'unknown',
|
|
192
|
+
environment: envHandle,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Return every database across all environments.
|
|
198
|
+
* @returns {{ handle: string, alias: string, port: number, type: string, environment: string, envAlias: string }[]}
|
|
199
|
+
*/
|
|
200
|
+
export function getAllDatabases() {
|
|
201
|
+
const config = load();
|
|
202
|
+
const result = [];
|
|
203
|
+
|
|
204
|
+
for (const [envHandle, env] of Object.entries(config.environments ?? {})) {
|
|
205
|
+
for (const [dbHandle, db] of Object.entries(env.databases ?? {})) {
|
|
206
|
+
result.push({
|
|
207
|
+
handle: dbHandle,
|
|
208
|
+
alias: db.alias ?? dbHandle,
|
|
209
|
+
port: db.port,
|
|
210
|
+
type: db.type ?? 'unknown',
|
|
211
|
+
environment: envHandle,
|
|
212
|
+
envAlias: env.alias ?? envHandle,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Find the next free port starting from tunnel_defaults.start_port.
|
|
222
|
+
* @returns {number}
|
|
223
|
+
*/
|
|
224
|
+
export function nextAvailablePort() {
|
|
225
|
+
const config = load();
|
|
226
|
+
const start = config.tunnel_defaults?.start_port ?? 55550;
|
|
227
|
+
|
|
228
|
+
const usedPorts = new Set();
|
|
229
|
+
for (const env of Object.values(config.environments ?? {})) {
|
|
230
|
+
for (const db of Object.values(env.databases ?? {})) {
|
|
231
|
+
if (db.port) usedPorts.add(db.port);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let port = start;
|
|
236
|
+
while (usedPorts.has(port)) port++;
|
|
237
|
+
return port;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function ensureConfigDir() {
|
|
243
|
+
const dir = getConfigHome();
|
|
244
|
+
if (!existsSync(dir)) {
|
|
245
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export const logger = {
|
|
4
|
+
success(msg) { console.log(chalk.green('✔ ') + msg); },
|
|
5
|
+
error(msg) { console.error(chalk.red('✖ ') + msg); },
|
|
6
|
+
warn(msg) { console.warn(chalk.yellow('⚠ ') + msg); },
|
|
7
|
+
info(msg) { console.log(chalk.cyan('ℹ ') + msg); },
|
|
8
|
+
dim(msg) { console.log(chalk.dim(msg)); },
|
|
9
|
+
plain(msg) { console.log(msg); },
|
|
10
|
+
|
|
11
|
+
// Print a key/value pair indented under a success/info block
|
|
12
|
+
detail(key, value) {
|
|
13
|
+
const k = chalk.dim(key.padEnd(10));
|
|
14
|
+
console.log(` ${k} ${value}`);
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
// Print a section header
|
|
18
|
+
section(title) {
|
|
19
|
+
console.log('\n' + chalk.bold.underline(title));
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
// Print a horizontal rule
|
|
23
|
+
rule() {
|
|
24
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
25
|
+
},
|
|
26
|
+
};
|