@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.
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/package.json +60 -0
- package/src/commands/delete.ts +193 -0
- package/src/commands/init.ts +219 -0
- package/src/commands/list.ts +159 -0
- package/src/commands/login.ts +133 -0
- package/src/commands/run.ts +241 -0
- package/src/commands/stop.ts +44 -0
- package/src/index.ts +129 -0
- package/src/lib/access.ts +191 -0
- package/src/lib/api.ts +383 -0
- package/src/lib/cloudflared.ts +279 -0
- package/src/lib/config.ts +125 -0
- package/src/lib/credentials.ts +67 -0
- package/src/lib/dns.ts +164 -0
- package/src/lib/service.ts +354 -0
- package/src/types/cloudflare.ts +155 -0
- package/src/types/config.ts +33 -0
- package/src/types/index.ts +6 -0
- package/tests/unit/config.test.ts +176 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +18 -0
|
@@ -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();
|