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