share CLI, persistent tunnels
This commit is contained in:
parent
7f2343fc04
commit
6ba3cdaf14
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user