@take-out/scripts 0.0.28

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 vxrn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@take-out/scripts",
3
+ "version": "0.0.28",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ "./package.json": "./package.json",
9
+ "./helpers/*": {
10
+ "types": "./src/helpers/*.ts",
11
+ "default": "./src/helpers/*.ts"
12
+ },
13
+ "./*": {
14
+ "types": "./src/*.ts",
15
+ "default": "./src/*.ts"
16
+ }
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@take-out/helpers": "0.0.28"
23
+ },
24
+ "devDependencies": {
25
+ "vxrn": "*"
26
+ }
27
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * @description Bootstrap project dependencies and workspace packages
5
+ *
6
+ * This script runs automatically during `bun install` via the prepare lifecycle.
7
+ * It manages the .env file configuration with the following responsibilities:
8
+ *
9
+ * 1. Creates .env from .env.template if it doesn't exist
10
+ * 2. Maintains an auto-generated section in .env with package metadata
11
+ * 3. Currently syncs ZERO_VERSION from package.json dependencies
12
+ * 4. Preserves all user-defined environment variables
13
+ * 5. Creates backups before modifications for safety
14
+ *
15
+ * The auto-generated section is clearly marked and should not be edited manually.
16
+ * All operations are defensive and will not fail the install process.
17
+ */
18
+
19
+ import {
20
+ existsSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ copyFileSync,
24
+ renameSync,
25
+ unlinkSync,
26
+ } from 'node:fs'
27
+ import { join } from 'node:path'
28
+ import { getZeroVersion } from '@take-out/scripts/helpers/zero-get-version'
29
+
30
+ const ENV_PATH = join(process.cwd(), '.env')
31
+ const ENV_TEMPLATE_PATH = join(process.cwd(), '.env.template')
32
+ const ENV_BACKUP_PATH = join(process.cwd(), '.env.backup')
33
+ const ENV_TEMP_PATH = join(process.cwd(), '.env.tmp')
34
+
35
+ // auto-generated section markers
36
+ const BEGIN_MARKER = '# ---- BEGIN AUTO-GENERATED (DO NOT EDIT) ----'
37
+ const END_MARKER = '# ---- END AUTO-GENERATED ----'
38
+
39
+ function createEnvFromTemplate(): boolean {
40
+ if (!existsSync(ENV_TEMPLATE_PATH)) {
41
+ console.info('No .env.template found, skipping .env creation')
42
+ return false
43
+ }
44
+
45
+ try {
46
+ copyFileSync(ENV_TEMPLATE_PATH, ENV_PATH)
47
+ console.info('Created .env from .env.template')
48
+ return true
49
+ } catch (error) {
50
+ console.error('Failed to create .env from .env.template:', error)
51
+ return false
52
+ }
53
+ }
54
+
55
+ function getAutoGeneratedContent(): string {
56
+ const zeroVersion = getZeroVersion()
57
+ if (!zeroVersion) {
58
+ console.warn('Could not determine Zero version')
59
+ return ''
60
+ }
61
+
62
+ // build the auto-generated content
63
+ const lines = [
64
+ BEGIN_MARKER,
65
+ `# Generated at: ${new Date().toISOString()}`,
66
+ `ZERO_VERSION=${zeroVersion}`,
67
+ END_MARKER,
68
+ ]
69
+
70
+ return lines.join('\n')
71
+ }
72
+
73
+ function updateEnvFile(): void {
74
+ // ensure .env exists
75
+ if (!existsSync(ENV_PATH)) {
76
+ const created = createEnvFromTemplate()
77
+ if (!created && !existsSync(ENV_PATH)) {
78
+ // create empty .env if no template exists
79
+ writeFileSync(ENV_PATH, '')
80
+ console.info('Created empty .env file')
81
+ }
82
+ }
83
+
84
+ try {
85
+ // create backup
86
+ if (existsSync(ENV_PATH)) {
87
+ copyFileSync(ENV_PATH, ENV_BACKUP_PATH)
88
+ }
89
+
90
+ // read current content
91
+ const currentContent = readFileSync(ENV_PATH, 'utf-8')
92
+
93
+ // find existing auto-generated section
94
+ const beginIndex = currentContent.indexOf(BEGIN_MARKER)
95
+ const endIndex = currentContent.indexOf(END_MARKER)
96
+
97
+ let newContent: string
98
+
99
+ if (beginIndex !== -1 && endIndex !== -1 && endIndex > beginIndex) {
100
+ // replace existing auto-generated section
101
+ const beforeSection = currentContent.substring(0, beginIndex).trimEnd()
102
+ const afterSection = currentContent
103
+ .substring(endIndex + END_MARKER.length)
104
+ .trimStart()
105
+
106
+ newContent = [beforeSection, getAutoGeneratedContent(), afterSection]
107
+ .filter(Boolean)
108
+ .join('\n\n')
109
+ } else if (beginIndex !== -1 || endIndex !== -1) {
110
+ // malformed markers - preserve content and append new section
111
+ console.warn('Found malformed auto-generated section, appending new section')
112
+ newContent = currentContent.trimEnd() + '\n\n' + getAutoGeneratedContent()
113
+ } else {
114
+ // no existing section - append to end
115
+ const trimmedContent = currentContent.trimEnd()
116
+ newContent = trimmedContent
117
+ ? trimmedContent + '\n\n' + getAutoGeneratedContent()
118
+ : getAutoGeneratedContent()
119
+ }
120
+
121
+ // write to temp file first (atomic operation)
122
+ writeFileSync(ENV_TEMP_PATH, newContent)
123
+
124
+ // validate temp file
125
+ const tempContent = readFileSync(ENV_TEMP_PATH, 'utf-8')
126
+ if (!tempContent.includes(BEGIN_MARKER) || !tempContent.includes(END_MARKER)) {
127
+ throw new Error('Generated content validation failed')
128
+ }
129
+
130
+ // atomic replace
131
+ renameSync(ENV_TEMP_PATH, ENV_PATH)
132
+
133
+ if (existsSync(ENV_BACKUP_PATH)) {
134
+ try {
135
+ unlinkSync(ENV_BACKUP_PATH)
136
+ } catch {
137
+ // ignore cleanup errors
138
+ }
139
+ }
140
+
141
+ console.info('Updated .env auto-generated section')
142
+ } catch (error) {
143
+ console.error('Failed to update .env file:', error)
144
+
145
+ // attempt to restore backup
146
+ if (existsSync(ENV_BACKUP_PATH)) {
147
+ try {
148
+ copyFileSync(ENV_BACKUP_PATH, ENV_PATH)
149
+ console.info('Restored .env from backup')
150
+ } catch (restoreError) {
151
+ console.error('Failed to restore backup:', restoreError)
152
+ }
153
+ }
154
+
155
+ // clean up temp file
156
+ if (existsSync(ENV_TEMP_PATH)) {
157
+ try {
158
+ unlinkSync(ENV_TEMP_PATH)
159
+ } catch {
160
+ // ignore cleanup errors
161
+ }
162
+ }
163
+
164
+ // don't fail the install process
165
+ process.exit(0)
166
+ }
167
+ }
168
+
169
+ // main execution
170
+ // skip bootstrap in CI environments
171
+ if (process.env.CI === 'true') {
172
+ console.info('Skipping bootstrap in CI environment')
173
+ process.exit(0)
174
+ }
175
+
176
+ try {
177
+ updateEnvFile()
178
+ } catch (error) {
179
+ // catch any unexpected errors and exit gracefully
180
+ console.error('Bootstrap script error:', error)
181
+ process.exit(0)
182
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { $ } from 'bun'
4
+ import { globSync } from 'glob'
5
+ import { basename } from 'node:path'
6
+
7
+ const projectRoot = process.cwd()
8
+
9
+ // parse --ignore flag
10
+ const args = process.argv.slice(2)
11
+ const ignoreIndex = args.indexOf('--ignore')
12
+ const ignoredFiles: string[] = []
13
+
14
+ if (ignoreIndex !== -1 && args[ignoreIndex + 1]) {
15
+ // split comma-separated files
16
+ const ignoreArg = args[ignoreIndex + 1]
17
+ if (ignoreArg) {
18
+ const ignoreList = ignoreArg.split(',')
19
+ ignoredFiles.push(...ignoreList.map((f) => f.trim()))
20
+ }
21
+ }
22
+
23
+ // glob all tsx files in app directory
24
+ const appFiles = globSync('app/**/*.tsx', {
25
+ cwd: projectRoot,
26
+ absolute: true,
27
+ })
28
+
29
+ // glob all tsx files in src directory
30
+ const srcFiles = globSync('src/**/*.tsx', {
31
+ cwd: projectRoot,
32
+ absolute: true,
33
+ })
34
+
35
+ // combine all files
36
+ const allFiles = [...appFiles, ...srcFiles]
37
+
38
+ if (allFiles.length === 0) {
39
+ console.error('No tsx files found')
40
+ process.exit(1)
41
+ }
42
+
43
+ // build the command with all -r flags
44
+ const commandArgs = [
45
+ 'bunx',
46
+ '@glideapps/ts-helper',
47
+ '-c', // check circular dependencies
48
+ '-p',
49
+ projectRoot,
50
+ ...allFiles.flatMap((file) => ['-r', file]),
51
+ ]
52
+
53
+ console.info(`Checking circular dependencies for ${allFiles.length} files...`)
54
+
55
+ try {
56
+ // run the command and capture output
57
+ await $`${commandArgs}`.quiet()
58
+ console.info('✅ No circular dependencies found')
59
+ } catch (error: any) {
60
+ // parse the output to check for cycles
61
+ const output = error.stderr?.toString() || error.stdout?.toString() || ''
62
+
63
+ // parse cycle arrays from output
64
+ const cycleRegex = /\[([^\]]+)\]/g
65
+ const cycles: string[][] = []
66
+ let match
67
+
68
+ while ((match = cycleRegex.exec(output)) !== null) {
69
+ // parse the cycle array
70
+ const cycleStr = match[1]
71
+ if (cycleStr) {
72
+ const files = cycleStr.split(',').map((f) => f.trim().replace(/"/g, ''))
73
+ cycles.push(files)
74
+ }
75
+ }
76
+
77
+ // filter out cycles that contain ignored files
78
+ const remainingCycles = cycles.filter((cycle) => {
79
+ const containsIgnored = cycle.some((file) =>
80
+ ignoredFiles.some((ignored) => basename(file) === ignored || file.includes(ignored))
81
+ )
82
+ return !containsIgnored
83
+ })
84
+
85
+ if (remainingCycles.length > 0) {
86
+ // reconstruct output with filtered cycles
87
+ console.info(
88
+ output
89
+ .split('\n')
90
+ .filter((line: string) => !line.startsWith('['))
91
+ .join('\n')
92
+ )
93
+ console.info(`Found ${remainingCycles.length} dependency cycles`)
94
+ remainingCycles.forEach((cycle) => {
95
+ console.info(JSON.stringify(cycle))
96
+ })
97
+ console.error('❌ Circular dependencies detected')
98
+ process.exit(1)
99
+ } else if (cycles.length > 0) {
100
+ // all cycles were ignored
101
+ console.info(
102
+ `✅ Found ${cycles.length} circular dependencies but all contain ignored files`
103
+ )
104
+ if (ignoredFiles.length > 0) {
105
+ console.info(` Ignored files: ${ignoredFiles.join(', ')}`)
106
+ }
107
+ } else {
108
+ // no cycles found, but command failed for another reason
109
+ console.error(output)
110
+ console.error('❌ Error running circular dependency check')
111
+ process.exit(1)
112
+ }
113
+ }
package/src/clean.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * @description Clean build artifacts and temporary files
5
+ */
6
+
7
+ import { execSync } from 'node:child_process'
8
+
9
+ try {
10
+ execSync('rm -rf dist types .tamagui .vite node_modules/.cache', { stdio: 'inherit' })
11
+ console.info('Cleanup complete!')
12
+ } catch (error) {
13
+ console.error('Error cleaning up')
14
+ throw error
15
+ }
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ const TUNNEL_CONFIG_DIR = join(homedir(), '.onechat-tunnel')
9
+ const TUNNEL_ID_FILE = join(TUNNEL_CONFIG_DIR, 'tunnel-id.txt')
10
+ const TUNNEL_CONFIG_FILE = join(TUNNEL_CONFIG_DIR, 'config.yml')
11
+
12
+ // check internet connectivity first
13
+ async function checkInternetConnection(): Promise<boolean> {
14
+ return new Promise((resolve) => {
15
+ // try to ping cloudflare dns
16
+ const pingProcess = spawn('ping', ['-c', '1', '-W', '2', '1.1.1.1'], {
17
+ stdio: 'pipe',
18
+ })
19
+
20
+ pingProcess.on('error', () => {
21
+ resolve(false)
22
+ })
23
+
24
+ pingProcess.on('exit', (code) => {
25
+ resolve(code === 0)
26
+ })
27
+ })
28
+ }
29
+
30
+ // check connectivity before proceeding
31
+ const isOnline = await checkInternetConnection()
32
+ if (!isOnline) {
33
+ console.info('📵 Offline - skipping tunnel setup')
34
+ // Keep process alive for watch mode
35
+ await new Promise(() => {})
36
+ }
37
+
38
+ // ensure config dir exists
39
+ if (!existsSync(TUNNEL_CONFIG_DIR)) {
40
+ mkdirSync(TUNNEL_CONFIG_DIR, { recursive: true })
41
+ }
42
+
43
+ // check if cloudflared is authenticated
44
+ const certPath = join(homedir(), '.cloudflared', 'cert.pem')
45
+ const hasCloudflaredAuth = existsSync(certPath)
46
+
47
+ if (!hasCloudflaredAuth) {
48
+ console.info('☁️ Tunnel not set up. Run "bun dev:tunnel" to enable.')
49
+ // Keep process alive for watch mode
50
+ await new Promise(() => {})
51
+ }
52
+
53
+ // check if we have a tunnel id
54
+ const hasTunnelId = existsSync(TUNNEL_ID_FILE)
55
+ if (!hasTunnelId) {
56
+ console.info('☁️ No tunnel created. Run "bun dev:tunnel" once to set up.')
57
+ // Keep process alive for watch mode
58
+ await new Promise(() => {})
59
+ }
60
+
61
+ // check if cloudflared is installed
62
+ const checkProcess = spawn('which', ['cloudflared'], {
63
+ shell: true,
64
+ stdio: 'pipe',
65
+ })
66
+
67
+ checkProcess.on('error', () => {
68
+ console.warn('⚠️ cloudflared not found - skipping tunnel setup')
69
+ // Keep process alive for watch mode
70
+ new Promise(() => {})
71
+ })
72
+
73
+ checkProcess.on('exit', async (code) => {
74
+ if (code === 0) {
75
+ const tunnelId = readFileSync(TUNNEL_ID_FILE, 'utf-8').trim()
76
+ const tunnelName = 'onechat-dev-n8' // your tunnel name
77
+ console.info(`🚇 Starting tunnel ${tunnelId}...`)
78
+
79
+ // The stable URL format for your domain
80
+ const tunnelDomain = `${tunnelName}.start.chat`
81
+
82
+ // Create config file with ingress rules
83
+ const tunnelConfig = `
84
+ tunnel: ${tunnelId}
85
+ credentials-file: ${join(homedir(), '.cloudflared', `${tunnelId}.json`)}
86
+
87
+ ingress:
88
+ - hostname: ${tunnelDomain}
89
+ service: http://localhost:8081
90
+ - service: http_status:404
91
+ `
92
+ writeFileSync(TUNNEL_CONFIG_FILE, tunnelConfig)
93
+
94
+ // Set up DNS route if needed (this is idempotent, safe to run multiple times)
95
+ const routeProcess = spawn(
96
+ 'cloudflared',
97
+ ['tunnel', 'route', 'dns', tunnelId, tunnelDomain],
98
+ {
99
+ stdio: 'pipe',
100
+ shell: true,
101
+ }
102
+ )
103
+
104
+ await new Promise((resolve) => {
105
+ routeProcess.on('exit', resolve)
106
+ })
107
+
108
+ const tunnelUrl = `https://${tunnelDomain}`
109
+ const tunnelUrlFile = join(TUNNEL_CONFIG_DIR, 'tunnel-url.txt')
110
+ writeFileSync(tunnelUrlFile, tunnelUrl)
111
+ console.info(`🌐 Tunnel URL: ${tunnelUrl}`)
112
+
113
+ // Run tunnel with config file
114
+ const tunnelProcess = spawn(
115
+ 'cloudflared',
116
+ ['tunnel', '--config', TUNNEL_CONFIG_FILE, 'run'],
117
+ {
118
+ stdio: ['inherit', 'pipe', 'pipe'],
119
+ shell: true,
120
+ }
121
+ )
122
+
123
+ // Track if we've shown the connection message
124
+ let hasShownConnected = false
125
+
126
+ // Process stdout to filter logs
127
+ tunnelProcess.stdout?.on('data', (data) => {
128
+ const output = data.toString()
129
+
130
+ // Only show first successful connection
131
+ if (!hasShownConnected && output.includes('Registered tunnel connection')) {
132
+ console.info('✅ Tunnel connected')
133
+ hasShownConnected = true
134
+ }
135
+ })
136
+
137
+ // Process stderr (where cloudflared logs go)
138
+ tunnelProcess.stderr?.on('data', (data) => {
139
+ const output = data.toString()
140
+
141
+ // Only show first successful connection
142
+ if (!hasShownConnected && output.includes('Registered tunnel connection')) {
143
+ console.info('✅ Tunnel connected')
144
+ hasShownConnected = true
145
+ }
146
+ })
147
+
148
+ // handle process termination
149
+ process.on('SIGINT', () => {
150
+ tunnelProcess.kill()
151
+ process.exit(0)
152
+ })
153
+
154
+ tunnelProcess.on('exit', (code) => {
155
+ if (code !== 0) {
156
+ console.warn(`⚠️ Tunnel process exited with code ${code}`)
157
+ }
158
+ // Keep process alive for watch mode
159
+ new Promise(() => {})
160
+ })
161
+ } else {
162
+ console.warn('⚠️ cloudflared not found - skipping tunnel setup')
163
+ // Keep process alive for watch mode
164
+ await new Promise(() => {})
165
+ }
166
+ })
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { handleProcessExit } from './helpers/handleProcessExit'
7
+ import { run } from './helpers/run'
8
+
9
+ handleProcessExit()
10
+
11
+ const TUNNEL_CONFIG_DIR = join(homedir(), '.onechat-tunnel')
12
+ const TUNNEL_ID_FILE = join(TUNNEL_CONFIG_DIR, 'tunnel-id.txt')
13
+
14
+ async function ensureCloudflared(): Promise<boolean> {
15
+ try {
16
+ // check if cloudflared is installed
17
+ await run('cloudflared --version', { silent: true })
18
+ return true
19
+ } catch {
20
+ // install cloudflared using npm
21
+ try {
22
+ await run('npm install -g cloudflared')
23
+ return true
24
+ } catch (error) {
25
+ console.error('Error installing cloudflared:', error)
26
+ return false
27
+ }
28
+ }
29
+ }
30
+
31
+ async function ensureAuthenticated(): Promise<boolean> {
32
+ // check if we have credentials
33
+ const certPath = join(homedir(), '.cloudflared', 'cert.pem')
34
+ if (existsSync(certPath)) {
35
+ return true
36
+ }
37
+
38
+ try {
39
+ await run('cloudflared tunnel login')
40
+ return true
41
+ } catch {
42
+ console.error('\n❌ Authentication failed')
43
+ return false
44
+ }
45
+ }
46
+
47
+ async function getOrCreateTunnel(): Promise<string | null> {
48
+ // create config directory if it doesn't exist
49
+ if (!existsSync(TUNNEL_CONFIG_DIR)) {
50
+ mkdirSync(TUNNEL_CONFIG_DIR, { recursive: true })
51
+ }
52
+
53
+ // check if we have a saved tunnel ID
54
+ if (existsSync(TUNNEL_ID_FILE)) {
55
+ const tunnelId = readFileSync(TUNNEL_ID_FILE, 'utf-8').trim()
56
+ return tunnelId
57
+ }
58
+
59
+ // create a new tunnel with a stable name
60
+ const tunnelName = `onechat-dev-${process.env.USER || 'tunnel'}`
61
+
62
+ try {
63
+ const { stdout, stderr } = await run(`cloudflared tunnel create ${tunnelName}`, {
64
+ captureOutput: true,
65
+ })
66
+ const output = stdout + stderr
67
+
68
+ // extract tunnel ID from output - try multiple patterns
69
+ const match1 = output.match(/Created tunnel .+ with id ([a-f0-9-]+)/i)
70
+ const match2 = output.match(/Tunnel ([a-f0-9-]+) created/i)
71
+ const match3 = output.match(
72
+ /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i
73
+ )
74
+
75
+ const tunnelId = match1?.[1] || match2?.[1] || match3?.[1]
76
+
77
+ if (tunnelId) {
78
+ writeFileSync(TUNNEL_ID_FILE, tunnelId)
79
+ return tunnelId
80
+ }
81
+
82
+ console.error('Failed to extract tunnel ID from output')
83
+ console.error('Output was:', output)
84
+ return null
85
+ } catch (error: any) {
86
+ // check if tunnel already exists
87
+ if (error.message?.includes('already exists')) {
88
+ try {
89
+ const { stdout } = await run(
90
+ `cloudflared tunnel list --name ${tunnelName} --output json`,
91
+ { captureOutput: true }
92
+ )
93
+ const tunnels = JSON.parse(stdout)
94
+ if (tunnels.length > 0) {
95
+ const tunnelId = tunnels[0].id
96
+ writeFileSync(TUNNEL_ID_FILE, tunnelId)
97
+ return tunnelId
98
+ }
99
+ } catch (e) {
100
+ console.error('Failed to parse tunnel list:', e)
101
+ }
102
+ } else {
103
+ console.error('Failed to create tunnel:', error)
104
+ }
105
+ return null
106
+ }
107
+ }
108
+
109
+ async function runTunnel(port: number = 8081) {
110
+ // ensure cloudflared is installed
111
+ const isInstalled = await ensureCloudflared()
112
+ if (!isInstalled) {
113
+ console.error('Failed to install cloudflared')
114
+ process.exit(1)
115
+ }
116
+
117
+ // ensure authenticated
118
+ const isAuthenticated = await ensureAuthenticated()
119
+ if (!isAuthenticated) {
120
+ console.error('Failed to authenticate with Cloudflare')
121
+ process.exit(1)
122
+ }
123
+
124
+ // get or create tunnel
125
+ const tunnelId = await getOrCreateTunnel()
126
+ if (!tunnelId) {
127
+ console.error('Failed to get or create tunnel')
128
+ process.exit(1)
129
+ }
130
+
131
+ // save the expected URL immediately so it's available right away
132
+ const expectedUrl = `https://${tunnelId}.cfargotunnel.com`
133
+ writeFileSync(join(TUNNEL_CONFIG_DIR, 'tunnel-url.txt'), expectedUrl)
134
+ console.info(`\n🌐 Tunnel URL: ${expectedUrl}`)
135
+
136
+ // get the public URL in the background
137
+ setTimeout(async () => {
138
+ try {
139
+ const { stdout } = await run(`cloudflared tunnel info ${tunnelId} --output json`, {
140
+ captureOutput: true,
141
+ silent: true,
142
+ })
143
+
144
+ try {
145
+ const info = JSON.parse(stdout)
146
+ const hostname = info.hostname || `${tunnelId}.cfargotunnel.com`
147
+ writeFileSync(join(TUNNEL_CONFIG_DIR, 'tunnel-url.txt'), `https://${hostname}`)
148
+ } catch (e) {
149
+ // use fallback URL already saved
150
+ }
151
+ } catch (e) {
152
+ // use fallback URL already saved
153
+ }
154
+ }, 3000)
155
+
156
+ // run tunnel in detached mode so it keeps running and gets managed by handleProcessExit
157
+ await run(`cloudflared tunnel run --url http://localhost:${port} ${tunnelId}`, {
158
+ detached: false,
159
+ })
160
+ }
161
+
162
+ // Parse command line arguments
163
+ const args = process.argv.slice(2)
164
+ let port = 8081
165
+
166
+ for (let i = 0; i < args.length; i++) {
167
+ if (args[i] === '--port' && args[i + 1]) {
168
+ const portArg = args[i + 1]
169
+ if (portArg) {
170
+ const parsedPort = parseInt(portArg, 10)
171
+ if (!Number.isNaN(parsedPort)) {
172
+ port = parsedPort
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ runTunnel(port)