toes/src/cli/index.ts
2026-01-29 23:39:28 -08:00

751 lines
20 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import type { App, LogLine, Manifest } from '@types'
import { loadGitignore } from '@gitignore'
import { computeHash, generateManifest } from '%sync'
import { generateTemplates } from '@templates'
import { program } from 'commander'
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import color from 'kleur'
import { basename, dirname, join } from 'path'
import * as readline from 'readline'
const HOST = `http://localhost:${process.env.PORT ?? 3000}`
const STATE_ICONS: Record<string, string> = {
running: color.green('●'),
starting: color.yellow('◎'),
stopped: color.gray('◯'),
invalid: color.red('◌'),
}
function makeUrl(path: string): string {
return `${HOST}${path}`
}
function handleError(error: unknown): void {
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
console.error(`🐾 Can't connect to toes server at ${HOST}`)
return
}
console.error(error)
}
async function get<T>(url: string): Promise<T | undefined> {
try {
const res = await fetch(makeUrl(url))
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return await res.json()
} catch (error) {
handleError(error)
}
}
async function getManifest(appName: string): Promise<{ exists: boolean; manifest?: Manifest } | null> {
try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
if (res.status === 404) return { exists: false }
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return { exists: true, manifest: await res.json() }
} catch (error) {
handleError(error)
return null
}
}
async function post<T, B = unknown>(url: string, body?: B): Promise<T | undefined> {
try {
const res = await fetch(makeUrl(url), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return await res.json()
} catch (error) {
handleError(error)
}
}
async function put(url: string, body: Buffer | Uint8Array): Promise<boolean> {
try {
const res = await fetch(makeUrl(url), {
method: 'PUT',
body: body as BodyInit,
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return true
} catch (error) {
handleError(error)
return false
}
}
async function download(url: string): Promise<Buffer | undefined> {
try {
const fullUrl = makeUrl(url)
const res = await fetch(fullUrl)
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return Buffer.from(await res.arrayBuffer())
} catch (error) {
handleError(error)
}
}
async function del(url: string): Promise<boolean> {
try {
const res = await fetch(makeUrl(url), {
method: 'DELETE',
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return true
} catch (error) {
handleError(error)
return false
}
}
async function infoApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const app: App | undefined = await get(`/api/apps/${name}`)
if (!app) {
console.error(`App not found: ${name}`)
return
}
const icon = STATE_ICONS[app.state] ?? '◯'
console.log(`${icon} ${color.bold(app.name)}`)
console.log(` State: ${app.state}`)
if (app.port) {
console.log(` Port: ${app.port}`)
console.log(` URL: http://localhost:${app.port}`)
}
if (app.started) {
const uptime = Date.now() - app.started
const seconds = Math.floor(uptime / 1000) % 60
const minutes = Math.floor(uptime / 60000) % 60
const hours = Math.floor(uptime / 3600000)
const parts = []
if (hours) parts.push(`${hours}h`)
if (minutes) parts.push(`${minutes}m`)
parts.push(`${seconds}s`)
console.log(` Uptime: ${parts.join(' ')}`)
}
if (app.error) console.log(` Error: ${color.red(app.error)}`)
}
async function listApps() {
const apps: App[] | undefined = await get('/api/apps')
if (!apps) return
for (const app of apps) {
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
}
}
async function startApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/start`)
}
async function stopApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/stop`)
}
async function restartApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/restart`)
}
const printLog = (line: LogLine) =>
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
async function logApp(arg: string | undefined, options: { follow?: boolean }) {
const name = resolveAppName(arg)
if (!name) return
if (options.follow) {
await tailLogs(name)
return
}
const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`)
if (!logs) {
console.error(`App not found: ${name}`)
return
}
if (logs.length === 0) {
console.log('No logs yet')
return
}
for (const line of logs) {
printLog(line)
}
}
async function tailLogs(name: string) {
const url = makeUrl(`/api/apps/${name}/logs/stream`)
const res = await fetch(url)
if (!res.ok) {
console.error(`App not found: ${name}`)
return
}
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as LogLine
printLog(data)
}
}
}
}
async function openApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const app: App | undefined = await get(`/api/apps/${name}`)
if (!app) {
console.error(`App not found: ${name}`)
return
}
if (app.state !== 'running') {
console.error(`App is not running: ${name}`)
return
}
const url = `http://localhost:${app.port}`
console.log(`Opening ${url}`)
Bun.spawn(['open', url])
}
async function getApp(name: string) {
console.log(`Fetching ${color.bold(name)} from server...`)
const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`)
if (!manifest) {
console.error(`App not found: ${name}`)
return
}
const appPath = join(process.cwd(), name)
if (existsSync(appPath)) {
console.error(`Directory already exists: ${name}`)
return
}
mkdirSync(appPath, { recursive: true })
const files = Object.keys(manifest.files)
console.log(`Downloading ${files.length} files...`)
for (const file of files) {
const content = await download(`/api/sync/apps/${name}/files/${file}`)
if (!content) {
console.error(`Failed to download: ${file}`)
continue
}
const fullPath = join(appPath, file)
const dir = dirname(fullPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(fullPath, content)
}
console.log(color.green(`✓ Downloaded ${name}`))
}
function getAppPackage(): { name?: string; scripts?: { toes?: string } } | null {
try {
return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
} catch {
return null
}
}
const isApp = () => !!getAppPackage()?.scripts?.toes
function resolveAppName(name?: string): string | undefined {
if (name) return name
const pkg = getAppPackage()
if (pkg?.scripts?.toes) return pkg.name || basename(process.cwd())
console.error('No app specified and current directory is not a toes app')
return undefined
}
async function pushApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = basename(process.cwd())
const localManifest = generateManifest(process.cwd(), appName)
const result = await getManifest(appName)
if (result === null) {
// Connection error - already printed
return
}
if (!result.exists) {
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
if (!ok) return
}
const localFiles = new Set(Object.keys(localManifest.files))
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
// Files to upload (new or changed)
const toUpload: string[] = []
for (const file of localFiles) {
const local = localManifest.files[file]!
const remote = result.manifest?.files[file]
if (!remote || local.hash !== remote.hash) {
toUpload.push(file)
}
}
// Files to delete (in remote but not local)
const toDelete: string[] = []
for (const file of remoteFiles) {
if (!localFiles.has(file)) {
toDelete.push(file)
}
}
if (toUpload.length === 0 && toDelete.length === 0) {
console.log('Already up to date')
return
}
console.log(`Pushing ${color.bold(appName)} to server...`)
if (toUpload.length > 0) {
console.log(`Uploading ${toUpload.length} files...`)
for (const file of toUpload) {
const content = readFileSync(join(process.cwd(), file))
const success = await put(`/api/sync/apps/${appName}/files/${file}`, content)
if (success) {
console.log(` ${color.green('↑')} ${file}`)
} else {
console.log(` ${color.red('✗')} ${file}`)
}
}
}
if (toDelete.length > 0) {
console.log(`Deleting ${toDelete.length} files on server...`)
for (const file of toDelete) {
const success = await del(`/api/sync/apps/${appName}/files/${file}`)
if (success) {
console.log(` ${color.red('✗')} ${file}`)
} else {
console.log(` ${color.red('Failed to delete')} ${file}`)
}
}
}
console.log(color.green('✓ Push complete'))
}
async function confirm(message: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(`${message} [y/N] `, (answer) => {
rl.close()
resolve(answer.toLowerCase() === 'y')
})
})
}
async function newApp(name?: string) {
const appPath = name ? join(process.cwd(), name) : process.cwd()
const appName = name ?? basename(process.cwd())
if (name && existsSync(appPath)) {
console.error(`Directory already exists: ${name}`)
return
}
const filesToCheck = ['index.tsx', 'package.json', 'tsconfig.json']
const existing = filesToCheck.filter((f) => existsSync(join(appPath, f)))
if (existing.length > 0) {
console.error(`Files already exist: ${existing.join(', ')}`)
return
}
const ok = await confirm(`Create ${color.bold(appName)} in ${appPath}?`)
if (!ok) return
mkdirSync(join(appPath, 'src', 'pages'), { recursive: true })
const templates = generateTemplates(appName)
for (const [filename, content] of Object.entries(templates)) {
writeFileSync(join(appPath, filename), content)
}
process.chdir(appPath)
await pushApp()
console.log(color.green(`✓ Created ${appName}`))
console.log()
console.log('Next steps:')
if (name) {
console.log(` cd ${name}`)
}
console.log(' bun install')
console.log(' bun dev')
}
async function pullApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = basename(process.cwd())
const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`)
if (!remoteManifest) {
console.error('App not found on server')
return
}
const localManifest = generateManifest(process.cwd(), appName)
const localFiles = new Set(Object.keys(localManifest.files))
const remoteFiles = new Set(Object.keys(remoteManifest.files))
// Files to download (new or changed)
const toDownload: string[] = []
for (const file of remoteFiles) {
const remote = remoteManifest.files[file]!
const local = localManifest.files[file]
if (!local || remote.hash !== local.hash) {
toDownload.push(file)
}
}
// Files to delete (in local but not remote)
const toDelete: string[] = []
for (const file of localFiles) {
if (!remoteFiles.has(file)) {
toDelete.push(file)
}
}
if (toDownload.length === 0 && toDelete.length === 0) {
console.log('Already up to date')
return
}
console.log(`Pulling ${color.bold(appName)} from server...`)
if (toDownload.length > 0) {
console.log(`Downloading ${toDownload.length} files...`)
for (const file of toDownload) {
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
if (!content) {
console.log(` ${color.red('✗')} ${file}`)
continue
}
const fullPath = join(process.cwd(), file)
const dir = dirname(fullPath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(fullPath, content)
console.log(` ${color.green('↓')} ${file}`)
}
}
if (toDelete.length > 0) {
console.log(`Deleting ${toDelete.length} local files...`)
for (const file of toDelete) {
const fullPath = join(process.cwd(), file)
unlinkSync(fullPath)
console.log(` ${color.red('✗')} ${file}`)
}
}
console.log(color.green('✓ Pull complete'))
}
async function syncApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = basename(process.cwd())
const gitignore = loadGitignore(process.cwd())
const localHashes = new Map<string, string>()
// Initialize local hashes
const manifest = generateManifest(process.cwd(), appName)
for (const [path, info] of Object.entries(manifest.files)) {
localHashes.set(path, info.hash)
}
console.log(`Syncing ${color.bold(appName)}...`)
// Watch local files
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
if (!filename || gitignore.shouldExclude(filename)) return
const fullPath = join(process.cwd(), filename)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
const content = readFileSync(fullPath)
const hash = computeHash(content)
if (localHashes.get(filename) !== hash) {
localHashes.set(filename, hash)
await put(`/api/sync/apps/${appName}/files/${filename}`, content)
console.log(` ${color.green('↑')} ${filename}`)
}
} else if (!existsSync(fullPath)) {
localHashes.delete(filename)
await del(`/api/sync/apps/${appName}/files/${filename}`)
console.log(` ${color.red('✗')} ${filename}`)
}
})
// Connect to SSE for remote changes
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
let res: Response
try {
res = await fetch(url)
if (!res.ok) {
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
watcher.close()
return
}
} catch (error) {
handleError(error)
watcher.close()
return
}
if (!res.body) {
console.error('No response body from server')
watcher.close()
return
}
console.log(` Connected to server, watching for changes...`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete'; path: string; hash?: string }
if (event.type === 'change') {
// Skip if we already have this version (handles echo from our own changes)
if (localHashes.get(event.path) === event.hash) continue
const content = await download(`/api/sync/apps/${appName}/files/${event.path}`)
if (content) {
const fullPath = join(process.cwd(), event.path)
mkdirSync(dirname(fullPath), { recursive: true })
writeFileSync(fullPath, content)
localHashes.set(event.path, event.hash!)
console.log(` ${color.green('↓')} ${event.path}`)
}
} else if (event.type === 'delete') {
const fullPath = join(process.cwd(), event.path)
if (existsSync(fullPath)) {
unlinkSync(fullPath)
localHashes.delete(event.path)
console.log(` ${color.red('✗')} ${event.path} (remote)`)
}
}
}
}
} finally {
watcher.close()
}
}
async function prompt(message: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise(resolve => {
rl.question(message, (answer) => {
rl.close()
resolve(answer)
})
})
}
async function rmApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
const result = await getManifest(name)
if (result === null) return
if (!result.exists) {
console.error(`App not found on server: ${name}`)
return
}
const expected = `sudo rm ${name}`
console.log(`This will ${color.red('permanently delete')} ${color.bold(name)} from the server.`)
const answer = await prompt(`Type "${expected}" to confirm: `)
if (answer !== expected) {
console.log('Aborted.')
return
}
const success = await del(`/api/sync/apps/${name}`)
if (success) {
console.log(color.green(`✓ Removed ${name}`))
}
}
program
.name('toes')
.version('0.0.1', '-v, --version')
.addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) {
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
}
return ''
})
.configureOutput({
writeOut: (str) => {
const colored = str
.replace(/^(Usage:)/gm, color.yellow('$1'))
.replace(/^(Commands:)/gm, color.yellow('$1'))
.replace(/^(Options:)/gm, color.yellow('$1'))
.replace(/^(Arguments:)/gm, color.yellow('$1'))
process.stdout.write(colored)
},
})
program
.command('info')
.description('Show info for an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(infoApp)
program
.command('list')
.description('List all apps')
.action(listApps)
program
.command('start')
.description('Start an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(startApp)
program
.command('stop')
.description('Stop an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(stopApp)
program
.command('restart')
.description('Restart an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(restartApp)
program
.command('logs')
.description('Show logs for an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-f, --follow', 'follow log output')
.action(logApp)
program
.command('log', { hidden: true })
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-f, --follow', 'follow log output')
.action(logApp)
program
.command('open')
.description('Open an app in browser')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(openApp)
program
.command('get')
.description('Download an app from server')
.argument('<name>', 'app name')
.action(getApp)
program
.command('new')
.description('Create a new toes app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(newApp)
program
.command('push')
.description('Push local changes to server')
.action(pushApp)
program
.command('pull')
.description('Pull changes from server')
.action(pullApp)
program
.command('sync')
.description('Watch and sync changes bidirectionally')
.action(syncApp)
program
.command('rm')
.description('Remove an app from the server')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(rmApp)
program.parse()