751 lines
20 KiB
TypeScript
Executable File
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() |