@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,279 @@
1
+ /**
2
+ * Cloudflared binary management - detection, download, and execution
3
+ */
4
+
5
+ import { execa, type ExecaError } from 'execa';
6
+ import { existsSync, mkdirSync, chmodSync, createWriteStream } from 'fs';
7
+ import { homedir, platform, arch } from 'os';
8
+ import { join } from 'path';
9
+
10
+ const TUNA_DIR = join(homedir(), '.tuna');
11
+ const BIN_DIR = join(TUNA_DIR, 'bin');
12
+ const TUNNELS_DIR = join(TUNA_DIR, 'tunnels');
13
+
14
+ // GitHub release download URLs
15
+ const DOWNLOAD_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
16
+
17
+ interface ExecResult {
18
+ stdout: string;
19
+ stderr: string;
20
+ exitCode: number;
21
+ }
22
+
23
+ /**
24
+ * Get the download URL for cloudflared based on platform and architecture
25
+ */
26
+ function getDownloadUrl(): string {
27
+ const os = platform();
28
+ const architecture = arch();
29
+
30
+ if (os === 'darwin') {
31
+ if (architecture === 'arm64') {
32
+ return `${DOWNLOAD_BASE}/cloudflared-darwin-arm64.tgz`;
33
+ }
34
+ return `${DOWNLOAD_BASE}/cloudflared-darwin-amd64.tgz`;
35
+ }
36
+
37
+ if (os === 'linux') {
38
+ if (architecture === 'arm64') {
39
+ return `${DOWNLOAD_BASE}/cloudflared-linux-arm64`;
40
+ }
41
+ if (architecture === 'arm') {
42
+ return `${DOWNLOAD_BASE}/cloudflared-linux-arm`;
43
+ }
44
+ return `${DOWNLOAD_BASE}/cloudflared-linux-amd64`;
45
+ }
46
+
47
+ if (os === 'win32') {
48
+ if (architecture === 'x64') {
49
+ return `${DOWNLOAD_BASE}/cloudflared-windows-amd64.exe`;
50
+ }
51
+ return `${DOWNLOAD_BASE}/cloudflared-windows-386.exe`;
52
+ }
53
+
54
+ throw new Error(`Unsupported platform: ${os} ${architecture}`);
55
+ }
56
+
57
+ /**
58
+ * Get the path to the cloudflared binary (either in PATH or downloaded)
59
+ */
60
+ export function getExecutablePath(): string {
61
+ // First check if cloudflared is in PATH
62
+ const systemPath = getSystemCloudflaredPath();
63
+ if (systemPath) {
64
+ return systemPath;
65
+ }
66
+
67
+ // Fall back to downloaded binary
68
+ const os = platform();
69
+ const binaryName = os === 'win32' ? 'cloudflared.exe' : 'cloudflared';
70
+ return join(BIN_DIR, binaryName);
71
+ }
72
+
73
+ /**
74
+ * Check if cloudflared is available in system PATH
75
+ */
76
+ function getSystemCloudflaredPath(): string | null {
77
+ try {
78
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
79
+ const { execSync } = require('child_process') as typeof import('child_process');
80
+ const stdout = execSync('which cloudflared 2>/dev/null || where cloudflared 2>nul', {
81
+ encoding: 'utf-8',
82
+ stdio: ['pipe', 'pipe', 'pipe']
83
+ });
84
+ const path = stdout.trim().split('\n')[0];
85
+ return path || null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if cloudflared is installed (either in PATH or downloaded)
93
+ */
94
+ export function isInstalled(): boolean {
95
+ // Check system PATH first
96
+ if (getSystemCloudflaredPath()) {
97
+ return true;
98
+ }
99
+
100
+ // Check downloaded binary
101
+ const binaryPath = getExecutablePath();
102
+ return existsSync(binaryPath);
103
+ }
104
+
105
+ /**
106
+ * Get the installed cloudflared version
107
+ */
108
+ export async function getVersion(): Promise<string> {
109
+ const result = await exec(['--version']);
110
+ // Output format: "cloudflared version 2024.1.0 (built 2024-01-15-1234 revision abcd1234)"
111
+ const match = result.stdout.match(/version\s+(\d+\.\d+\.\d+)/);
112
+ if (match) {
113
+ return match[1];
114
+ }
115
+ throw new Error('Could not parse cloudflared version');
116
+ }
117
+
118
+ /**
119
+ * Ensure the tuna directories exist
120
+ */
121
+ export function ensureDirectories(): void {
122
+ if (!existsSync(TUNA_DIR)) {
123
+ mkdirSync(TUNA_DIR, { recursive: true });
124
+ }
125
+ if (!existsSync(BIN_DIR)) {
126
+ mkdirSync(BIN_DIR, { recursive: true });
127
+ }
128
+ if (!existsSync(TUNNELS_DIR)) {
129
+ mkdirSync(TUNNELS_DIR, { recursive: true, mode: 0o700 });
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Download cloudflared binary for the current platform
135
+ */
136
+ export async function download(
137
+ onProgress?: (percent: number) => void
138
+ ): Promise<string> {
139
+ ensureDirectories();
140
+
141
+ const url = getDownloadUrl();
142
+ const isTarball = url.endsWith('.tgz');
143
+ const binaryPath = getExecutablePath();
144
+
145
+ // Download the file
146
+ const response = await fetch(url);
147
+ if (!response.ok) {
148
+ throw new Error(`Failed to download cloudflared: ${response.statusText}`);
149
+ }
150
+
151
+ const contentLength = response.headers.get('content-length');
152
+ const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
153
+ let downloadedSize = 0;
154
+
155
+ if (isTarball) {
156
+ // For macOS, we need to extract from tarball
157
+ const tempTarPath = join(BIN_DIR, 'cloudflared.tgz');
158
+ const writeStream = createWriteStream(tempTarPath);
159
+
160
+ const reader = response.body?.getReader();
161
+ if (!reader) {
162
+ throw new Error('Failed to get response body reader');
163
+ }
164
+
165
+ // Download with progress
166
+ while (true) {
167
+ const { done, value } = await reader.read();
168
+ if (done) break;
169
+
170
+ writeStream.write(Buffer.from(value));
171
+ downloadedSize += value.length;
172
+
173
+ if (onProgress && totalSize > 0) {
174
+ onProgress(Math.round((downloadedSize / totalSize) * 100));
175
+ }
176
+ }
177
+
178
+ writeStream.end();
179
+ await new Promise((resolve) => writeStream.on('finish', resolve));
180
+
181
+ // Extract tarball using tar command
182
+ await execa('tar', ['-xzf', tempTarPath, '-C', BIN_DIR]);
183
+
184
+ // Remove tarball
185
+ const { unlinkSync } = await import('fs');
186
+ unlinkSync(tempTarPath);
187
+ } else {
188
+ // Direct binary download (Linux, Windows)
189
+ const writeStream = createWriteStream(binaryPath);
190
+ const reader = response.body?.getReader();
191
+ if (!reader) {
192
+ throw new Error('Failed to get response body reader');
193
+ }
194
+
195
+ while (true) {
196
+ const { done, value } = await reader.read();
197
+ if (done) break;
198
+
199
+ writeStream.write(Buffer.from(value));
200
+ downloadedSize += value.length;
201
+
202
+ if (onProgress && totalSize > 0) {
203
+ onProgress(Math.round((downloadedSize / totalSize) * 100));
204
+ }
205
+ }
206
+
207
+ writeStream.end();
208
+ await new Promise((resolve) => writeStream.on('finish', resolve));
209
+ }
210
+
211
+ // Make executable on Unix
212
+ if (platform() !== 'win32') {
213
+ chmodSync(binaryPath, 0o755);
214
+ }
215
+
216
+ return binaryPath;
217
+ }
218
+
219
+ /**
220
+ * Execute a cloudflared command
221
+ */
222
+ export async function exec(args: string[]): Promise<ExecResult> {
223
+ const binaryPath = getExecutablePath();
224
+
225
+ if (!existsSync(binaryPath) && !getSystemCloudflaredPath()) {
226
+ throw new Error(
227
+ 'cloudflared is not installed. Run `tuna` to auto-download, or install manually:\n' +
228
+ ' brew install cloudflared\n' +
229
+ ' https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
230
+ );
231
+ }
232
+
233
+ try {
234
+ const result = await execa(binaryPath, args);
235
+ return {
236
+ stdout: String(result.stdout ?? ''),
237
+ stderr: String(result.stderr ?? ''),
238
+ exitCode: result.exitCode ?? 0,
239
+ };
240
+ } catch (error) {
241
+ const execaError = error as ExecaError;
242
+ if (execaError.exitCode !== undefined) {
243
+ return {
244
+ stdout: String(execaError.stdout ?? ''),
245
+ stderr: String(execaError.stderr ?? ''),
246
+ exitCode: execaError.exitCode,
247
+ };
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Get the tuna directory path
255
+ */
256
+ export function getTunaDir(): string {
257
+ return TUNA_DIR;
258
+ }
259
+
260
+ /**
261
+ * Get the tunnels directory path
262
+ */
263
+ export function getTunnelsDir(): string {
264
+ return TUNNELS_DIR;
265
+ }
266
+
267
+ /**
268
+ * Get path for a tunnel credentials file
269
+ */
270
+ export function getTunnelCredentialsPath(tunnelId: string): string {
271
+ return join(TUNNELS_DIR, `${tunnelId}.json`);
272
+ }
273
+
274
+ /**
275
+ * Get path for a tunnel config file
276
+ */
277
+ export function getTunnelConfigPath(tunnelId: string): string {
278
+ return join(TUNA_DIR, `config-${tunnelId}.yml`);
279
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Configuration management - reads package.json and interpolates environment variables
3
+ */
4
+
5
+ import { findUp } from 'find-up';
6
+ import { readFile } from 'fs/promises';
7
+ import type { PackageJson, TunaConfig } from '../types/index.ts';
8
+
9
+ /**
10
+ * Find package.json in current directory or parent directories
11
+ */
12
+ export async function findPackageJson(): Promise<string | null> {
13
+ const found = await findUp('package.json');
14
+ return found || null;
15
+ }
16
+
17
+ /**
18
+ * Interpolate environment variables in a string
19
+ * Priority: $TUNA_USER > $USER > 'unknown'
20
+ */
21
+ export function interpolateEnvVars(value: string): string {
22
+ const tunaUser = process.env.TUNA_USER;
23
+ const user = process.env.USER;
24
+ const home = process.env.HOME;
25
+
26
+ let result = value;
27
+
28
+ // Replace $TUNA_USER first (if set)
29
+ if (tunaUser) {
30
+ result = result.replace(/\$TUNA_USER/g, tunaUser);
31
+ }
32
+
33
+ // Replace $USER with TUNA_USER if set, otherwise USER, otherwise 'unknown'
34
+ result = result.replace(/\$USER/g, tunaUser || user || 'unknown');
35
+
36
+ // Replace $HOME
37
+ result = result.replace(/\$HOME/g, home || '~');
38
+
39
+ return result;
40
+ }
41
+
42
+ /**
43
+ * Validate tuna configuration
44
+ */
45
+ export function validateConfig(config: TunaConfig): void {
46
+ if (!config.forward) {
47
+ throw new Error('Missing "forward" in tuna config');
48
+ }
49
+
50
+ if (config.port === undefined || config.port === null) {
51
+ throw new Error('Missing "port" in tuna config');
52
+ }
53
+
54
+ if (typeof config.port !== 'number') {
55
+ throw new Error('"port" must be a number');
56
+ }
57
+
58
+ if (config.port < 1 || config.port > 65535) {
59
+ throw new Error('"port" must be between 1 and 65535');
60
+ }
61
+
62
+ // Ensure no unresolved variables remain (check this before domain validation)
63
+ if (config.forward.includes('$')) {
64
+ throw new Error(
65
+ `Unresolved environment variables in forward field: ${config.forward}\n` +
66
+ 'Supported: $USER, $TUNA_USER, $HOME'
67
+ );
68
+ }
69
+
70
+ // Basic domain validation (after interpolation)
71
+ // Allow dots but not consecutive dots
72
+ const domainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i;
73
+ if (!domainRegex.test(config.forward)) {
74
+ throw new Error(`Invalid domain format: ${config.forward}`);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read and parse tuna configuration from package.json
80
+ */
81
+ export async function readConfig(): Promise<TunaConfig> {
82
+ const pkgPath = await findPackageJson();
83
+
84
+ if (!pkgPath) {
85
+ throw new Error(
86
+ 'No package.json found.\n' +
87
+ 'Run this command from your project directory.'
88
+ );
89
+ }
90
+
91
+ const content = await readFile(pkgPath, 'utf-8');
92
+ const pkg: PackageJson = JSON.parse(content);
93
+
94
+ if (!pkg.tuna) {
95
+ throw new Error(
96
+ 'No "tuna" config in package.json.\n' +
97
+ 'Add to package.json:\n' +
98
+ '{\n' +
99
+ ' "tuna": {\n' +
100
+ ' "forward": "my-app.example.com",\n' +
101
+ ' "port": 3000\n' +
102
+ ' }\n' +
103
+ '}'
104
+ );
105
+ }
106
+
107
+ // Interpolate environment variables in forward field
108
+ const interpolatedConfig: TunaConfig = {
109
+ ...pkg.tuna,
110
+ forward: interpolateEnvVars(pkg.tuna.forward),
111
+ };
112
+
113
+ validateConfig(interpolatedConfig);
114
+ return interpolatedConfig;
115
+ }
116
+
117
+ /**
118
+ * Generate tunnel name from forward domain
119
+ * Format: tuna-{sanitized-forward}
120
+ */
121
+ export function generateTunnelName(forward: string): string {
122
+ // Sanitize: replace non-alphanumeric with -, lowercase
123
+ const sanitized = forward.replace(/[^a-z0-9]/gi, '-').toLowerCase();
124
+ return `tuna-${sanitized}`;
125
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Credential management - secure storage in macOS Keychain
3
+ */
4
+
5
+ import keytar from 'keytar';
6
+ import type { Credentials } from '../types/index.ts';
7
+
8
+ // Use a single service name so findCredentials works correctly
9
+ const SERVICE_NAME = 'tuna';
10
+
11
+ /**
12
+ * Store credentials in macOS Keychain
13
+ */
14
+ export async function storeCredentials(
15
+ domain: string,
16
+ creds: Credentials
17
+ ): Promise<void> {
18
+ const password = JSON.stringify(creds);
19
+ await keytar.setPassword(SERVICE_NAME, domain, password);
20
+ }
21
+
22
+ /**
23
+ * Retrieve credentials from macOS Keychain
24
+ * Triggers biometric authentication
25
+ */
26
+ export async function getCredentials(
27
+ domain: string
28
+ ): Promise<Credentials | null> {
29
+ const password = await keytar.getPassword(SERVICE_NAME, domain);
30
+
31
+ if (!password) {
32
+ return null;
33
+ }
34
+
35
+ try {
36
+ return JSON.parse(password) as Credentials;
37
+ } catch {
38
+ throw new Error(`Failed to parse credentials for ${domain}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Delete credentials from macOS Keychain
44
+ */
45
+ export async function deleteCredentials(domain: string): Promise<boolean> {
46
+ return await keytar.deletePassword(SERVICE_NAME, domain);
47
+ }
48
+
49
+ /**
50
+ * List all configured domains
51
+ */
52
+ export async function listDomains(): Promise<string[]> {
53
+ const credentials = await keytar.findCredentials(SERVICE_NAME);
54
+ return credentials.map((c) => c.account);
55
+ }
56
+
57
+ /**
58
+ * Extract root domain from subdomain
59
+ * e.g., "my-app.example.com" → "example.com"
60
+ */
61
+ export function getRootDomain(forward: string): string {
62
+ const parts = forward.split('.');
63
+ if (parts.length < 2) {
64
+ throw new Error(`Invalid domain: ${forward}`);
65
+ }
66
+ return parts.slice(-2).join('.');
67
+ }
package/src/lib/dns.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * DNS management - higher-level DNS operations built on top of API client
3
+ */
4
+
5
+ import { CloudflareAPI } from './api.ts';
6
+ import { getRootDomain } from './credentials.ts';
7
+ import type { Credentials } from '../types/index.ts';
8
+
9
+ /**
10
+ * Get the CNAME target for a tunnel
11
+ * Format: {tunnel-id}.cfargotunnel.com
12
+ */
13
+ export function getTunnelCnameTarget(tunnelId: string): string {
14
+ return `${tunnelId}.cfargotunnel.com`;
15
+ }
16
+
17
+ /**
18
+ * Ensure a DNS CNAME record exists pointing to the tunnel
19
+ * Creates the record if it doesn't exist, updates if it points to wrong target
20
+ */
21
+ export async function ensureDnsRecord(
22
+ credentials: Credentials,
23
+ forward: string,
24
+ tunnelId: string
25
+ ): Promise<void> {
26
+ const api = new CloudflareAPI(credentials);
27
+ const rootDomain = getRootDomain(forward);
28
+ const cnameTarget = getTunnelCnameTarget(tunnelId);
29
+
30
+ // Get the zone
31
+ const zone = await api.getZoneByName(rootDomain);
32
+
33
+ // Check if record already exists
34
+ const existingRecords = await api.listDnsRecords(zone.id, forward);
35
+ const existingCname = existingRecords.find(
36
+ (r) => r.type === 'CNAME' && r.name === forward
37
+ );
38
+
39
+ if (existingCname) {
40
+ // Record exists - check if it points to correct target
41
+ if (existingCname.content === cnameTarget) {
42
+ // Already correct, nothing to do
43
+ return;
44
+ }
45
+
46
+ // Update to point to correct target
47
+ await api.updateDnsRecord(zone.id, existingCname.id!, {
48
+ type: 'CNAME',
49
+ name: forward,
50
+ content: cnameTarget,
51
+ proxied: true,
52
+ ttl: 1, // Auto
53
+ });
54
+ return;
55
+ }
56
+
57
+ // Check for conflicting A/AAAA records
58
+ const conflictingRecords = existingRecords.filter(
59
+ (r) => (r.type === 'A' || r.type === 'AAAA') && r.name === forward
60
+ );
61
+
62
+ if (conflictingRecords.length > 0) {
63
+ throw new Error(
64
+ `DNS conflict: ${forward} already has ${conflictingRecords[0].type} record(s).\n` +
65
+ `Delete them first or use a different subdomain.`
66
+ );
67
+ }
68
+
69
+ // Create new CNAME record
70
+ await api.createDnsRecord(zone.id, {
71
+ type: 'CNAME',
72
+ name: forward,
73
+ content: cnameTarget,
74
+ proxied: true,
75
+ ttl: 1, // Auto
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Delete the DNS record for a domain
81
+ */
82
+ export async function deleteDnsRecordForDomain(
83
+ credentials: Credentials,
84
+ forward: string
85
+ ): Promise<boolean> {
86
+ const api = new CloudflareAPI(credentials);
87
+ const rootDomain = getRootDomain(forward);
88
+
89
+ try {
90
+ const zone = await api.getZoneByName(rootDomain);
91
+ const records = await api.listDnsRecords(zone.id, forward);
92
+
93
+ // Find and delete CNAME record for this domain
94
+ const cnameRecord = records.find(
95
+ (r) => r.type === 'CNAME' && r.name === forward
96
+ );
97
+
98
+ if (cnameRecord && cnameRecord.id) {
99
+ await api.deleteDnsRecord(zone.id, cnameRecord.id);
100
+ return true;
101
+ }
102
+
103
+ return false;
104
+ } catch (error) {
105
+ // If zone not found, record doesn't exist
106
+ if ((error as Error).message.includes('Zone not found')) {
107
+ return false;
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * List all DNS records pointing to a specific tunnel
115
+ */
116
+ export async function listDnsRecordsForTunnel(
117
+ credentials: Credentials,
118
+ tunnelId: string,
119
+ domain: string
120
+ ): Promise<string[]> {
121
+ const api = new CloudflareAPI(credentials);
122
+ const cnameTarget = getTunnelCnameTarget(tunnelId);
123
+
124
+ try {
125
+ const zone = await api.getZoneByName(domain);
126
+ const allRecords = await api.listDnsRecords(zone.id);
127
+
128
+ // Find all CNAME records pointing to this tunnel
129
+ return allRecords
130
+ .filter((r) => r.type === 'CNAME' && r.content === cnameTarget)
131
+ .map((r) => r.name);
132
+ } catch (error) {
133
+ // If zone not found, return empty list
134
+ if ((error as Error).message.includes('Zone not found')) {
135
+ return [];
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Validate that the domain is properly configured
143
+ * Returns true if DNS record exists and points to the tunnel
144
+ */
145
+ export async function validateDnsRecord(
146
+ credentials: Credentials,
147
+ forward: string,
148
+ tunnelId: string
149
+ ): Promise<boolean> {
150
+ const api = new CloudflareAPI(credentials);
151
+ const rootDomain = getRootDomain(forward);
152
+ const cnameTarget = getTunnelCnameTarget(tunnelId);
153
+
154
+ try {
155
+ const zone = await api.getZoneByName(rootDomain);
156
+ const records = await api.listDnsRecords(zone.id, forward);
157
+
158
+ return records.some(
159
+ (r) => r.type === 'CNAME' && r.name === forward && r.content === cnameTarget
160
+ );
161
+ } catch {
162
+ return false;
163
+ }
164
+ }