toes/src/cli/index.ts
Chris Wanstrath fcdb8feea0 fix cli isApp
2026-01-29 15:22:39 -08:00

529 lines
13 KiB
TypeScript
Executable File

#!/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<string, FileInfo>
name: string
}
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}`
}
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) {
console.error(error)
}
}
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) {
console.error(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) {
console.error(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) {
console.error(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) {
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<string, FileInfo> = {}
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 <app>` 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 <app>` 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('<name>', 'app name')
.action(infoApp)
program
.command('list')
.description('List all apps')
.action(listApps)
program
.command('start')
.description('Start an app')
.argument('<name>', 'app name')
.action(startApp)
program
.command('stop')
.description('Stop an app')
.argument('<name>', 'app name')
.action(stopApp)
program
.command('restart')
.description('Restart an app')
.argument('<name>', 'app name')
.action(restartApp)
program
.command('logs')
.description('Show logs for an app')
.argument('<name>', 'app name')
.option('-f, --follow', 'follow log output')
.action(logApp)
program
.command('log', { hidden: true })
.argument('<name>', 'app name')
.option('-f, --follow', 'follow log output')
.action(logApp)
program
.command('open')
.description('Open an app in browser')
.argument('<name>', 'app name')
.action(openApp)
program
.command('get')
.description('Download an app from server')
.argument('<name>', '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()