@zeroexcore/tuna 0.1.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,133 @@
1
+ /**
2
+ * Login command - setup Cloudflare credentials
3
+ */
4
+
5
+ import inquirer from 'inquirer';
6
+ import chalk from 'chalk';
7
+ import ora from 'ora';
8
+ import { storeCredentials } from '../lib/credentials.ts';
9
+ import { CloudflareAPI } from '../lib/api.ts';
10
+ import type { Credentials } from '../types/index.ts';
11
+
12
+ /**
13
+ * Interactive login flow to setup Cloudflare credentials
14
+ */
15
+ export async function loginCommand(): Promise<void> {
16
+ console.log(chalk.blue('\nšŸ” Tuna Login\n'));
17
+ console.log(chalk.dim('Store your Cloudflare credentials securely in macOS Keychain.\n'));
18
+
19
+ // Prompt for API token
20
+ console.log(chalk.dim('Create a token at: https://dash.cloudflare.com/profile/api-tokens'));
21
+ console.log(chalk.dim('Required permissions:'));
22
+ console.log(chalk.dim(' • Account → Cloudflare Tunnel → Edit'));
23
+ console.log(chalk.dim(' • Account → Access: Apps and Policies → Edit'));
24
+ console.log(chalk.dim(' • Zone → DNS → Edit'));
25
+ console.log(chalk.dim(' • Account → Account Settings → Read\n'));
26
+
27
+ const { apiToken } = await inquirer.prompt([
28
+ {
29
+ type: 'password',
30
+ name: 'apiToken',
31
+ message: 'Enter your Cloudflare API token:',
32
+ mask: '*',
33
+ validate: (input: string) => {
34
+ if (!input || input.trim().length === 0) {
35
+ return 'API token is required';
36
+ }
37
+ return true;
38
+ },
39
+ },
40
+ ]);
41
+
42
+ // Validate token and get account ID
43
+ const spinner = ora('Validating token...').start();
44
+
45
+ let accountId: string;
46
+ try {
47
+ // Create a temporary API instance to validate
48
+ const tempApi = new CloudflareAPI({
49
+ apiToken: apiToken.trim(),
50
+ accountId: '', // Will be fetched
51
+ domain: '',
52
+ });
53
+
54
+ accountId = await tempApi.validateToken();
55
+ spinner.succeed('Token validated');
56
+ } catch (error) {
57
+ spinner.fail('Invalid token');
58
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
59
+ process.exit(1);
60
+ }
61
+
62
+ // Prompt for domain
63
+ const { domain } = await inquirer.prompt([
64
+ {
65
+ type: 'input',
66
+ name: 'domain',
67
+ message: 'Enter your root domain (e.g., example.com):',
68
+ validate: (input: string) => {
69
+ if (!input || input.trim().length === 0) {
70
+ return 'Domain is required';
71
+ }
72
+ // Basic domain validation
73
+ const domainRegex = /^[a-z0-9][a-z0-9-]*\.[a-z]{2,}$/i;
74
+ if (!domainRegex.test(input.trim())) {
75
+ return 'Invalid domain format. Enter just the root domain (e.g., example.com)';
76
+ }
77
+ return true;
78
+ },
79
+ },
80
+ ]);
81
+
82
+ // Verify domain access
83
+ const domainSpinner = ora('Verifying domain access...').start();
84
+
85
+ try {
86
+ const api = new CloudflareAPI({
87
+ apiToken: apiToken.trim(),
88
+ accountId,
89
+ domain: domain.trim(),
90
+ });
91
+
92
+ await api.getZoneByName(domain.trim());
93
+ domainSpinner.succeed('Domain verified');
94
+ } catch (error) {
95
+ domainSpinner.fail('Domain verification failed');
96
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
97
+ console.log(chalk.yellow('\nMake sure:'));
98
+ console.log(chalk.yellow(' 1. The domain is added to your Cloudflare account'));
99
+ console.log(chalk.yellow(' 2. Your API token has access to this domain'));
100
+ process.exit(1);
101
+ }
102
+
103
+ // Store credentials
104
+ const saveSpinner = ora('Saving credentials...').start();
105
+
106
+ try {
107
+ const credentials: Credentials = {
108
+ apiToken: apiToken.trim(),
109
+ accountId,
110
+ domain: domain.trim(),
111
+ };
112
+
113
+ await storeCredentials(domain.trim(), credentials);
114
+ saveSpinner.succeed('Credentials saved to macOS Keychain');
115
+ } catch (error) {
116
+ saveSpinner.fail('Failed to save credentials');
117
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
118
+ process.exit(1);
119
+ }
120
+
121
+ console.log(chalk.green('\nāœ“ Login successful!\n'));
122
+ console.log(chalk.dim('You can now use tuna to create tunnels for this domain.'));
123
+ console.log(chalk.dim('Example:\n'));
124
+ console.log(chalk.dim(' Add to your package.json:'));
125
+ console.log(chalk.cyan(' {'));
126
+ console.log(chalk.cyan(' "tuna": {'));
127
+ console.log(chalk.cyan(` "forward": "my-app.${domain.trim()}",`));
128
+ console.log(chalk.cyan(' "port": 3000'));
129
+ console.log(chalk.cyan(' }'));
130
+ console.log(chalk.cyan(' }\n'));
131
+ console.log(chalk.dim(' Then run:'));
132
+ console.log(chalk.cyan(' tuna npm run dev\n'));
133
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Run command - main wrapper that sets up tunnel and runs the child command
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import { execa, type ExecaError } from 'execa';
8
+ import { randomBytes } from 'crypto';
9
+ import { readConfig, generateTunnelName } from '../lib/config.ts';
10
+ import { getCredentials, getRootDomain } from '../lib/credentials.ts';
11
+ import { CloudflareAPI } from '../lib/api.ts';
12
+ import {
13
+ isInstalled,
14
+ download,
15
+ ensureDirectories,
16
+ } from '../lib/cloudflared.ts';
17
+ import {
18
+ generateIngressConfig,
19
+ writeIngressConfig,
20
+ saveTunnelCredentials,
21
+ tunnelCredentialsExist,
22
+ installService,
23
+ isServiceInstalled,
24
+ restartService,
25
+ } from '../lib/service.ts';
26
+ import { ensureDnsRecord } from '../lib/dns.ts';
27
+ import { ensureAccess, getAccessDescription } from '../lib/access.ts';
28
+ import type { Tunnel, AccessConfig } from '../types/index.ts';
29
+
30
+ /**
31
+ * Main run command - sets up tunnel and executes wrapped command
32
+ */
33
+ export async function runCommand(args: string[]): Promise<void> {
34
+ // Read config from package.json
35
+ let config: { forward: string; port: number; access?: AccessConfig };
36
+ try {
37
+ config = await readConfig();
38
+ } catch (error) {
39
+ console.error(chalk.red(`Error: ${(error as Error).message}`));
40
+ process.exit(1);
41
+ }
42
+
43
+ const { forward, port, access } = config;
44
+ const rootDomain = getRootDomain(forward);
45
+ const tunnelName = generateTunnelName(forward);
46
+
47
+ // Get credentials
48
+ const spinner = ora('Authenticating...').start();
49
+
50
+ const credentials = await getCredentials(rootDomain);
51
+ if (!credentials) {
52
+ spinner.fail('Not logged in');
53
+ console.error(chalk.red(`\nNo credentials found for ${rootDomain}`));
54
+ console.log(chalk.yellow('Run: tuna --login'));
55
+ process.exit(1);
56
+ }
57
+
58
+ spinner.text = 'Checking cloudflared...';
59
+
60
+ // Ensure cloudflared is installed
61
+ if (!isInstalled()) {
62
+ spinner.text = 'Downloading cloudflared...';
63
+ try {
64
+ await download((percent) => {
65
+ spinner.text = `Downloading cloudflared... ${percent}%`;
66
+ });
67
+ spinner.succeed('cloudflared downloaded');
68
+ spinner.start('Setting up tunnel...');
69
+ } catch (error) {
70
+ spinner.fail('Failed to download cloudflared');
71
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
72
+ console.log(chalk.yellow('\nYou can install manually:'));
73
+ console.log(chalk.yellow(' brew install cloudflared'));
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ ensureDirectories();
79
+
80
+ // Initialize API client
81
+ const api = new CloudflareAPI(credentials);
82
+
83
+ // Check if tunnel exists
84
+ spinner.text = 'Checking tunnel...';
85
+ let tunnel: Tunnel | undefined;
86
+
87
+ try {
88
+ const tunnels = await api.listTunnels();
89
+ tunnel = tunnels.find((t) => t.name === tunnelName && !t.deleted_at);
90
+ } catch (error) {
91
+ spinner.fail('Failed to check tunnels');
92
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
93
+ process.exit(1);
94
+ }
95
+
96
+ // Create tunnel if it doesn't exist
97
+ if (!tunnel) {
98
+ spinner.text = 'Creating tunnel...';
99
+ try {
100
+ // Generate tunnel secret (32 bytes, base64 encoded)
101
+ const tunnelSecret = randomBytes(32).toString('base64');
102
+
103
+ tunnel = await api.createTunnel(tunnelName, tunnelSecret);
104
+
105
+ // Save tunnel credentials
106
+ saveTunnelCredentials(tunnel.id, credentials.accountId, tunnelSecret);
107
+
108
+ spinner.succeed(`Tunnel created: ${tunnelName}`);
109
+ spinner.start('Setting up DNS...');
110
+ } catch (error) {
111
+ spinner.fail('Failed to create tunnel');
112
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
113
+ process.exit(1);
114
+ }
115
+ } else {
116
+ // Check if we have local credentials
117
+ if (!tunnelCredentialsExist(tunnel.id)) {
118
+ spinner.fail('Tunnel exists but local credentials missing');
119
+ console.error(chalk.red('\nThe tunnel exists on Cloudflare but local credentials are missing.'));
120
+ console.log(chalk.yellow('Options:'));
121
+ console.log(chalk.yellow(' 1. Delete the tunnel and let tuna recreate it:'));
122
+ console.log(chalk.cyan(` tuna --delete ${tunnelName}`));
123
+ console.log(chalk.yellow(' 2. Manually recreate the credentials file'));
124
+ process.exit(1);
125
+ }
126
+ spinner.succeed(`Using existing tunnel: ${tunnelName}`);
127
+ spinner.start('Setting up DNS...');
128
+ }
129
+
130
+ // Ensure DNS record
131
+ try {
132
+ await ensureDnsRecord(credentials, forward, tunnel.id);
133
+ spinner.succeed('DNS configured');
134
+ spinner.start('Starting service...');
135
+ } catch (error) {
136
+ spinner.fail('Failed to configure DNS');
137
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
138
+ process.exit(1);
139
+ }
140
+
141
+ // Generate and write ingress config
142
+ const ingressConfig = generateIngressConfig(tunnel.id, forward, port);
143
+ writeIngressConfig(tunnel.id, ingressConfig);
144
+
145
+ // Install or update service - always restart to pick up config changes
146
+ try {
147
+ if (!isServiceInstalled()) {
148
+ await installService(tunnel.id);
149
+ spinner.succeed('Service installed');
150
+ } else {
151
+ // Always restart to ensure config changes (port, hostname) are picked up
152
+ await restartService();
153
+ spinner.succeed('Service restarted');
154
+ }
155
+ } catch (error) {
156
+ spinner.fail('Failed to start service');
157
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
158
+ process.exit(1);
159
+ }
160
+
161
+ // Setup Zero Trust Access if configured
162
+ let accessConfigured = false;
163
+ if (access && access.length > 0) {
164
+ spinner.start('Configuring access control...');
165
+ try {
166
+ await ensureAccess(credentials, forward, access);
167
+ accessConfigured = true;
168
+ spinner.succeed('Access control configured');
169
+ } catch (error) {
170
+ spinner.fail('Failed to configure access control');
171
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
172
+ console.log(chalk.yellow('\nTunnel is active but without access control.'));
173
+ console.log(chalk.yellow('Your API token may need additional permissions:'));
174
+ console.log(chalk.yellow(' - Account → Access: Apps and Policies → Edit'));
175
+ }
176
+ } else {
177
+ // No access config - remove any existing Access app
178
+ try {
179
+ await ensureAccess(credentials, forward, undefined);
180
+ } catch {
181
+ // Ignore errors when removing - may not have permissions or app doesn't exist
182
+ }
183
+ }
184
+
185
+ // Display tunnel info
186
+ console.log('');
187
+ console.log(chalk.green('āœ“ Tunnel active'));
188
+ console.log(chalk.cyan(` https://${forward}`), chalk.dim(`→ localhost:${port}`));
189
+ if (accessConfigured && access) {
190
+ console.log(chalk.dim(` Access: ${getAccessDescription(access)}`));
191
+ }
192
+ console.log('');
193
+
194
+ // Execute wrapped command
195
+ if (args.length === 0) {
196
+ // No command to run, just setup tunnel
197
+ console.log(chalk.dim('Tunnel is running. Press Ctrl+C to exit.'));
198
+
199
+ // Keep process alive
200
+ await new Promise(() => {}); // Never resolves
201
+ return;
202
+ }
203
+
204
+ const [command, ...commandArgs] = args;
205
+
206
+ console.log(chalk.dim(`Running: ${command} ${commandArgs.join(' ')}\n`));
207
+
208
+ try {
209
+ // Spawn child process with stdio inheritance
210
+ const childProcess = execa(command, commandArgs, {
211
+ stdio: 'inherit',
212
+ env: {
213
+ ...process.env,
214
+ TUNA_TUNNEL_URL: `https://${forward}`,
215
+ TUNA_TUNNEL_ID: tunnel.id,
216
+ },
217
+ });
218
+
219
+ // Forward signals to child
220
+ const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
221
+ for (const signal of signals) {
222
+ process.on(signal, () => {
223
+ childProcess.kill(signal);
224
+ });
225
+ }
226
+
227
+ const result = await childProcess;
228
+ process.exit(result.exitCode ?? 0);
229
+ } catch (error) {
230
+ const execaError = error as ExecaError;
231
+
232
+ // If command not found
233
+ if (execaError.code === 'ENOENT') {
234
+ console.error(chalk.red(`\nCommand not found: ${command}`));
235
+ process.exit(127);
236
+ }
237
+
238
+ // Exit with child's exit code
239
+ process.exit(execaError.exitCode ?? 1);
240
+ }
241
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Stop command - stop the cloudflared service
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import { stopService, getServiceStatus, isServiceInstalled } from '../lib/service.ts';
8
+
9
+ /**
10
+ * Stop the cloudflared service
11
+ */
12
+ export async function stopCommand(): Promise<void> {
13
+ console.log('');
14
+
15
+ // Check if service is installed
16
+ if (!isServiceInstalled()) {
17
+ console.log(chalk.yellow('Cloudflared service is not installed.'));
18
+ console.log(chalk.dim('Run tuna with a command to set up a tunnel first.\n'));
19
+ return;
20
+ }
21
+
22
+ // Check current status
23
+ const status = await getServiceStatus();
24
+
25
+ if (!status.running) {
26
+ console.log(chalk.yellow('Cloudflared service is not running.\n'));
27
+ return;
28
+ }
29
+
30
+ // Stop the service
31
+ const spinner = ora('Stopping cloudflared service...').start();
32
+
33
+ try {
34
+ await stopService();
35
+ spinner.succeed('Cloudflared service stopped');
36
+ console.log('');
37
+ console.log(chalk.dim('Your tunnels are no longer active.'));
38
+ console.log(chalk.dim('Run tuna with a command to start them again.\n'));
39
+ } catch (error) {
40
+ spinner.fail('Failed to stop service');
41
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
42
+ process.exit(1);
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Tuna - Cloudflare Tunnel Wrapper for Development Servers
4
+ * CLI entry point
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+ import { loginCommand } from './commands/login.ts';
9
+ import { initCommand } from './commands/init.ts';
10
+ import { runCommand } from './commands/run.ts';
11
+ import { listCommand } from './commands/list.ts';
12
+ import { stopCommand } from './commands/stop.ts';
13
+ import { deleteCommand } from './commands/delete.ts';
14
+
15
+ const version = '0.1.0';
16
+
17
+ function showHelp(): void {
18
+ console.log(`
19
+ ${chalk.bold('tuna')} - Cloudflare Tunnel Wrapper for Development Servers
20
+
21
+ ${chalk.dim('Usage:')}
22
+ tuna [options]
23
+ tuna <command> [args...]
24
+
25
+ ${chalk.dim('Options:')}
26
+ --init Interactive project setup
27
+ --login Setup Cloudflare credentials
28
+ --list List all tunnels
29
+ --stop Stop cloudflared service
30
+ --delete [name] Delete tunnel (from config or by name)
31
+ --version, -V Show version
32
+ --help, -h Show this help
33
+
34
+ ${chalk.dim('Examples:')}
35
+ $ tuna --init Interactive project setup
36
+ $ tuna --login Setup Cloudflare credentials
37
+ $ tuna npm run dev Run dev server with tunnel
38
+ $ tuna vite dev --port 3000 Run vite with tunnel
39
+ $ tuna --list List all tunnels
40
+ $ tuna --stop Stop cloudflared service
41
+ $ tuna --delete Delete tunnel from package.json
42
+ $ tuna --delete my-tunnel Delete specific tunnel
43
+
44
+ ${chalk.dim('Configuration:')}
45
+ Add to your package.json:
46
+ {
47
+ "tuna": {
48
+ "forward": "my-app.example.com",
49
+ "port": 3000
50
+ }
51
+ }
52
+
53
+ For team collaboration (unique subdomains per user):
54
+ {
55
+ "tuna": {
56
+ "forward": "$USER-api.example.com",
57
+ "port": 3000
58
+ }
59
+ }
60
+ `);
61
+ }
62
+
63
+ async function main(): Promise<void> {
64
+ const args = process.argv.slice(2);
65
+
66
+ // No arguments - show help
67
+ if (args.length === 0) {
68
+ showHelp();
69
+ return;
70
+ }
71
+
72
+ const firstArg = args[0];
73
+
74
+ try {
75
+ // Handle management commands (flags)
76
+ if (firstArg === '--help' || firstArg === '-h') {
77
+ showHelp();
78
+ return;
79
+ }
80
+
81
+ if (firstArg === '--version' || firstArg === '-V') {
82
+ console.log(version);
83
+ return;
84
+ }
85
+
86
+ if (firstArg === '--init') {
87
+ await initCommand();
88
+ return;
89
+ }
90
+
91
+ if (firstArg === '--login') {
92
+ await loginCommand();
93
+ return;
94
+ }
95
+
96
+ if (firstArg === '--list') {
97
+ await listCommand();
98
+ return;
99
+ }
100
+
101
+ if (firstArg === '--stop') {
102
+ await stopCommand();
103
+ return;
104
+ }
105
+
106
+ if (firstArg === '--delete') {
107
+ // Optional tunnel name as second argument
108
+ const tunnelName = args[1];
109
+ await deleteCommand(tunnelName);
110
+ return;
111
+ }
112
+
113
+ // Check for unknown flags
114
+ if (firstArg.startsWith('--')) {
115
+ console.error(chalk.red(`Unknown option: ${firstArg}`));
116
+ console.log(chalk.dim('Run: tuna --help'));
117
+ process.exit(1);
118
+ }
119
+
120
+ // Default: wrapper command
121
+ // All arguments are passed to the wrapped command
122
+ await runCommand(args);
123
+ } catch (error) {
124
+ console.error(chalk.red('Error:'), (error as Error).message);
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ main();