@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,14 @@
|
|
|
1
|
+
|
|
2
|
+
> @zeroexcore/tuna@0.1.0 test /home/runner/work/tuna/tuna/packages/cli
|
|
3
|
+
> vitest run
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[1m[46m RUN [49m[22m [36mv4.0.18 [39m[90m/home/runner/work/tuna/tuna/packages/cli[39m
|
|
7
|
+
|
|
8
|
+
[32m✓[39m tests/unit/config.test.ts [2m([22m[2m23 tests[22m[2m)[22m[32m 9[2mms[22m[39m
|
|
9
|
+
|
|
10
|
+
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
11
|
+
[2m Tests [22m [1m[32m23 passed[39m[22m[90m (23)[39m
|
|
12
|
+
[2m Start at [22m 07:11:47
|
|
13
|
+
[2m Duration [22m 198ms[2m (transform 47ms, setup 0ms, import 66ms, tests 9ms, environment 0ms)[22m
|
|
14
|
+
|
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
|
+
}
|