share CLI, persistent tunnels

This commit is contained in:
Chris Wanstrath 2026-02-15 10:33:03 -08:00
parent 7f2343fc04
commit 6ba3cdaf14
5 changed files with 79 additions and 7 deletions

View File

@ -10,8 +10,10 @@ export {
renameApp,
restartApp,
rmApp,
shareApp,
startApp,
stopApp,
unshareApp,
} from './manage'
export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -211,6 +211,29 @@ export async function openApp(arg?: string) {
Bun.spawn(['open', url])
}
export async function shareApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${name}/tunnel`)
if (!result) return
if (!result.ok) {
console.error(color.red(result.error ?? 'Failed to share'))
return
}
process.stdout.write(`${color.cyan('↗')} Sharing ${color.bold(name)}...`)
// Poll until tunnelUrl appears
const start = Date.now()
while (Date.now() - start < 15000) {
await sleep(500)
const app: App | undefined = await get(`/api/apps/${name}`)
if (app?.tunnelUrl) {
console.log(` ${color.cyan(app.tunnelUrl)}`)
return
}
}
console.log(` ${color.yellow('enabled (URL pending)')}`)
}
export async function renameApp(arg: string | undefined, newName: string) {
const name = resolveAppName(arg)
if (!name) return
@ -296,6 +319,14 @@ export async function startApp(arg?: string) {
}
}
export async function unshareApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const result = await del(`/api/apps/${name}/tunnel`)
if (!result) return
console.log(`${color.gray('↗')} Unshared ${color.bold(name)}`)
}
export async function stopApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return

View File

@ -33,9 +33,11 @@ import {
stashPopApp,
startApp,
metricsApp,
shareApp,
statusApp,
stopApp,
syncApp,
unshareApp,
versionsApp,
} from './commands'
@ -140,6 +142,20 @@ program
.argument('[name]', 'app name (uses current directory if omitted)')
.action(restartApp)
program
.command('share')
.helpGroup('Lifecycle:')
.description('Share an app via public tunnel')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(shareApp)
program
.command('unshare')
.helpGroup('Lifecycle:')
.description('Stop sharing an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(unshareApp)
program
.command('logs')
.helpGroup('Lifecycle:')

View File

@ -6,7 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realp
import { hostname } from 'os'
import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env'
import { closeAllTunnels, closeTunnel, disableTunnel, openTunnelIfEnabled, renameTunnelConfig } from './tunnels'
import { disableTunnel, openTunnelIfEnabled, renameTunnelConfig } from './tunnels'
import { appLog, hostLog, setApps } from './tui'
export type { AppState } from '@types'
@ -418,7 +418,6 @@ function getPort(appName?: string): number {
async function gracefulShutdown(signal: string) {
if (_shuttingDown) return
_shuttingDown = true
closeAllTunnels()
hostLog(`Received ${signal}, shutting down gracefully...`)
@ -735,8 +734,6 @@ async function runApp(dir: string, port: number) {
writeLogLine(dir, 'system', 'Stopped')
}
closeTunnel(dir)
// Release port back to pool
if (app.port) {
releasePort(app.port)

View File

@ -9,7 +9,9 @@ const SNEAKER_URL = process.env.SNEAKER_URL ?? 'https://toes.space'
type TunnelConfig = Record<string, { subdomain?: string }>
const _closing = new Set<string>()
const _tunnels = new Map<string, Tunnel>()
const _tunnelPorts = new Map<string, number>()
const configPath = () =>
join(TOES_DIR, 'tunnels.json')
@ -45,18 +47,22 @@ export const isTunnelsAvailable = (): boolean =>
!!SNEAKER_URL
export function closeAllTunnels() {
for (const [, tunnel] of _tunnels) {
for (const [name, tunnel] of _tunnels) {
_closing.add(name)
tunnel.close()
}
_tunnels.clear()
_tunnelPorts.clear()
}
export function closeTunnel(appName: string) {
const tunnel = _tunnels.get(appName)
if (!tunnel) return
_closing.add(appName)
tunnel.close()
_tunnels.delete(appName)
_tunnelPorts.delete(appName)
const app = getApp(appName)
if (app) {
@ -112,6 +118,9 @@ export function openTunnelIfEnabled(appName: string, port: number) {
update()
}
// Skip if tunnel is already open on the same port
if (_tunnels.has(appName) && _tunnelPorts.get(appName) === port) return
openTunnel(appName, port, config[appName]?.subdomain)
}
@ -128,6 +137,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
// Close existing tunnel if any
const existing = _tunnels.get(appName)
if (existing) {
_closing.add(appName)
existing.close()
_tunnels.delete(appName)
}
@ -158,15 +168,30 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
},
onClose() {
hostLog(`Tunnel closed: ${appName}`)
_tunnels.delete(appName)
_tunnelPorts.delete(appName)
// Intentional close (disableTunnel, closeAllTunnels, etc.) — don't reconnect
if (_closing.delete(appName)) {
hostLog(`Tunnel closed: ${appName}`)
return
}
// Unexpected close — try to reconnect
hostLog(`Tunnel dropped: ${appName}, reconnecting in 5s...`)
const app = getApp(appName)
if (app) {
app.tunnelEnabled = false
app.tunnelUrl = undefined
update()
}
setTimeout(() => {
// Only reconnect if still enabled in config
const config = loadConfig()
if (!config[appName]) return
hostLog(`Tunnel reconnecting: ${appName}`)
openTunnel(appName, port, config[appName]?.subdomain)
}, 5000)
},
onError(error) {
@ -175,4 +200,5 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
})
_tunnels.set(appName, tunnel)
_tunnelPorts.set(appName, port)
}