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, renameApp,
restartApp, restartApp,
rmApp, rmApp,
shareApp,
startApp, startApp,
stopApp, stopApp,
unshareApp,
} from './manage' } from './manage'
export { metricsApp } from './metrics' export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync' 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]) 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) { export async function renameApp(arg: string | undefined, newName: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return 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) { export async function stopApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return

View File

@ -33,9 +33,11 @@ import {
stashPopApp, stashPopApp,
startApp, startApp,
metricsApp, metricsApp,
shareApp,
statusApp, statusApp,
stopApp, stopApp,
syncApp, syncApp,
unshareApp,
versionsApp, versionsApp,
} from './commands' } from './commands'
@ -140,6 +142,20 @@ program
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(restartApp) .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 program
.command('logs') .command('logs')
.helpGroup('Lifecycle:') .helpGroup('Lifecycle:')

View File

@ -6,7 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realp
import { hostname } from 'os' import { hostname } from 'os'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env' 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' import { appLog, hostLog, setApps } from './tui'
export type { AppState } from '@types' export type { AppState } from '@types'
@ -418,7 +418,6 @@ function getPort(appName?: string): number {
async function gracefulShutdown(signal: string) { async function gracefulShutdown(signal: string) {
if (_shuttingDown) return if (_shuttingDown) return
_shuttingDown = true _shuttingDown = true
closeAllTunnels()
hostLog(`Received ${signal}, shutting down gracefully...`) hostLog(`Received ${signal}, shutting down gracefully...`)
@ -735,8 +734,6 @@ async function runApp(dir: string, port: number) {
writeLogLine(dir, 'system', 'Stopped') writeLogLine(dir, 'system', 'Stopped')
} }
closeTunnel(dir)
// Release port back to pool // Release port back to pool
if (app.port) { if (app.port) {
releasePort(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 }> type TunnelConfig = Record<string, { subdomain?: string }>
const _closing = new Set<string>()
const _tunnels = new Map<string, Tunnel>() const _tunnels = new Map<string, Tunnel>()
const _tunnelPorts = new Map<string, number>()
const configPath = () => const configPath = () =>
join(TOES_DIR, 'tunnels.json') join(TOES_DIR, 'tunnels.json')
@ -45,18 +47,22 @@ export const isTunnelsAvailable = (): boolean =>
!!SNEAKER_URL !!SNEAKER_URL
export function closeAllTunnels() { export function closeAllTunnels() {
for (const [, tunnel] of _tunnels) { for (const [name, tunnel] of _tunnels) {
_closing.add(name)
tunnel.close() tunnel.close()
} }
_tunnels.clear() _tunnels.clear()
_tunnelPorts.clear()
} }
export function closeTunnel(appName: string) { export function closeTunnel(appName: string) {
const tunnel = _tunnels.get(appName) const tunnel = _tunnels.get(appName)
if (!tunnel) return if (!tunnel) return
_closing.add(appName)
tunnel.close() tunnel.close()
_tunnels.delete(appName) _tunnels.delete(appName)
_tunnelPorts.delete(appName)
const app = getApp(appName) const app = getApp(appName)
if (app) { if (app) {
@ -112,6 +118,9 @@ export function openTunnelIfEnabled(appName: string, port: number) {
update() 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) openTunnel(appName, port, config[appName]?.subdomain)
} }
@ -128,6 +137,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
// Close existing tunnel if any // Close existing tunnel if any
const existing = _tunnels.get(appName) const existing = _tunnels.get(appName)
if (existing) { if (existing) {
_closing.add(appName)
existing.close() existing.close()
_tunnels.delete(appName) _tunnels.delete(appName)
} }
@ -158,15 +168,30 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
}, },
onClose() { onClose() {
hostLog(`Tunnel closed: ${appName}`)
_tunnels.delete(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) const app = getApp(appName)
if (app) { if (app) {
app.tunnelEnabled = false
app.tunnelUrl = undefined app.tunnelUrl = undefined
update() 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) { onError(error) {
@ -175,4 +200,5 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
}) })
_tunnels.set(appName, tunnel) _tunnels.set(appName, tunnel)
_tunnelPorts.set(appName, port)
} }