@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,14 @@
1
+
2
+ > @zeroexcore/tuna@0.1.0 test /home/runner/work/tuna/tuna/packages/cli
3
+ > vitest run
4
+
5
+
6
+  RUN  v4.0.18 /home/runner/work/tuna/tuna/packages/cli
7
+
8
+ ✓ tests/unit/config.test.ts (23 tests) 9ms
9
+
10
+  Test Files  1 passed (1)
11
+  Tests  23 passed (23)
12
+  Start at  07:11:47
13
+  Duration  198ms (transform 47ms, setup 0ms, import 66ms, tests 9ms, environment 0ms)
14
+
@@ -0,0 +1,4 @@
1
+
2
+ > @zeroexcore/tuna@0.1.0 typecheck /home/runner/work/tuna/tuna/packages/cli
3
+ > tsc --noEmit
4
+
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@zeroexcore/tuna",
3
+ "version": "0.1.0",
4
+ "description": "Cloudflare Tunnel Wrapper for Development Servers",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zeroexcore/tuna.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/zeroexcore/tuna/issues"
13
+ },
14
+ "homepage": "https://github.com/zeroexcore/tuna#readme",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "bin": {
19
+ "tuna": "./src/index.ts"
20
+ },
21
+ "keywords": [
22
+ "cloudflare",
23
+ "tunnel",
24
+ "dev",
25
+ "ngrok",
26
+ "localtunnel",
27
+ "development",
28
+ "cloudflared"
29
+ ],
30
+ "author": "zeroexcore",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/inquirer": "^9.0.9",
37
+ "@types/js-yaml": "^4.0.9",
38
+ "@types/node": "^25.0.10",
39
+ "@vitest/ui": "^4.0.18",
40
+ "tsx": "^4.21.0",
41
+ "vitest": "^4.0.18"
42
+ },
43
+ "dependencies": {
44
+ "chalk": "^5.6.2",
45
+ "execa": "^9.6.1",
46
+ "find-up": "^8.0.0",
47
+ "inquirer": "^13.2.1",
48
+ "js-yaml": "^4.1.1",
49
+ "keytar": "^7.9.0",
50
+ "ora": "^9.1.0"
51
+ },
52
+ "scripts": {
53
+ "dev": "tsx watch src/index.ts",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "test:watch": "vitest",
57
+ "test:ui": "vitest --ui",
58
+ "lint": "eslint src"
59
+ }
60
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Delete command - delete a tunnel and its DNS records
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import inquirer from 'inquirer';
8
+ import { readConfig, generateTunnelName } from '../lib/config.ts';
9
+ import { getCredentials, getRootDomain, listDomains } from '../lib/credentials.ts';
10
+ import { CloudflareAPI } from '../lib/api.ts';
11
+ import { deleteDnsRecordForDomain, listDnsRecordsForTunnel } from '../lib/dns.ts';
12
+ import {
13
+ stopService,
14
+ deleteTunnelCredentials,
15
+ deleteTunnelConfig,
16
+ isServiceInstalled,
17
+ } from '../lib/service.ts';
18
+ import { removeAccess } from '../lib/access.ts';
19
+ import type { Tunnel } from '../types/index.ts';
20
+
21
+ /**
22
+ * Delete a specific tunnel by name or from package.json config
23
+ */
24
+ export async function deleteCommand(tunnelNameArg?: string | boolean): Promise<void> {
25
+ console.log('');
26
+
27
+ let tunnelName: string;
28
+ let forward: string | undefined;
29
+ let rootDomain: string;
30
+
31
+ // Determine tunnel name
32
+ if (tunnelNameArg && typeof tunnelNameArg === 'string') {
33
+ // Tunnel name provided as argument
34
+ tunnelName = tunnelNameArg;
35
+ if (!tunnelName.startsWith('tuna-')) {
36
+ tunnelName = `tuna-${tunnelName}`;
37
+ }
38
+ } else {
39
+ // Try to read from package.json
40
+ try {
41
+ const config = await readConfig();
42
+ forward = config.forward;
43
+ tunnelName = generateTunnelName(forward);
44
+ } catch {
45
+ // No config found, ask user to specify
46
+ console.error(chalk.red('No tunnel name specified and no tuna config in package.json.'));
47
+ console.log(chalk.dim('\nUsage: tuna --delete <tunnel-name>'));
48
+ console.log(chalk.dim('Or run from a directory with tuna config in package.json.\n'));
49
+ process.exit(1);
50
+ }
51
+ }
52
+
53
+ // Get root domain - either from forward or ask user
54
+ if (forward) {
55
+ rootDomain = getRootDomain(forward);
56
+ } else {
57
+ // Get all configured domains
58
+ const domains = await listDomains();
59
+
60
+ if (domains.length === 0) {
61
+ console.error(chalk.red('No credentials configured.'));
62
+ console.log(chalk.dim('Run: tuna --login\n'));
63
+ process.exit(1);
64
+ }
65
+
66
+ if (domains.length === 1) {
67
+ rootDomain = domains[0];
68
+ } else {
69
+ const { domain } = await inquirer.prompt([
70
+ {
71
+ type: 'list',
72
+ name: 'domain',
73
+ message: 'Select domain for this tunnel:',
74
+ choices: domains,
75
+ },
76
+ ]);
77
+ rootDomain = domain;
78
+ }
79
+ }
80
+
81
+ // Get credentials
82
+ const credentials = await getCredentials(rootDomain);
83
+ if (!credentials) {
84
+ console.error(chalk.red(`No credentials found for ${rootDomain}`));
85
+ console.log(chalk.dim('Run: tuna --login\n'));
86
+ process.exit(1);
87
+ }
88
+
89
+ // Find the tunnel
90
+ const spinner = ora('Finding tunnel...').start();
91
+ const api = new CloudflareAPI(credentials);
92
+
93
+ let tunnel: Tunnel | undefined;
94
+ try {
95
+ const tunnels = await api.listTunnels();
96
+ tunnel = tunnels.find((t) => t.name === tunnelName && !t.deleted_at);
97
+ } catch (error) {
98
+ spinner.fail('Failed to fetch tunnels');
99
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
100
+ process.exit(1);
101
+ }
102
+
103
+ if (!tunnel) {
104
+ spinner.fail(`Tunnel not found: ${tunnelName}`);
105
+ console.log(chalk.dim('\nRun: tuna --list to see all tunnels.\n'));
106
+ process.exit(1);
107
+ }
108
+
109
+ spinner.stop();
110
+
111
+ // Get DNS records for this tunnel
112
+ let dnsRecords: string[] = [];
113
+ try {
114
+ dnsRecords = await listDnsRecordsForTunnel(credentials, tunnel.id, rootDomain);
115
+ } catch {
116
+ // Ignore errors
117
+ }
118
+
119
+ // Show what will be deleted
120
+ console.log(chalk.yellow('The following will be deleted:\n'));
121
+ console.log(` Tunnel: ${chalk.cyan(tunnel.name)}`);
122
+ console.log(` ID: ${chalk.dim(tunnel.id)}`);
123
+
124
+ if (dnsRecords.length > 0) {
125
+ console.log(` DNS records: ${dnsRecords.join(', ')}`);
126
+ }
127
+
128
+ console.log('');
129
+
130
+ // Confirm deletion
131
+ const { confirm } = await inquirer.prompt([
132
+ {
133
+ type: 'confirm',
134
+ name: 'confirm',
135
+ message: 'Are you sure you want to delete this tunnel?',
136
+ default: false,
137
+ },
138
+ ]);
139
+
140
+ if (!confirm) {
141
+ console.log(chalk.dim('\nDeletion cancelled.\n'));
142
+ return;
143
+ }
144
+
145
+ // Delete process
146
+ const deleteSpinner = ora('Deleting tunnel...').start();
147
+
148
+ try {
149
+ // 1. Stop service if running
150
+ deleteSpinner.text = 'Stopping service...';
151
+ if (isServiceInstalled()) {
152
+ await stopService();
153
+ }
154
+
155
+ // 2. Delete Access application (if exists)
156
+ deleteSpinner.text = 'Removing access control...';
157
+ for (const record of dnsRecords) {
158
+ try {
159
+ await removeAccess(credentials, record);
160
+ } catch {
161
+ // Ignore errors - may not have permissions or app doesn't exist
162
+ }
163
+ }
164
+
165
+ // 3. Delete DNS records
166
+ deleteSpinner.text = 'Deleting DNS records...';
167
+ for (const record of dnsRecords) {
168
+ await deleteDnsRecordForDomain(credentials, record);
169
+ }
170
+
171
+ // 4. Delete tunnel from Cloudflare
172
+ deleteSpinner.text = 'Deleting tunnel...';
173
+ await api.deleteTunnel(tunnel.id);
174
+
175
+ // 5. Delete local files
176
+ deleteSpinner.text = 'Cleaning up local files...';
177
+ deleteTunnelCredentials(tunnel.id);
178
+ deleteTunnelConfig(tunnel.id);
179
+
180
+ // 6. Uninstall service if no other tunnels
181
+ // (We leave the service installed for now - user can manually uninstall)
182
+
183
+ deleteSpinner.succeed('Tunnel deleted');
184
+ console.log('');
185
+ console.log(chalk.green(`✓ Tunnel ${tunnelName} has been deleted.\n`));
186
+ } catch (error) {
187
+ deleteSpinner.fail('Failed to delete tunnel');
188
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
189
+ console.log(chalk.yellow('\nPartial deletion may have occurred.'));
190
+ console.log(chalk.dim('Check: tuna --list\n'));
191
+ process.exit(1);
192
+ }
193
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Init command - interactive project setup
3
+ */
4
+
5
+ import inquirer from 'inquirer';
6
+ import chalk from 'chalk';
7
+ import ora from 'ora';
8
+ import { readFile, writeFile } from 'fs/promises';
9
+ import { findPackageJson } from '../lib/config.ts';
10
+ import { listDomains, getCredentials } from '../lib/credentials.ts';
11
+
12
+ interface InitAnswers {
13
+ subdomain: string;
14
+ port: number;
15
+ useEnvVar: boolean;
16
+ enableAccess: boolean;
17
+ accessRules?: string;
18
+ }
19
+
20
+ /**
21
+ * Interactive init flow to setup tuna config in package.json
22
+ */
23
+ export async function initCommand(): Promise<void> {
24
+ console.log(chalk.blue('\n🐟 Tuna Project Setup\n'));
25
+
26
+ // Check for package.json
27
+ const pkgPath = await findPackageJson();
28
+ if (!pkgPath) {
29
+ console.error(chalk.red('Error: No package.json found.'));
30
+ console.log(chalk.dim('Run this command from your project directory.'));
31
+ console.log(chalk.dim('\nTo create a new project:'));
32
+ console.log(chalk.cyan(' npm init -y'));
33
+ process.exit(1);
34
+ }
35
+
36
+ // Read existing package.json
37
+ const pkgContent = await readFile(pkgPath, 'utf-8');
38
+ const pkg = JSON.parse(pkgContent);
39
+
40
+ // Check if tuna config already exists
41
+ if (pkg.tuna) {
42
+ const { overwrite } = await inquirer.prompt([
43
+ {
44
+ type: 'confirm',
45
+ name: 'overwrite',
46
+ message: 'Tuna config already exists. Overwrite?',
47
+ default: false,
48
+ },
49
+ ]);
50
+
51
+ if (!overwrite) {
52
+ console.log(chalk.dim('Keeping existing config.'));
53
+ return;
54
+ }
55
+ }
56
+
57
+ // Try to get stored credentials to suggest domain
58
+ let storedDomain: string | undefined;
59
+ try {
60
+ // Check if we have any credentials stored
61
+ const domains = await listDomains();
62
+ if (domains.length > 0) {
63
+ // Use the first configured domain
64
+ const creds = await getCredentials(domains[0]);
65
+ if (creds) {
66
+ storedDomain = creds.domain;
67
+ }
68
+ }
69
+ } catch {
70
+ // No credentials stored, that's fine
71
+ }
72
+
73
+ // Check if credentials are set up
74
+ if (!storedDomain) {
75
+ console.log(chalk.yellow('No Cloudflare credentials found.'));
76
+ console.log(chalk.dim('Run `tuna --login` first to set up your credentials.\n'));
77
+
78
+ const { continueAnyway } = await inquirer.prompt([
79
+ {
80
+ type: 'confirm',
81
+ name: 'continueAnyway',
82
+ message: 'Continue with setup anyway?',
83
+ default: true,
84
+ },
85
+ ]);
86
+
87
+ if (!continueAnyway) {
88
+ console.log(chalk.dim('\nRun `tuna --login` then `tuna --init`'));
89
+ return;
90
+ }
91
+ }
92
+
93
+ const domainSuffix = storedDomain ? `.${storedDomain}` : '.example.com';
94
+ const projectName = pkg.name || 'my-app';
95
+
96
+ // Interactive prompts
97
+ const answers = await inquirer.prompt<InitAnswers>([
98
+ {
99
+ type: 'confirm',
100
+ name: 'useEnvVar',
101
+ message: 'Use $USER variable for unique subdomains per developer?',
102
+ default: true,
103
+ },
104
+ {
105
+ type: 'input',
106
+ name: 'subdomain',
107
+ message: (answers) =>
108
+ answers.useEnvVar
109
+ ? `Subdomain pattern (will become $USER-<pattern>${domainSuffix}):`
110
+ : `Full subdomain (will become <subdomain>${domainSuffix}):`,
111
+ default: projectName.replace(/[^a-z0-9-]/gi, '-').toLowerCase(),
112
+ validate: (input: string) => {
113
+ if (!input || input.trim().length === 0) {
114
+ return 'Subdomain is required';
115
+ }
116
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(input.trim())) {
117
+ return 'Subdomain must start with a letter/number and contain only letters, numbers, and hyphens';
118
+ }
119
+ return true;
120
+ },
121
+ },
122
+ {
123
+ type: 'number',
124
+ name: 'port',
125
+ message: 'Local port to forward:',
126
+ default: 3000,
127
+ validate: (input: number) => {
128
+ if (isNaN(input) || input < 1 || input > 65535) {
129
+ return 'Port must be between 1 and 65535';
130
+ }
131
+ return true;
132
+ },
133
+ },
134
+ {
135
+ type: 'confirm',
136
+ name: 'enableAccess',
137
+ message: 'Enable Zero Trust Access control?',
138
+ default: false,
139
+ },
140
+ {
141
+ type: 'input',
142
+ name: 'accessRules',
143
+ message: 'Access rules (comma-separated emails or @domains):',
144
+ when: (answers) => answers.enableAccess,
145
+ default: storedDomain ? `@${storedDomain}` : '@yourcompany.com',
146
+ validate: (input: string) => {
147
+ if (!input || input.trim().length === 0) {
148
+ return 'At least one access rule is required';
149
+ }
150
+ return true;
151
+ },
152
+ },
153
+ ]);
154
+
155
+ // Build the forward domain
156
+ const subdomain = answers.subdomain.trim().toLowerCase();
157
+ const forward = answers.useEnvVar
158
+ ? `$USER-${subdomain}${domainSuffix}`
159
+ : `${subdomain}${domainSuffix}`;
160
+
161
+ // Build config
162
+ const tunaConfig: Record<string, unknown> = {
163
+ forward,
164
+ port: answers.port,
165
+ };
166
+
167
+ if (answers.enableAccess && answers.accessRules) {
168
+ tunaConfig.access = answers.accessRules
169
+ .split(',')
170
+ .map((rule) => rule.trim())
171
+ .filter((rule) => rule.length > 0);
172
+ }
173
+
174
+ // Update package.json
175
+ const spinner = ora('Updating package.json...').start();
176
+
177
+ try {
178
+ pkg.tuna = tunaConfig;
179
+
180
+ // Preserve formatting by using 2-space indent
181
+ const newContent = JSON.stringify(pkg, null, 2) + '\n';
182
+ await writeFile(pkgPath, newContent, 'utf-8');
183
+
184
+ spinner.succeed('Configuration saved to package.json');
185
+ } catch (error) {
186
+ spinner.fail('Failed to update package.json');
187
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
188
+ process.exit(1);
189
+ }
190
+
191
+ // Show result
192
+ console.log(chalk.green('\n✓ Tuna configured!\n'));
193
+ console.log(chalk.dim('Added to package.json:'));
194
+ console.log(chalk.cyan(JSON.stringify({ tuna: tunaConfig }, null, 2)));
195
+
196
+ // Show next steps
197
+ console.log(chalk.dim('\nNext steps:'));
198
+ if (!storedDomain) {
199
+ console.log(chalk.dim(' 1. Run: ') + chalk.cyan('tuna --login'));
200
+ console.log(chalk.dim(' 2. Update the domain in package.json'));
201
+ console.log(chalk.dim(' 3. Run: ') + chalk.cyan('tuna npm run dev'));
202
+ } else {
203
+ console.log(chalk.dim(' Run: ') + chalk.cyan('tuna npm run dev'));
204
+ }
205
+
206
+ // Show example URL
207
+ const exampleUser = process.env.USER || 'alice';
208
+ const exampleUrl = answers.useEnvVar
209
+ ? `https://${exampleUser}-${subdomain}${domainSuffix}`
210
+ : `https://${subdomain}${domainSuffix}`;
211
+
212
+ console.log(chalk.dim('\nYour tunnel URL will be:'));
213
+ console.log(chalk.cyan(` ${exampleUrl}`));
214
+
215
+ if (answers.useEnvVar) {
216
+ console.log(chalk.dim(`\n (Each developer gets their own URL based on $USER)`));
217
+ }
218
+ console.log();
219
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * List command - display all tunnels for configured domains
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import inquirer from 'inquirer';
8
+ import { listDomains, getCredentials } from '../lib/credentials.ts';
9
+ import { CloudflareAPI } from '../lib/api.ts';
10
+ import { listDnsRecordsForTunnel } from '../lib/dns.ts';
11
+ import type { Tunnel } from '../types/index.ts';
12
+
13
+ /**
14
+ * Format tunnel status with color
15
+ */
16
+ function formatStatus(status: Tunnel['status']): string {
17
+ switch (status) {
18
+ case 'healthy':
19
+ return chalk.green('● healthy');
20
+ case 'degraded':
21
+ return chalk.yellow('● degraded');
22
+ case 'down':
23
+ return chalk.red('● down');
24
+ case 'inactive':
25
+ return chalk.dim('○ inactive');
26
+ default:
27
+ return chalk.dim('○ unknown');
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Format tunnel name (highlight tuna- tunnels)
33
+ */
34
+ function formatTunnelName(name: string): string {
35
+ if (name.startsWith('tuna-')) {
36
+ return chalk.cyan(name);
37
+ }
38
+ return chalk.dim(name);
39
+ }
40
+
41
+ /**
42
+ * List all tunnels for configured domains
43
+ */
44
+ export async function listCommand(): Promise<void> {
45
+ // Get all configured domains
46
+ const domains = await listDomains();
47
+
48
+ if (domains.length === 0) {
49
+ console.log(chalk.yellow('\nNo credentials configured.'));
50
+ console.log(chalk.dim('Run: tuna --login\n'));
51
+ return;
52
+ }
53
+
54
+ // If multiple domains, let user choose
55
+ let selectedDomain: string;
56
+
57
+ if (domains.length === 1) {
58
+ selectedDomain = domains[0];
59
+ } else {
60
+ const { domain } = await inquirer.prompt([
61
+ {
62
+ type: 'list',
63
+ name: 'domain',
64
+ message: 'Select domain:',
65
+ choices: domains,
66
+ },
67
+ ]);
68
+ selectedDomain = domain;
69
+ }
70
+
71
+ // Get credentials
72
+ const spinner = ora('Fetching tunnels...').start();
73
+
74
+ const credentials = await getCredentials(selectedDomain);
75
+ if (!credentials) {
76
+ spinner.fail('Failed to get credentials');
77
+ console.error(chalk.red(`\nNo credentials found for ${selectedDomain}`));
78
+ return;
79
+ }
80
+
81
+ // Fetch tunnels
82
+ const api = new CloudflareAPI(credentials);
83
+ let tunnels: Tunnel[];
84
+
85
+ try {
86
+ tunnels = await api.listTunnels();
87
+ // Filter out deleted tunnels
88
+ tunnels = tunnels.filter((t) => !t.deleted_at);
89
+ } catch (error) {
90
+ spinner.fail('Failed to fetch tunnels');
91
+ console.error(chalk.red(`\nError: ${(error as Error).message}`));
92
+ return;
93
+ }
94
+
95
+ spinner.stop();
96
+
97
+ // Separate tuna tunnels from others
98
+ const tunaTunnels = tunnels.filter((t) => t.name.startsWith('tuna-'));
99
+ const otherTunnels = tunnels.filter((t) => !t.name.startsWith('tuna-'));
100
+
101
+ console.log('');
102
+ console.log(chalk.bold(`TUNNELS for ${selectedDomain}`), chalk.dim(`(${tunnels.length} total)`));
103
+ console.log('');
104
+
105
+ if (tunaTunnels.length === 0 && otherTunnels.length === 0) {
106
+ console.log(chalk.dim(' No tunnels found.\n'));
107
+ return;
108
+ }
109
+
110
+ // Display tuna tunnels
111
+ if (tunaTunnels.length > 0) {
112
+ console.log(chalk.dim(' Tuna Tunnels:'));
113
+
114
+ for (const tunnel of tunaTunnels) {
115
+ // Try to get DNS records for this tunnel
116
+ let dnsRecords: string[] = [];
117
+ try {
118
+ dnsRecords = await listDnsRecordsForTunnel(credentials, tunnel.id, selectedDomain);
119
+ } catch {
120
+ // Ignore DNS lookup errors
121
+ }
122
+
123
+ const name = formatTunnelName(tunnel.name);
124
+ const status = formatStatus(tunnel.status);
125
+ const domains = dnsRecords.length > 0 ? dnsRecords.join(', ') : chalk.dim('no DNS');
126
+
127
+ console.log(` ${name}`);
128
+ console.log(` ${status} ${domains}`);
129
+ }
130
+ console.log('');
131
+ }
132
+
133
+ // Display other tunnels (non-tuna)
134
+ if (otherTunnels.length > 0) {
135
+ console.log(chalk.dim(' Other Tunnels:'));
136
+
137
+ for (const tunnel of otherTunnels) {
138
+ const name = formatTunnelName(tunnel.name);
139
+ const status = formatStatus(tunnel.status);
140
+
141
+ console.log(` ${name} ${status}`);
142
+ }
143
+ console.log('');
144
+ }
145
+
146
+ // Show connection info for active tunnels
147
+ const activeTunnels = tunaTunnels.filter((t) => t.status === 'healthy');
148
+ if (activeTunnels.length > 0) {
149
+ console.log(chalk.dim(' Active connections:'));
150
+ for (const tunnel of activeTunnels) {
151
+ if (tunnel.connections && tunnel.connections.length > 0) {
152
+ for (const conn of tunnel.connections) {
153
+ console.log(chalk.dim(` ${tunnel.name}: ${conn.colo_name} (${conn.origin_ip})`));
154
+ }
155
+ }
156
+ }
157
+ console.log('');
158
+ }
159
+ }