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