versions, rollback, clean

This commit is contained in:
Chris Wanstrath 2026-01-30 22:13:34 -08:00
parent 68ebf1d7a7
commit a56af4ed47
16 changed files with 681 additions and 40 deletions

View File

@ -47,5 +47,9 @@
[x] list projects
[x] start/stop/restart project
[x] create project
[ ] todo.txt
[x] todo.txt
[x] tools
[x] code browser
[x] versioned pushes
[x] version browser
[ ] ...

View File

@ -6,7 +6,7 @@
"name": "toes",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
"@because/hype": "^0.0.2",
"commander": "^14.0.2",
"diff": "^8.0.3",
"kleur": "^4.1.5",
@ -23,7 +23,7 @@
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],

View File

@ -4,7 +4,9 @@
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
"files": ["src"],
"files": [
"src"
],
"exports": {
".": "./src/index.ts",
"./tools": "./src/tools/index.ts"
@ -36,9 +38,9 @@
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
"@because/hype": "^0.0.2",
"commander": "^14.0.2",
"diff": "^8.0.3",
"kleur": "^4.1.5"
}
}
}

View File

@ -11,4 +11,4 @@ export {
startApp,
stopApp,
} from './manage'
export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } from './sync'
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -53,7 +53,7 @@ export async function infoApp(arg?: string) {
}
const icon = STATE_ICONS[app.state] ?? '◯'
console.log(`${icon} ${color.bold(app.name)} ${app.tool && '[tool]'}`)
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
console.log(` State: ${app.state}`)
if (app.port) {
console.log(` Port: ${app.port}`)

View File

@ -3,11 +3,11 @@ import { loadGitignore } from '@gitignore'
import { computeHash, generateManifest } from '%sync'
import color from 'kleur'
import { diffLines } from 'diff'
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
import { confirm } from '../prompts'
import { getAppName, isApp } from '../name'
import { confirm, prompt } from '../prompts'
import { getAppName, isApp, resolveAppName } from '../name'
interface ManifestDiff {
changed: string[]
@ -236,7 +236,7 @@ export async function diffApp() {
const { changed, localOnly, remoteOnly } = diff
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
console.log(color.green('✓ No differences'))
// console.log(color.green('✓ No differences'))
return
}
@ -329,7 +329,7 @@ export async function statusApp() {
const hasLocalChanges = toPush.length > 0
// Display status
console.log(`Status for ${color.bold(appName)}:\n`)
// console.log(`Status for ${color.bold(appName)}:\n`)
if (!remoteManifest) {
console.log(color.yellow('App does not exist on server'))
@ -471,6 +471,364 @@ export async function syncApp() {
}
}
export async function versionsApp(name?: string) {
const appName = resolveAppName(name)
if (!appName) return
const result = await getVersions(appName)
if (!result) return
if (result.versions.length === 0) {
console.log(`No versions found for ${color.bold(appName)}`)
return
}
console.log(`Versions for ${color.bold(appName)}:\n`)
for (const version of result.versions) {
const isCurrent = version === result.current
const date = formatVersion(version)
if (isCurrent) {
console.log(` ${color.green('→')} ${color.bold(version)} ${color.gray(date)} ${color.green('(current)')}`)
} else {
console.log(` ${version} ${color.gray(date)}`)
}
}
console.log()
}
export async function rollbackApp(name?: string, version?: string) {
const appName = resolveAppName(name)
if (!appName) return
// Get available versions
const result = await getVersions(appName)
if (!result) return
if (result.versions.length === 0) {
console.error(`No versions found for ${color.bold(appName)}`)
return
}
// Filter out current version for rollback candidates
const candidates = result.versions.filter(v => v !== result.current)
if (candidates.length === 0) {
console.error('No previous versions to rollback to')
return
}
let targetVersion: string | undefined = version
if (!targetVersion) {
// Show available versions and prompt
console.log(`Available versions for ${color.bold(appName)}:\n`)
for (let i = 0; i < candidates.length; i++) {
const v = candidates[i]!
const date = formatVersion(v)
console.log(` ${color.cyan(String(i + 1))}. ${v} ${color.gray(date)}`)
}
console.log()
const answer = await prompt('Enter version number or name: ')
// Check if it's a number (index) or version name
const index = parseInt(answer, 10)
if (!isNaN(index) && index >= 1 && index <= candidates.length) {
targetVersion = candidates[index - 1]!
} else if (candidates.includes(answer)) {
targetVersion = answer
} else {
console.error('Invalid selection')
return
}
}
// Validate version exists (handles both user-provided and selected versions)
if (!targetVersion || !result.versions.includes(targetVersion)) {
console.error(`Version ${color.bold(targetVersion ?? 'unknown')} not found`)
console.error(`Available versions: ${result.versions.join(', ')}`)
return
}
if (targetVersion === result.current) {
console.log(`Version ${color.bold(targetVersion)} is already active`)
return
}
const ok = await confirm(`Rollback ${color.bold(appName)} to version ${color.bold(targetVersion)}?`)
if (!ok) return
console.log(`Rolling back to ${color.bold(targetVersion)}...`)
type ActivateResponse = { ok: boolean }
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${targetVersion}`)
if (!activateRes?.ok) {
console.error('Failed to activate version')
return
}
console.log(color.green(`✓ Rolled back to version ${targetVersion}`))
}
const STASH_BASE = '/tmp/toes-stash'
export async function stashApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const diff = await getManifestDiff(appName)
if (diff === null) {
return
}
const { changed, localOnly } = diff
const toStash = [...changed, ...localOnly]
if (toStash.length === 0) {
console.log('No local changes to stash')
return
}
const stashDir = join(STASH_BASE, appName)
// Check if stash already exists
if (existsSync(stashDir)) {
console.error('Stash already exists. Use `toes stash-pop` first.')
return
}
mkdirSync(stashDir, { recursive: true })
// Save stash metadata
const metadata = {
app: appName,
timestamp: new Date().toISOString(),
files: toStash,
changed,
localOnly,
}
writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2))
// Copy files to stash
for (const file of toStash) {
const srcPath = join(process.cwd(), file)
const destPath = join(stashDir, 'files', file)
const destDir = dirname(destPath)
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true })
}
const content = readFileSync(srcPath)
writeFileSync(destPath, content)
console.log(` ${color.yellow('→')} ${file}`)
}
// Restore changed files from server
if (changed.length > 0) {
console.log(`\nRestoring ${changed.length} changed files from server...`)
for (const file of changed) {
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
if (content) {
writeFileSync(join(process.cwd(), file), content)
}
}
}
// Delete local-only files
if (localOnly.length > 0) {
console.log(`Removing ${localOnly.length} local-only files...`)
for (const file of localOnly) {
unlinkSync(join(process.cwd(), file))
}
}
console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`))
}
export async function stashListApp() {
if (!existsSync(STASH_BASE)) {
console.log('No stashes')
return
}
const entries = readdirSync(STASH_BASE, { withFileTypes: true })
const stashes = entries.filter(e => e.isDirectory())
if (stashes.length === 0) {
console.log('No stashes')
return
}
console.log('Stashes:\n')
for (const stash of stashes) {
const metadataPath = join(STASH_BASE, stash.name, 'metadata.json')
if (existsSync(metadataPath)) {
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as {
timestamp: string
files: string[]
}
const date = new Date(metadata.timestamp).toLocaleString()
console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} files)`)}`)
} else {
console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`)
}
}
console.log()
}
export async function stashPopApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const stashDir = join(STASH_BASE, appName)
if (!existsSync(stashDir)) {
console.error(`No stash found for ${color.bold(appName)}`)
return
}
// Read metadata
const metadataPath = join(stashDir, 'metadata.json')
if (!existsSync(metadataPath)) {
console.error('Invalid stash: missing metadata')
return
}
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as {
app: string
timestamp: string
files: string[]
changed: string[]
localOnly: string[]
}
console.log(`Restoring stash from ${new Date(metadata.timestamp).toLocaleString()}...\n`)
// Restore files from stash
for (const file of metadata.files) {
const srcPath = join(stashDir, 'files', file)
const destPath = join(process.cwd(), file)
const destDir = dirname(destPath)
if (!existsSync(srcPath)) {
console.log(` ${color.red('✗')} ${file} (missing from stash)`)
continue
}
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true })
}
const content = readFileSync(srcPath)
writeFileSync(destPath, content)
console.log(` ${color.green('←')} ${file}`)
}
// Remove stash directory
rmSync(stashDir, { recursive: true })
console.log(color.green(`\n✓ Restored ${metadata.files.length} file(s)`))
}
export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const diff = await getManifestDiff(appName)
if (diff === null) {
return
}
const { localOnly } = diff
if (localOnly.length === 0) {
console.log('Nothing to clean')
return
}
if (options.dryRun) {
console.log('Would remove:')
for (const file of localOnly) {
console.log(` ${color.red('✗')} ${file}`)
}
return
}
if (!options.force) {
console.log('Files not on server:')
for (const file of localOnly) {
console.log(` ${color.red('✗')} ${file}`)
}
console.log()
const ok = await confirm(`Remove ${localOnly.length} file(s)?`)
if (!ok) return
}
for (const file of localOnly) {
const fullPath = join(process.cwd(), file)
unlinkSync(fullPath)
console.log(` ${color.red('✗')} ${file}`)
}
console.log(color.green(`✓ Removed ${localOnly.length} file(s)`))
}
interface VersionsResponse {
current: string | null
versions: string[]
}
async function getVersions(appName: string): Promise<VersionsResponse | null> {
try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
if (res.status === 404) {
console.error(`App not found: ${appName}`)
return null
}
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return await res.json()
} catch (error) {
handleError(error)
return null
}
}
function formatVersion(version: string): string {
// Parse YYYYMMDD-HHMMSS format
const match = version.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/)
if (!match) return ''
const date = new Date(
parseInt(match[1]!, 10),
parseInt(match[2]!, 10) - 1,
parseInt(match[3]!, 10),
parseInt(match[4]!, 10),
parseInt(match[5]!, 10),
parseInt(match[6]!, 10)
)
return date.toLocaleString()
}
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
const localManifest = generateManifest(process.cwd(), appName)
const result = await getManifest(appName)

View File

@ -2,6 +2,7 @@ import { program } from 'commander'
import color from 'kleur'
import {
cleanApp,
configShow,
diffApp,
getApp,
@ -15,10 +16,15 @@ import {
renameApp,
restartApp,
rmApp,
rollbackApp,
stashApp,
stashListApp,
stashPopApp,
startApp,
statusApp,
stopApp,
syncApp,
versionsApp,
} from './commands'
program
@ -141,6 +147,41 @@ program
.description('Watch and sync changes bidirectionally')
.action(syncApp)
program
.command('clean')
.description('Remove local files not on server')
.option('-f, --force', 'skip confirmation')
.option('-n, --dry-run', 'show what would be removed')
.action(cleanApp)
const stash = program
.command('stash')
.description('Stash local changes')
.action(stashApp)
stash
.command('pop')
.description('Restore stashed changes')
.action(stashPopApp)
stash
.command('list')
.description('List all stashes')
.action(stashListApp)
program
.command('versions')
.description('List deployed versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(versionsApp)
program
.command('rollback')
.description('Rollback to a previous version')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
.action((name, options) => rollbackApp(name, options.version))
program
.command('rm')
.description('Remove an app from the server')

View File

@ -1,12 +1,17 @@
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
type TemplateType = 'ssr' | 'spa' | 'bare'
let newAppError = ''
let newAppCreating = false
let newAppError = ''
let newAppName = ''
let newAppTemplate: TemplateType = 'ssr'
let newAppTool = false
async function createNewApp(input: HTMLInputElement) {
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
async function createNewApp() {
const name = newAppName.trim().toLowerCase().replace(/\s+/g, '-')
if (!name) {
newAppError = 'App name is required'
@ -34,7 +39,7 @@ async function createNewApp(input: HTMLInputElement) {
const res = await fetch('/api/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, template: newAppTemplate, tool: newAppTool }),
})
const data = await res.json()
@ -54,14 +59,16 @@ async function createNewApp(input: HTMLInputElement) {
}
export function openNewAppModal() {
newAppError = ''
newAppCreating = false
newAppError = ''
newAppName = ''
newAppTemplate = 'ssr'
newAppTool = false
openModal('New App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
createNewApp(input)
createNewApp()
}}>
<FormField>
<FormLabel for="app-name">App Name</FormLabel>
@ -69,10 +76,39 @@ export function openNewAppModal() {
id="app-name"
type="text"
placeholder="my-app"
value={newAppName}
onInput={(e: Event) => {
newAppName = (e.target as HTMLInputElement).value
}}
autofocus
/>
{newAppError && <FormError>{newAppError}</FormError>}
</FormField>
<FormField>
<FormLabel for="app-template">Template</FormLabel>
<FormSelect
id="app-template"
onChange={(e: Event) => {
newAppTemplate = (e.target as HTMLSelectElement).value as TemplateType
}}
>
<option value="ssr" selected={newAppTemplate === 'ssr'}>SSR</option>
<option value="spa" selected={newAppTemplate === 'spa'}>SPA</option>
<option value="bare" selected={newAppTemplate === 'bare'}>Bare</option>
</FormSelect>
</FormField>
<FormCheckboxField>
<FormCheckbox
id="app-tool"
type="checkbox"
checked={newAppTool}
onChange={(e: Event) => {
newAppTool = (e.target as HTMLInputElement).checked
rerenderModal()
}}
/>
<FormCheckboxLabel for="app-tool">Tool</FormCheckboxLabel>
</FormCheckboxField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
Cancel

View File

@ -51,3 +51,43 @@ export const FormActions = define('FormActions', {
gap: 8,
marginTop: 8,
})
export const FormSelect = define('FormSelect', {
base: 'select',
padding: '8px 12px',
background: theme('colors-bgSubtle'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-text'),
fontSize: 14,
cursor: 'pointer',
selectors: {
'&:focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
},
})
export const FormCheckbox = define('FormCheckbox', {
base: 'input',
width: 16,
height: 16,
margin: 0,
cursor: 'pointer',
accentColor: theme('colors-primary'),
})
export const FormCheckboxField = define('FormCheckboxField', {
display: 'flex',
alignItems: 'center',
gap: 8,
})
export const FormCheckboxLabel = define('FormCheckboxLabel', {
base: 'label',
fontSize: 13,
fontWeight: 500,
color: theme('colors-text'),
cursor: 'pointer',
})

View File

@ -1,5 +1,5 @@
export { ActionBar, Button, NewAppButton } from './buttons'
export { Form, FormActions, FormError, FormField, FormInput, FormLabel } from './forms'
export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms'
export {
AppItem,
AppList,

View File

@ -6,6 +6,10 @@ export type TemplateType = 'ssr' | 'bare' | 'spa'
export type AppTemplates = Record<string, string>
interface TemplateOptions {
tool?: boolean
}
interface TemplateVars {
APP_EMOJI: string
APP_NAME: string
@ -32,7 +36,7 @@ function replaceVars(content: string, vars: TemplateVars): string {
.replace(/\$\$APP_EMOJI\$\$/g, vars.APP_EMOJI)
}
export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
export function generateTemplates(appName: string, template: TemplateType = 'ssr', options: TemplateOptions = {}): AppTemplates {
const vars: TemplateVars = {
APP_EMOJI: DEFAULT_EMOJI,
APP_NAME: appName,
@ -43,8 +47,17 @@ export function generateTemplates(appName: string, template: TemplateType = 'ssr
// Read shared files from templates/
for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) {
const path = join(TEMPLATES_DIR, filename)
const content = readFileSync(path, 'utf-8')
result[filename] = replaceVars(content, vars)
let content = readFileSync(path, 'utf-8')
content = replaceVars(content, vars)
// Add tool option to package.json if specified
if (filename === 'package.json' && options.tool) {
const pkg = JSON.parse(content)
pkg.toes.tool = true
content = JSON.stringify(pkg, null, 2) + '\n'
}
result[filename] = content
}
// Read template-specific files

View File

@ -3,9 +3,15 @@ import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import { Hype } from '@because/hype'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
const timestamp = () => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
}
const router = Hype.router()
// BackendApp -> SharedApp
@ -55,7 +61,7 @@ router.get('/:app/logs', c => {
})
router.post('/', async c => {
let body: { name?: string, template?: TemplateType }
let body: { name?: string, template?: TemplateType, tool?: boolean }
try {
body = await c.req.json()
} catch {
@ -75,11 +81,16 @@ router.post('/', async c => {
}
const template = body.template ?? 'ssr'
const templates = generateTemplates(name, template)
const templates = generateTemplates(name, template, { tool: body.tool })
// Create directories and write files
// Create versioned directory structure
const ts = timestamp()
const versionPath = join(appPath, ts)
const currentPath = join(appPath, 'current')
// Create directories and write files into version directory
for (const [filename, content] of Object.entries(templates)) {
const fullPath = join(appPath, filename)
const fullPath = join(versionPath, filename)
const dir = dirname(fullPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
@ -87,6 +98,9 @@ router.post('/', async c => {
writeFileSync(fullPath, content)
}
// Create current symlink
symlinkSync(ts, currentPath)
return c.json({ ok: true, name })
})

View File

@ -27,6 +27,29 @@ const router = Hype.router()
router.get('/apps', c => c.json(allApps().map(a => a.name)))
router.get('/apps/:app/versions', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const appDir = safePath(APPS_DIR, appName)
if (!appDir) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404)
const currentLink = join(appDir, 'current')
const currentVersion = existsSync(currentLink)
? realpathSync(currentLink).split('/').pop()
: null
const entries = readdirSync(appDir, { withFileTypes: true })
const versions = entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => e.name)
.sort()
.reverse() // Newest first
return c.json({ versions, current: currentVersion })
})
router.get('/apps/:app/manifest', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)

View File

@ -3,6 +3,7 @@ import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types'
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, watch, writeFileSync } from 'fs'
import { join, resolve } from 'path'
import { appLog, hostLog, setApps } from './tui'
export type { AppState } from '@types'
@ -222,14 +223,17 @@ const clearTimers = (app: App) => {
}
const info = (app: App, ...msg: string[]) => {
console.log('🐾', `[${app.name}]`, ...msg)
appLog(app, ...msg)
app.logs?.push({ time: Date.now(), text: msg.join(' ') })
}
const isApp = (dir: string): boolean =>
!loadApp(dir).error
const update = () => _listeners.forEach(cb => cb())
const update = () => {
setApps(allApps())
_listeners.forEach(cb => cb())
}
function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true })
@ -246,6 +250,7 @@ function discoverApps() {
const tool = pkg.toes?.tool
_apps.set(dir, { name: dir, state, icon, error, tool })
}
update()
}
function getPort(appName?: string): number {
@ -282,15 +287,15 @@ async function gracefulShutdown(signal: string) {
if (_shuttingDown) return
_shuttingDown = true
console.log(`\n🐾 Received ${signal}, shutting down gracefully...`)
hostLog(`Received ${signal}, shutting down gracefully...`)
const running = runningApps()
if (running.length === 0) {
console.log('🐾 No apps running, exiting.')
hostLog('No apps running, exiting.')
process.exit(0)
}
console.log(`🐾 Stopping ${running.length} app(s)...`)
hostLog(`Stopping ${running.length} app(s)...`)
// Stop all running apps
for (const app of running) {
@ -308,14 +313,14 @@ async function gracefulShutdown(signal: string) {
const stillRunning = runningApps()
if (stillRunning.length === 0) {
clearInterval(checkInterval)
console.log('🐾 All apps stopped, exiting.')
hostLog('All apps stopped, exiting.')
process.exit(0)
}
// Check for timeout
if (Date.now() - shutdownStart > SHUTDOWN_TIMEOUT) {
clearInterval(checkInterval)
console.log(`🐾 Shutdown timeout, forcing ${stillRunning.length} app(s) to stop...`)
hostLog(`Shutdown timeout, forcing ${stillRunning.length} app(s) to stop...`)
for (const app of stillRunning) {
if (app.proc) {
app.proc.kill(9) // SIGKILL
@ -323,7 +328,7 @@ async function gracefulShutdown(signal: string) {
}
// Give a moment for SIGKILL to take effect
setTimeout(() => {
console.log('🐾 Forced shutdown complete, exiting.')
hostLog('Forced shutdown complete, exiting.')
process.exit(1)
}, 500)
}

View File

@ -3,7 +3,7 @@ import appsRouter from './api/apps'
import syncRouter from './api/sync'
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
const app = new Hype({ layout: false, logging: false })
app.route('/api/apps', appsRouter)
app.route('/api/sync', syncRouter)
@ -20,7 +20,6 @@ app.get('/tool/:tool', c => {
return c.redirect(url)
})
console.log('🐾 Toes!')
initApps()
export default {

106
src/server/tui.ts Normal file
View File

@ -0,0 +1,106 @@
import type { App } from '$apps'
const RENDER_DEBOUNCE = 50
let _apps: App[] = []
let _enabled = process.stdout.isTTY ?? false
let _lastRender = 0
let _renderTimer: Timer | undefined
let _showEmoji = false
export const setShowEmoji = (show: boolean) => {
_showEmoji = show
scheduleRender()
}
export function appLog(app: App, ...msg: string[]) {
if (!_enabled) {
console.log('🐾', `[${app.name}]`, msg.join(' '))
}
}
export function hostLog(...msg: string[]) {
if (!_enabled) {
console.log('🐾', msg.join(' '))
}
}
export function setApps(apps: App[]) {
_apps = apps
scheduleRender()
}
const formatStatus = (app: App): string => {
switch (app.state) {
case 'running':
return '\x1b[32m●\x1b[0m'
case 'starting':
return '\x1b[33m◐\x1b[0m'
case 'stopping':
return '\x1b[33m◑\x1b[0m'
case 'stopped':
return '\x1b[90m○\x1b[0m'
case 'invalid':
return '\x1b[31m✕\x1b[0m'
default:
return '\x1b[90m?\x1b[0m'
}
}
const formatAppLine = (app: App): string => {
const status = formatStatus(app)
const port = app.port ? `\x1b[90m:${app.port}\x1b[0m` : ''
if (_showEmoji) {
const icon = app.icon ?? '📦'
return ` ${icon} ${app.name} ${status}${port}`
} else {
return ` ${status} ${app.name}${port}`
}
}
const scheduleRender = () => {
if (_renderTimer) return
const elapsed = Date.now() - _lastRender
const delay = Math.max(0, RENDER_DEBOUNCE - elapsed)
_renderTimer = setTimeout(() => {
_renderTimer = undefined
render()
}, delay)
}
function render() {
if (!_enabled) return
_lastRender = Date.now()
const lines: string[] = []
// Clear screen and move cursor to top
lines.push('\x1b[2J\x1b[H')
// Header
lines.push('\x1b[1m🐾 Toes\x1b[0m')
lines.push('')
// Apps section
const regularApps = _apps.filter(a => !a.tool)
const tools = _apps.filter(a => a.tool)
if (regularApps.length > 0) {
lines.push('\x1b[1mApps\x1b[0m')
for (const app of regularApps) {
lines.push(formatAppLine(app))
}
lines.push('')
}
if (tools.length > 0) {
lines.push('\x1b[1mTools\x1b[0m')
for (const app of tools) {
lines.push(formatAppLine(app))
}
lines.push('')
}
process.stdout.write(lines.join('\n'))
}