#!/usr/bin/env bun import type { App, LogLine } from '@types' import { loadGitignore } from '@gitignore' import { program } from 'commander' import { createHash } from 'crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs' import color from 'kleur' import { dirname, join, relative } from 'path' const HOST = `http://localhost:${process.env.PORT ?? 3000}` interface FileInfo { hash: string mtime: string size: number } interface Manifest { files: Record name: string } const STATE_ICONS: Record = { running: color.green('●'), starting: color.yellow('◎'), stopped: color.gray('◯'), invalid: color.red('◌'), } function makeUrl(path: string): string { return `${HOST}${path}` } async function get(url: string): Promise { try { const res = await fetch(makeUrl(url)) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return await res.json() } catch (error) { console.error(error) } } async function post(url: string, body?: B): Promise { 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) { console.error(error) } } async function put(url: string, body: Buffer | Uint8Array): Promise { 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) { console.error(error) return false } } async function download(url: string): Promise { 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) { console.error(error) } } async function del(url: string): Promise { try { const res = await fetch(makeUrl(url), { method: 'DELETE', }) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return true } catch (error) { console.error(error) return false } } function computeHash(content: Buffer | string): string { return createHash('sha256').update(content).digest('hex') } function generateLocalManifest(appPath: string, appName: string): Manifest { const files: Record = {} const gitignore = loadGitignore(appPath) function walkDir(dir: string) { if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = join(dir, entry.name) const relativePath = relative(appPath, fullPath) if (gitignore.shouldExclude(relativePath)) continue if (entry.isDirectory()) { walkDir(fullPath) } else if (entry.isFile()) { const content = readFileSync(fullPath) const stats = statSync(fullPath) files[relativePath] = { hash: computeHash(content), mtime: stats.mtime.toISOString(), size: stats.size, } } } } walkDir(appPath) return { name: appName, files, } } async function infoApp(name: string) { 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}`) } } const startApp = async (app: string) => { await post(`/api/apps/${app}/start`) } const stopApp = async (app: string) => { await post(`/api/apps/${app}/stop`) } const restartApp = async (app: string) => { await post(`/api/apps/${app}/restart`) } const printLog = (line: LogLine) => console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`) async function logApp(name: string, options: { follow?: boolean }) { 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(name: string) { 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 isApp(): boolean { try { const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) return !!pkg?.scripts?.toes } catch (e) { return false } } async function pushApp() { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') return } const appName = process.cwd().split('/').pop() if (!appName) { console.error('Could not determine app name from current directory') return } console.log(`Pushing ${color.bold(appName)} to server...`) const localManifest = generateLocalManifest(process.cwd(), appName) const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) if (!remoteManifest) { console.error('App not found on server') return } const localFiles = new Set(Object.keys(localManifest.files)) const remoteFiles = new Set(Object.keys(remoteManifest.files)) // Files to upload (new or changed) const toUpload: string[] = [] for (const file of localFiles) { const local = localManifest.files[file]! const remote = remoteManifest.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 } 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 pullApp() { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') return } const appName = process.cwd().split('/').pop() if (!appName) { console.error('Could not determine app name from current directory') return } console.log(`Pulling ${color.bold(appName)} from server...`) const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) if (!remoteManifest) { console.error('App not found on server') return } const localManifest = generateLocalManifest(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 } 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')) } program .name('toes') .version('0.0.1', '-v, --version') program .command('info') .description('Show info for an app') .argument('', 'app name') .action(infoApp) program .command('list') .description('List all apps') .action(listApps) program .command('start') .description('Start an app') .argument('', 'app name') .action(startApp) program .command('stop') .description('Stop an app') .argument('', 'app name') .action(stopApp) program .command('restart') .description('Restart an app') .argument('', 'app name') .action(restartApp) program .command('logs') .description('Show logs for an app') .argument('', 'app name') .option('-f, --follow', 'follow log output') .action(logApp) program .command('log', { hidden: true }) .argument('', 'app name') .option('-f, --follow', 'follow log output') .action(logApp) program .command('open') .description('Open an app in browser') .argument('', 'app name') .action(openApp) program .command('get') .description('Download an app from server') .argument('', 'app name') .action(getApp) program .command('push') .description('Push local changes to server') .action(pushApp) program .command('pull') .description('Pull changes from server') .action(pullApp) program.parse()