From 6ba3cdaf141d60fcaed5b5c291c584674678d1a8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 15 Feb 2026 10:33:03 -0800 Subject: [PATCH] share CLI, persistent tunnels --- src/cli/commands/index.ts | 2 ++ src/cli/commands/manage.ts | 31 +++++++++++++++++++++++++++++++ src/cli/setup.ts | 16 ++++++++++++++++ src/server/apps.ts | 5 +---- src/server/tunnels.ts | 32 +++++++++++++++++++++++++++++--- 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 4a434d4..f546c94 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -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' diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts index 51dd8e0..87042ac 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -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 diff --git a/src/cli/setup.ts b/src/cli/setup.ts index ead335d..643cc71 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -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:') diff --git a/src/server/apps.ts b/src/server/apps.ts index e50aa32..2b5479e 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -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) diff --git a/src/server/tunnels.ts b/src/server/tunnels.ts index 7b6f983..5160f6e 100644 --- a/src/server/tunnels.ts +++ b/src/server/tunnels.ts @@ -9,7 +9,9 @@ const SNEAKER_URL = process.env.SNEAKER_URL ?? 'https://toes.space' type TunnelConfig = Record +const _closing = new Set() const _tunnels = new Map() +const _tunnelPorts = new Map() 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) }