This commit is contained in:
Chris Wanstrath 2026-01-30 08:23:29 -08:00
parent 060c93c378
commit f1cf2b9291
29 changed files with 1845 additions and 1663 deletions

13
src/cli/commands/index.ts Normal file
View File

@ -0,0 +1,13 @@
export { logApp } from './logs'
export {
infoApp,
listApps,
newApp,
openApp,
renameApp,
restartApp,
rmApp,
startApp,
stopApp,
} from './manage'
export { getApp, pullApp, pushApp, syncApp } from './sync'

59
src/cli/commands/logs.ts Normal file
View File

@ -0,0 +1,59 @@
import type { LogLine } from '@types'
import { get, makeUrl } from '../http'
import { resolveAppName } from '../name'
export const printLog = (line: LogLine) =>
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
export 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)
}
}
export 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)
}
}
}
}

188
src/cli/commands/manage.ts Normal file
View File

@ -0,0 +1,188 @@
import type { App } from '@types'
import { generateTemplates } from '@templates'
import color from 'kleur'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { basename, join } from 'path'
import { del, get, getManifest, post } from '../http'
import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = {
running: color.green('●'),
starting: color.yellow('◎'),
stopped: color.gray('◯'),
invalid: color.red('◌'),
}
export 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)}`)
}
export 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}`)
}
}
export 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')
}
export 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])
}
export async function renameApp(arg: string | undefined, newName: 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 rename ${name} ${newName}`
console.log(`This will rename ${color.bold(name)} to ${color.bold(newName)}.`)
const answer = await prompt(`Type "${expected}" to confirm: `)
if (answer !== expected) {
console.log('Aborted.')
return
}
const response = await post<{ ok: boolean, error?: string, name?: string }>(`/api/apps/${name}/rename`, { name: newName })
if (!response) return
if (!response.ok) {
console.error(color.red(`Error: ${response.error}`))
return
}
console.log(color.green(`✓ Renamed ${name} to ${response.name}`))
}
export async function restartApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/restart`)
}
export 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}`))
}
}
export async function startApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/start`)
}
export async function stopApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
await post(`/api/apps/${name}/stop`)
}

309
src/cli/commands/sync.ts Normal file
View File

@ -0,0 +1,309 @@
import type { Manifest } from '@types'
import { loadGitignore } from '@gitignore'
import { computeHash, generateManifest } from '%sync'
import color from 'kleur'
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { del, download, get, getManifest, handleError, makeUrl, put } from '../http'
import { confirm } from '../prompts'
import { getAppName, isApp } from '../name'
export 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}`))
}
export async function pushApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
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'))
}
export async function pullApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
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'))
}
export async function syncApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
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()
}
}

89
src/cli/http.ts Normal file
View File

@ -0,0 +1,89 @@
import type { Manifest } from '@types'
export const HOST = `http://localhost:${process.env.PORT ?? 3000}`
export function makeUrl(path: string): string {
return `${HOST}${path}`
}
export 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)
}
export 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)
}
}
export 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
}
}
export 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)
}
}
export 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
}
}
export 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)
}
}
export 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
}
}

View File

@ -1,793 +1,4 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import type { App, LogLine, Manifest } from '@types' import { program } from './setup'
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 getAppName = () => getAppPackage()?.name || basename(process.cwd())
const isApp = () => !!getAppPackage()?.scripts?.toes
function resolveAppName(name?: string): string | undefined {
if (name) return name
if (isApp()) return getAppName()
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 = getAppName()
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 = getAppName()
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 = getAppName()
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 renameApp(arg: string | undefined, newName: 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 rename ${name} ${newName}`
console.log(`This will rename ${color.bold(name)} to ${color.bold(newName)}.`)
const answer = await prompt(`Type "${expected}" to confirm: `)
if (answer !== expected) {
console.log('Aborted.')
return
}
const response = await post<{ ok: boolean, error?: string, name?: string }>(`/api/apps/${name}/rename`, { name: newName })
if (!response) return
if (!response.ok) {
console.error(color.red(`Error: ${response.error}`))
return
}
console.log(color.green(`✓ Renamed ${name} to ${response.name}`))
}
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('version', { hidden: true })
.action(() => console.log(program.version()))
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
.command('rename')
.description('Rename an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<new-name>', 'new app name')
.action(renameApp)
program.parse() program.parse()

21
src/cli/name.ts Normal file
View File

@ -0,0 +1,21 @@
import { readFileSync } from 'fs'
import { basename, join } from 'path'
export function getAppPackage(): { name?: string, scripts?: { toes?: string } } | null {
try {
return JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
} catch {
return null
}
}
export const getAppName = () => getAppPackage()?.name || basename(process.cwd())
export const isApp = () => !!getAppPackage()?.scripts?.toes
export function resolveAppName(name?: string): string | undefined {
if (name) return name
if (isApp()) return getAppName()
console.error('No app specified and current directory is not a toes app')
return undefined
}

29
src/cli/prompts.ts Normal file
View File

@ -0,0 +1,29 @@
import * as readline from 'readline'
export 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')
})
})
}
export 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)
})
})
}

132
src/cli/setup.ts Normal file
View File

@ -0,0 +1,132 @@
import { program } from 'commander'
import color from 'kleur'
import {
getApp,
infoApp,
listApps,
logApp,
newApp,
openApp,
pullApp,
pushApp,
renameApp,
restartApp,
rmApp,
startApp,
stopApp,
syncApp,
} from './commands'
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('version', { hidden: true })
.action(() => console.log(program.version()))
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
.command('rename')
.description('Rename an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.argument('<new-name>', 'new app name')
.action(renameApp)
export { program }

5
src/client/api.ts Normal file
View File

@ -0,0 +1,5 @@
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })

View File

@ -0,0 +1,151 @@
import { define } from '@because/forge'
import type { App } from '../../shared/types'
import { restartApp, startApp, stopApp } from '../api'
import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { selectedTab } from '../state'
import {
ActionBar,
Button,
ClickableAppName,
HeaderActions,
InfoLabel,
InfoRow,
InfoValue,
Link,
LogLine,
LogsContainer,
LogTime,
Main,
MainContent,
MainHeader,
MainTitle,
Section,
SectionTitle,
stateLabels,
StatusDot,
TabContent,
} from '../styles'
import { openEmojiPicker } from './emoji-picker'
import { theme } from '../themes'
import { Nav } from './Nav'
const OpenEmojiPicker = define('OpenEmojiPicker', {
cursor: 'pointer',
render({ props: { app, children, render: renderFn }, parts: { Root } }) {
return <Root onClick={() => openEmojiPicker((emoji) => {
if (!app) return
fetch(`/api/apps/${app.name}/icon?icon=${emoji}`, { method: 'POST' })
app.icon = emoji
renderFn()
})}>{children}</Root>
}
})
export function AppDetail({ app, render }: { app: App, render: () => void }) {
return (
<Main>
<MainHeader>
<MainTitle>
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
</HeaderActions>
</MainHeader>
<MainContent>
<Nav render={render} />
<TabContent active={selectedTab === 'overview'}>
<Section>
<SectionTitle>Status</SectionTitle>
<InfoRow>
<InfoLabel>State</InfoLabel>
<InfoValue>
<StatusDot state={app.state} />
{stateLabels[app.state]}
</InfoValue>
</InfoRow>
{app.state === 'running' && app.port && (
<InfoRow>
<InfoLabel>URL</InfoLabel>
<InfoValue>
<Link href={`http://localhost:${app.port}`} target="_blank">
http://localhost:{app.port}
</Link>
</InfoValue>
</InfoRow>
)}
{app.state === 'running' && app.port && (
<InfoRow>
<InfoLabel>Port</InfoLabel>
<InfoValue>
{app.port}
</InfoValue>
</InfoRow>
)}
{app.started && (
<InfoRow>
<InfoLabel>Started</InfoLabel>
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
</InfoRow>
)}
{app.error && (
<InfoRow>
<InfoLabel>Error</InfoLabel>
<InfoValue style={{ color: theme('colors-error') }}>
{app.error}
</InfoValue>
</InfoRow>
)}
</Section>
<Section>
<SectionTitle>Logs</SectionTitle>
<LogsContainer>
{app.logs?.length ? (
app.logs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
</LogLine>
))
) : (
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
</LogLine>
)}
</LogsContainer>
</Section>
<ActionBar>
{app.state === 'stopped' && (
<Button variant="primary" onClick={() => startApp(app.name)}>
Start
</Button>
)}
{app.state === 'running' && (
<>
<Button onClick={() => restartApp(app.name)}>Restart</Button>
<Button variant="danger" onClick={() => stopApp(app.name)}>
Stop
</Button>
</>
)}
{(app.state === 'starting' || app.state === 'stopping') && (
<Button disabled>{stateLabels[app.state]}...</Button>
)}
</ActionBar>
</TabContent>
<TabContent active={selectedTab === 'todo'}>
<h1>hardy har har</h1>
</TabContent>
</MainContent>
</Main>
)
}

View File

@ -0,0 +1,23 @@
import { Styles } from '@because/forge'
import { Modal } from './modal'
import { apps, selectedApp } from '../state'
import { EmptyState, Layout } from '../styles'
import { AppDetail } from './AppDetail'
import { Sidebar } from './Sidebar'
export function Dashboard({ render }: { render: () => void }) {
const selected = apps.find(a => a.name === selectedApp)
return (
<Layout>
<Styles />
<Sidebar render={render} />
{selected ? (
<AppDetail app={selected} render={render} />
) : (
<EmptyState>Select an app to view details</EmptyState>
)}
<Modal />
</Layout>
)
}

View File

@ -0,0 +1,16 @@
import { selectedTab, setSelectedTab } from '../state'
import { Tab, TabBar } from '../styles'
export function Nav({ render }: { render: () => void }) {
const handleTabClick = (tab: 'overview' | 'todo') => {
setSelectedTab(tab)
render()
}
return (
<TabBar>
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>Overview</Tab>
<Tab active={selectedTab === 'todo' ? true : undefined} onClick={() => handleTabClick('todo')}>TODO</Tab>
</TabBar>
)
}

View File

@ -0,0 +1,66 @@
import { openNewAppModal } from '../modals'
import { apps, selectedApp, setSelectedApp, setSidebarCollapsed, sidebarCollapsed } from '../state'
import {
AppItem,
AppList,
HamburgerButton,
HamburgerLine,
Logo,
NewAppButton,
SectionLabel,
Sidebar as SidebarContainer,
SidebarFooter,
StatusDot,
} from '../styles'
export function Sidebar({ render }: { render: () => void }) {
const selectApp = (name: string) => {
setSelectedApp(name)
render()
}
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed)
render()
}
return (
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
<Logo>
{!sidebarCollapsed && <span>🐾 Toes</span>}
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
</Logo>
{!sidebarCollapsed && <SectionLabel>Apps</SectionLabel>}
<AppList>
{apps.map(app => (
<AppItem
key={app.name}
onClick={() => selectApp(app.name)}
selected={app.name === selectedApp ? true : undefined}
style={sidebarCollapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={sidebarCollapsed ? app.name : undefined}
>
{sidebarCollapsed ? (
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} style={{ marginLeft: 'auto' }} />
</>
)}
</AppItem>
))}
</AppList>
{!sidebarCollapsed && (
<SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</SidebarFooter>
)}
</SidebarContainer>
)
}

View File

@ -0,0 +1,4 @@
export { AppDetail } from './AppDetail'
export { Dashboard } from './Dashboard'
export { Nav } from './Nav'
export { Sidebar } from './Sidebar'

View File

@ -1,877 +1,11 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { define, Styles } from '@because/forge' import { Dashboard } from './components'
import type { App, AppState } from '../shared/types' import { apps, selectedApp, setApps, setSelectedApp } from './state'
import { generateTemplates } from '../shared/templates' import { initModal } from './components/modal'
import { theme } from './themes'
import { closeModal, initModal, Modal, openModal, rerenderModal } from './tags/modal'
import { initUpdate } from './update' import { initUpdate } from './update'
import { openEmojiPicker } from './tags/emoji-picker'
// UI state (survives re-renders)
let selectedApp: string | null = localStorage.getItem('selectedApp')
let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
// Server state (from SSE)
let apps: App[] = []
// Layout
const Layout = define('Layout', {
display: 'flex',
height: '100vh',
fontFamily: theme('fonts-sans'),
background: theme('colors-bg'),
color: theme('colors-text'),
})
const Sidebar = define('Sidebar', {
width: 220,
borderRight: `1px solid ${theme('colors-border')}`,
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
})
const Logo = define('Logo', {
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
fontSize: 20,
fontWeight: 'bold',
borderBottom: `1px solid ${theme('colors-border')}`,
})
const HamburgerButton = define('HamburgerButton', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
selectors: {
'&:hover span': { background: theme('colors-text') },
},
})
const HamburgerLine = define('HamburgerLine', {
width: 18,
height: 2,
background: theme('colors-textMuted'),
borderRadius: 1,
transition: 'background 0.15s',
})
const SectionLabel = define('SectionLabel', {
padding: '16px 16px 8px',
fontSize: 12,
fontWeight: 600,
color: theme('colors-textFaint'),
textTransform: 'uppercase',
letterSpacing: '0.05em',
})
const AppList = define('AppList', {
flex: 1,
overflow: 'auto',
})
const AppItem = define('AppItem', {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: '8px 16px',
color: theme('colors-textMuted'),
textDecoration: 'none',
fontSize: 14,
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
variants: {
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
},
})
const StatusDot = define('StatusDot', {
width: 8,
height: 8,
borderRadius: '50%',
flexShrink: 0,
variants: {
state: {
invalid: { background: theme('colors-statusInvalid') },
stopped: { background: theme('colors-statusStopped') },
starting: { background: theme('colors-statusStarting') },
running: { background: theme('colors-statusRunning') },
stopping: { background: theme('colors-statusStarting') },
},
inline: {
display: 'inline'
}
},
})
const SidebarFooter = define('SidebarFooter', {
padding: 16,
borderTop: `1px solid ${theme('colors-border')}`,
})
const NewAppButton = define('NewAppButton', {
display: 'block',
padding: '8px 12px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-textMuted'),
textDecoration: 'none',
fontSize: 14,
textAlign: 'center',
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
})
// Main pane
const Main = define('Main', {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})
const MainHeader = define('MainHeader', {
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
borderBottom: `1px solid ${theme('colors-border')}`,
})
const MainTitle = define('MainTitle', {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 20,
fontWeight: 600,
margin: 0,
})
const ClickableAppName = define('ClickableAppName', {
cursor: 'pointer',
borderRadius: theme('radius-md'),
padding: '2px 6px',
margin: '-2px -6px',
selectors: {
'&:hover': {
background: theme('colors-bgHover'),
},
},
})
const HeaderActions = define('HeaderActions', {
display: 'flex',
gap: 8,
})
const MainContent = define('MainContent', {
flex: 1,
padding: '10px 24px',
overflow: 'auto',
})
const Section = define('Section', {
marginBottom: 32,
})
const SectionTitle = define('SectionTitle', {
fontSize: 12,
fontWeight: 600,
color: theme('colors-textFaint'),
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 12,
paddingBottom: 8,
borderBottom: `1px solid ${theme('colors-border')}`,
})
const InfoRow = define('InfoRow', {
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
fontSize: 14,
})
const InfoLabel = define('InfoLabel', {
color: theme('colors-textFaint'),
width: 80,
flexShrink: 0,
})
const InfoValue = define('InfoValue', {
color: theme('colors-text'),
display: 'flex',
alignItems: 'center',
gap: 8,
})
const Link = define('Link', {
base: 'a',
color: theme('colors-link'),
textDecoration: 'none',
selectors: {
'&:hover': { textDecoration: 'underline' },
},
})
const Button = define('Button', {
base: 'button',
padding: '6px 12px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-text'),
fontSize: 13,
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover') },
},
variants: {
variant: {
danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') },
primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') },
},
},
})
const ActionBar = define('ActionBar', {
display: 'flex',
gap: 8,
marginTop: 24,
paddingTop: 24,
borderTop: `1px solid ${theme('colors-border')}`,
})
const EmptyState = define('EmptyState', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme('colors-textFaint'),
fontSize: 14,
})
const LogsContainer = define('LogsContainer', {
background: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
padding: 12,
fontFamily: theme('fonts-mono'),
fontSize: 12,
color: theme('colors-textMuted'),
maxHeight: 200,
overflow: 'auto',
render({ props: { children }, parts: { Root } }) {
return <Root ref={(el: HTMLElement | null) => {
if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight)
}}>{children}</Root>
}
})
const LogLine = define('LogLine', {
marginBottom: 4,
selectors: {
'&:last-child': { marginBottom: 0 },
},
})
const LogTime = define('LogTime', {
color: theme('colors-textFaintest'),
marginRight: 12,
display: 'inline',
})
let selectedTab: 'overview' | 'todo' = 'overview'
const TabContent = define('TabContent', {
display: 'none',
variants: {
active: {
display: 'block'
}
}
})
const TabBar = define('TabBar', {
display: 'flex',
gap: 24,
marginBottom: 20,
})
const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: 14,
color: theme('colors-textMuted'),
selectors: {
'&:hover': { color: theme('colors-text') },
},
variants: {
active: {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: 500,
},
},
})
function setSelectedTab(tab: 'overview' | 'todo') {
selectedTab = tab
render()
}
const Nav = () => {
return (
<TabBar>
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => setSelectedTab('overview')}>Overview</Tab>
<Tab active={selectedTab === 'todo' ? true : undefined} onClick={() => setSelectedTab('todo')}>TODO</Tab>
</TabBar>
)
}
const stateLabels: Record<AppState, string> = {
invalid: 'Invalid',
stopped: 'Stopped',
starting: 'Starting',
running: 'Running',
stopping: 'Stopping',
}
// Form styles for modal
const Form = define('Form', {
base: 'form',
display: 'flex',
flexDirection: 'column',
gap: 16,
})
const FormField = define('FormField', {
display: 'flex',
flexDirection: 'column',
gap: 6,
})
const FormLabel = define('FormLabel', {
base: 'label',
fontSize: 13,
fontWeight: 500,
color: theme('colors-text'),
})
const FormInput = define('FormInput', {
base: 'input',
padding: '8px 12px',
background: theme('colors-bgSubtle'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-text'),
fontSize: 14,
selectors: {
'&:focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
'&::placeholder': {
color: theme('colors-textFaint'),
},
},
})
const FormError = define('FormError', {
fontSize: 13,
color: theme('colors-error'),
})
const FormActions = define('FormActions', {
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 8,
})
// New App creation
let newAppError = ''
let newAppCreating = false
// Delete App confirmation
let deleteAppError = ''
let deleteAppDeleting = false
let deleteAppTarget: App | null = null
// Rename App
let renameAppError = ''
let renameAppRenaming = false
let renameAppTarget: App | null = null
async function createNewApp(input: HTMLInputElement) {
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
if (!name) {
newAppError = 'App name is required'
rerenderModal()
return
}
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
rerenderModal()
return
}
if (apps.some(a => a.name === name)) {
newAppError = 'An app with this name already exists'
rerenderModal()
return
}
newAppCreating = true
newAppError = ''
rerenderModal()
try {
const templates = generateTemplates(name)
for (const [filename, content] of Object.entries(templates)) {
const res = await fetch(`/api/sync/apps/${name}/files/${filename}`, {
method: 'PUT',
body: content,
})
if (!res.ok) {
throw new Error(`Failed to create ${filename}`)
}
}
// Success - close modal and select the new app
selectedApp = name
localStorage.setItem('selectedApp', name)
closeModal()
} catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false
rerenderModal()
}
}
function openNewAppModal() {
newAppError = ''
newAppCreating = false
openModal('New App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
createNewApp(input)
}}>
<FormField>
<FormLabel for="app-name">App Name</FormLabel>
<FormInput
id="app-name"
type="text"
placeholder="my-app"
autofocus
/>
{newAppError && <FormError>{newAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={newAppCreating}>
{newAppCreating ? 'Creating...' : 'Create App'}
</Button>
</FormActions>
</Form>
))
}
// Delete App confirmation modal
async function deleteApp(input: HTMLInputElement) {
if (!deleteAppTarget) return
const expected = `sudo rm ${deleteAppTarget.name}`
const value = input.value.trim()
if (value !== expected) {
deleteAppError = `Type "${expected}" to confirm`
rerenderModal()
return
}
deleteAppDeleting = true
deleteAppError = ''
rerenderModal()
try {
const res = await fetch(`/api/sync/apps/${deleteAppTarget.name}`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error(`Failed to delete app: ${res.statusText}`)
}
// Success - close modal and clear selection
if (selectedApp === deleteAppTarget.name) {
selectedApp = null
localStorage.removeItem('selectedApp')
}
closeModal()
} catch (err) {
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
deleteAppDeleting = false
rerenderModal()
}
}
function openDeleteAppModal(app: App) {
deleteAppError = ''
deleteAppDeleting = false
deleteAppTarget = app
const expected = `sudo rm ${app.name}`
openModal('Delete App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
deleteApp(input)
}}>
<p style={{ margin: '0 0 16px', color: theme('colors-textMuted') }}>
This will <strong style={{ color: theme('colors-error') }}>permanently delete</strong> <strong>{app.name}</strong> from the server.
</p>
<FormField>
<FormLabel for="delete-confirm">Type "{expected}" to confirm</FormLabel>
<FormInput
id="delete-confirm"
type="text"
placeholder={expected}
autofocus
/>
{deleteAppError && <FormError>{deleteAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={deleteAppDeleting}>
Cancel
</Button>
<Button type="submit" variant="danger" disabled={deleteAppDeleting}>
{deleteAppDeleting ? 'Deleting...' : 'Delete App'}
</Button>
</FormActions>
</Form>
))
}
// Rename App modal
async function doRenameApp(input: HTMLInputElement) {
if (!renameAppTarget) return
const newName = input.value.trim().toLowerCase().replace(/\s+/g, '-')
if (!newName) {
renameAppError = 'App name is required'
rerenderModal()
return
}
if (!/^[a-z][a-z0-9-]*$/.test(newName)) {
renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
rerenderModal()
return
}
if (newName === renameAppTarget.name) {
closeModal()
return
}
if (apps.some(a => a.name === newName)) {
renameAppError = 'An app with this name already exists'
rerenderModal()
return
}
renameAppRenaming = true
renameAppError = ''
rerenderModal()
try {
const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
})
const text = await res.text()
let data: { ok?: boolean, error?: string, name?: string }
try {
data = JSON.parse(text)
} catch {
throw new Error(`Server error: ${text.slice(0, 100)}`)
}
if (!res.ok || !data.ok) {
throw new Error(data.error || 'Failed to rename app')
}
// Success - update selection and close modal
selectedApp = data.name || newName
localStorage.setItem('selectedApp', data.name || newName)
closeModal()
} catch (err) {
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
renameAppRenaming = false
rerenderModal()
}
}
function openRenameAppModal(app: App) {
renameAppError = ''
renameAppRenaming = false
renameAppTarget = app
openModal('Rename App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
doRenameApp(input)
}}>
<FormField>
<FormLabel for="rename-app">App Name</FormLabel>
<FormInput
id="rename-app"
type="text"
value={renameAppTarget?.name ?? ''}
autofocus
/>
{renameAppError && <FormError>{renameAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={renameAppRenaming}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={renameAppRenaming}>
{renameAppRenaming ? 'Renaming...' : 'Rename'}
</Button>
</FormActions>
</Form>
))
}
// Actions - call API then let SSE update the state
const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
const selectApp = (name: string) => {
selectedApp = name
localStorage.setItem('selectedApp', name)
render()
}
const toggleSidebar = () => {
sidebarCollapsed = !sidebarCollapsed
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed))
render()
}
const OpenEmojiPicker = define('OpenEmojiPicker', {
cursor: 'pointer',
render({ props: { app, children }, parts: { Root } }) {
return <Root onClick={() => openEmojiPicker((emoji) => {
if (!app) return
fetch(`/api/apps/${app.name}/icon?icon=${emoji}`, { method: 'POST' })
app.icon = emoji
render()
})}>{children}</Root>
}
})
const AppDetail = ({ app }: { app: App }) => (
<>
<MainHeader>
<MainTitle>
<OpenEmojiPicker app={app}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
{/* <Button>Settings</Button> */}
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
</HeaderActions>
</MainHeader>
<MainContent>
<Nav />
<TabContent active={selectedTab === 'overview'}>
<Section>
<SectionTitle>Status</SectionTitle>
<InfoRow>
<InfoLabel>State</InfoLabel>
<InfoValue>
<StatusDot state={app.state} />
{stateLabels[app.state]}
</InfoValue>
</InfoRow>
{app.state === 'running' && app.port && (
<InfoRow>
<InfoLabel>URL</InfoLabel>
<InfoValue>
<Link href={`http://localhost:${app.port}`} target="_blank">
http://localhost:{app.port}
</Link>
</InfoValue>
</InfoRow>
)}
{app.state === 'running' && app.port && (
<InfoRow>
<InfoLabel>Port</InfoLabel>
<InfoValue>
{app.port}
</InfoValue>
</InfoRow>
)}
{app.started && (
<InfoRow>
<InfoLabel>Started</InfoLabel>
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
</InfoRow>
)}
{app.error && (
<InfoRow>
<InfoLabel>Error</InfoLabel>
<InfoValue style={{ color: theme('colors-error') }}>
{app.error}
</InfoValue>
</InfoRow>
)}
</Section>
<Section>
<SectionTitle>Logs</SectionTitle>
<LogsContainer>
{app.logs?.length ? (
app.logs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
</LogLine>
))
) : (
<LogLine>
<LogTime>--:--:--</LogTime>
<span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
</LogLine>
)}
</LogsContainer>
</Section>
<ActionBar>
{app.state === 'stopped' && (
<Button variant="primary" onClick={() => startApp(app.name)}>
Start
</Button>
)}
{app.state === 'running' && (
<>
<Button onClick={() => restartApp(app.name)}>Restart</Button>
<Button variant="danger" onClick={() => stopApp(app.name)}>
Stop
</Button>
</>
)}
{(app.state === 'starting' || app.state === 'stopping') && (
<Button disabled>{stateLabels[app.state]}...</Button>
)}
</ActionBar>
</TabContent>
<TabContent active={selectedTab === 'todo'}>
<h1>hardy har har</h1>
</TabContent>
</MainContent>
</>
)
const Dashboard = () => {
const selected = apps.find(a => a.name === selectedApp)
return (
<Layout>
<Styles />
<Sidebar style={sidebarCollapsed ? { width: 'auto' } : undefined}>
<Logo>
{!sidebarCollapsed && <span>🐾 Toes</span>}
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
</Logo>
{!sidebarCollapsed && <SectionLabel>Apps</SectionLabel>}
<AppList>
{apps.map(app => (
<AppItem
key={app.name}
onClick={() => selectApp(app.name)}
selected={app.name === selectedApp ? true : undefined}
style={sidebarCollapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={sidebarCollapsed ? app.name : undefined}
>
{sidebarCollapsed ? (
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} style={{ marginLeft: 'auto' }} />
</>
)}
</AppItem>
))}
</AppList>
{!sidebarCollapsed && (
<SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</SidebarFooter>
)}
</Sidebar>
<Main>
{selected ? (
<AppDetail app={selected} />
) : (
<EmptyState>Select an app to view details</EmptyState>
)}
</Main>
<Modal />
</Layout>
)
}
const render = () => { const render = () => {
renderApp(<Dashboard />, document.getElementById('app')!) renderApp(<Dashboard render={render} />, document.getElementById('app')!)
} }
// Initialize render functions // Initialize render functions
@ -896,8 +30,8 @@ setTheme()
// SSE connection // SSE connection
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
apps = JSON.parse(e.data) setApps(JSON.parse(e.data))
const valid = selectedApp && apps.some(a => a.name === selectedApp) const valid = selectedApp && apps.some(a => a.name === selectedApp)
if (!valid && apps.length) selectedApp = apps[0]!.name if (!valid && apps.length) setSelectedApp(apps[0]!.name)
render() render()
} }

View File

@ -0,0 +1,83 @@
import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { selectedApp, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
import { theme } from '../themes'
let deleteAppError = ''
let deleteAppDeleting = false
let deleteAppTarget: App | null = null
async function deleteApp(input: HTMLInputElement) {
if (!deleteAppTarget) return
const expected = `sudo rm ${deleteAppTarget.name}`
const value = input.value.trim()
if (value !== expected) {
deleteAppError = `Type "${expected}" to confirm`
rerenderModal()
return
}
deleteAppDeleting = true
deleteAppError = ''
rerenderModal()
try {
const res = await fetch(`/api/sync/apps/${deleteAppTarget.name}`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error(`Failed to delete app: ${res.statusText}`)
}
// Success - close modal and clear selection
if (selectedApp === deleteAppTarget.name) {
setSelectedApp(null)
}
closeModal()
} catch (err) {
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
deleteAppDeleting = false
rerenderModal()
}
}
export function openDeleteAppModal(app: App) {
deleteAppError = ''
deleteAppDeleting = false
deleteAppTarget = app
const expected = `sudo rm ${app.name}`
openModal('Delete App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
deleteApp(input)
}}>
<p style={{ margin: '0 0 16px', color: theme('colors-textMuted') }}>
This will <strong style={{ color: theme('colors-error') }}>permanently delete</strong> <strong>{app.name}</strong> from the server.
</p>
<FormField>
<FormLabel for="delete-confirm">Type "{expected}" to confirm</FormLabel>
<FormInput
id="delete-confirm"
type="text"
placeholder={expected}
autofocus
/>
{deleteAppError && <FormError>{deleteAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={deleteAppDeleting}>
Cancel
</Button>
<Button type="submit" variant="danger" disabled={deleteAppDeleting}>
{deleteAppDeleting ? 'Deleting...' : 'Delete App'}
</Button>
</FormActions>
</Form>
))
}

View File

@ -0,0 +1,87 @@
import { generateTemplates } from '../../shared/templates'
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
let newAppError = ''
let newAppCreating = false
async function createNewApp(input: HTMLInputElement) {
const name = input.value.trim().toLowerCase().replace(/\s+/g, '-')
if (!name) {
newAppError = 'App name is required'
rerenderModal()
return
}
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
newAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
rerenderModal()
return
}
if (apps.some(a => a.name === name)) {
newAppError = 'An app with this name already exists'
rerenderModal()
return
}
newAppCreating = true
newAppError = ''
rerenderModal()
try {
const templates = generateTemplates(name)
for (const [filename, content] of Object.entries(templates)) {
const res = await fetch(`/api/sync/apps/${name}/files/${filename}`, {
method: 'PUT',
body: content,
})
if (!res.ok) {
throw new Error(`Failed to create ${filename}`)
}
}
// Success - close modal and select the new app
setSelectedApp(name)
closeModal()
} catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false
rerenderModal()
}
}
export function openNewAppModal() {
newAppError = ''
newAppCreating = false
openModal('New App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
createNewApp(input)
}}>
<FormField>
<FormLabel for="app-name">App Name</FormLabel>
<FormInput
id="app-name"
type="text"
placeholder="my-app"
autofocus
/>
{newAppError && <FormError>{newAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={newAppCreating}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={newAppCreating}>
{newAppCreating ? 'Creating...' : 'Create App'}
</Button>
</FormActions>
</Form>
))
}

View File

@ -0,0 +1,102 @@
import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
let renameAppError = ''
let renameAppRenaming = false
let renameAppTarget: App | null = null
async function doRenameApp(input: HTMLInputElement) {
if (!renameAppTarget) return
const newName = input.value.trim().toLowerCase().replace(/\s+/g, '-')
if (!newName) {
renameAppError = 'App name is required'
rerenderModal()
return
}
if (!/^[a-z][a-z0-9-]*$/.test(newName)) {
renameAppError = 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens'
rerenderModal()
return
}
if (newName === renameAppTarget.name) {
closeModal()
return
}
if (apps.some(a => a.name === newName)) {
renameAppError = 'An app with this name already exists'
rerenderModal()
return
}
renameAppRenaming = true
renameAppError = ''
rerenderModal()
try {
const res = await fetch(`/api/apps/${renameAppTarget.name}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
})
const text = await res.text()
let data: { ok?: boolean, error?: string, name?: string }
try {
data = JSON.parse(text)
} catch {
throw new Error(`Server error: ${text.slice(0, 100)}`)
}
if (!res.ok || !data.ok) {
throw new Error(data.error || 'Failed to rename app')
}
// Success - update selection and close modal
setSelectedApp(data.name || newName)
closeModal()
} catch (err) {
renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
renameAppRenaming = false
rerenderModal()
}
}
export function openRenameAppModal(app: App) {
renameAppError = ''
renameAppRenaming = false
renameAppTarget = app
openModal('Rename App', () => (
<Form onSubmit={(e: Event) => {
e.preventDefault()
const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement
doRenameApp(input)
}}>
<FormField>
<FormLabel for="rename-app">App Name</FormLabel>
<FormInput
id="rename-app"
type="text"
value={renameAppTarget?.name ?? ''}
autofocus
/>
{renameAppError && <FormError>{renameAppError}</FormError>}
</FormField>
<FormActions>
<Button type="button" onClick={closeModal} disabled={renameAppRenaming}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={renameAppRenaming}>
{renameAppRenaming ? 'Renaming...' : 'Rename'}
</Button>
</FormActions>
</Form>
))
}

View File

@ -0,0 +1,3 @@
export { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp'

34
src/client/state.ts Normal file
View File

@ -0,0 +1,34 @@
import type { App } from '../shared/types'
// UI state (survives re-renders)
export let selectedApp: string | null = localStorage.getItem('selectedApp')
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
// Server state (from SSE)
export let apps: App[] = []
// Tab state
export let selectedTab: 'overview' | 'todo' = 'overview'
// State setters
export function setSelectedApp(name: string | null) {
selectedApp = name
if (name) {
localStorage.setItem('selectedApp', name)
} else {
localStorage.removeItem('selectedApp')
}
}
export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed))
}
export function setApps(newApps: App[]) {
apps = newApps
}
export function setSelectedTab(tab: 'overview' | 'todo') {
selectedTab = tab
}

View File

@ -0,0 +1,46 @@
import { define } from '@because/forge'
import { theme } from '../themes'
export const Button = define('Button', {
base: 'button',
padding: '6px 12px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-text'),
fontSize: 13,
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover') },
},
variants: {
variant: {
danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') },
primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') },
},
},
})
export const ActionBar = define('ActionBar', {
display: 'flex',
gap: 8,
marginTop: 24,
paddingTop: 24,
borderTop: `1px solid ${theme('colors-border')}`,
})
export const NewAppButton = define('NewAppButton', {
display: 'block',
padding: '8px 12px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-textMuted'),
textDecoration: 'none',
fontSize: 14,
textAlign: 'center',
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
})

View File

@ -0,0 +1,53 @@
import { define } from '@because/forge'
import { theme } from '../themes'
export const Form = define('Form', {
base: 'form',
display: 'flex',
flexDirection: 'column',
gap: 16,
})
export const FormField = define('FormField', {
display: 'flex',
flexDirection: 'column',
gap: 6,
})
export const FormLabel = define('FormLabel', {
base: 'label',
fontSize: 13,
fontWeight: 500,
color: theme('colors-text'),
})
export const FormInput = define('FormInput', {
base: 'input',
padding: '8px 12px',
background: theme('colors-bgSubtle'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
color: theme('colors-text'),
fontSize: 14,
selectors: {
'&:focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
'&::placeholder': {
color: theme('colors-textFaint'),
},
},
})
export const FormError = define('FormError', {
fontSize: 13,
color: theme('colors-error'),
})
export const FormActions = define('FormActions', {
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 8,
})

View File

@ -0,0 +1,34 @@
export { ActionBar, Button, NewAppButton } from './buttons'
export { Form, FormActions, FormError, FormField, FormInput, FormLabel } from './forms'
export {
AppItem,
AppList,
ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions,
Layout,
Logo,
Main,
MainContent,
MainHeader,
MainTitle,
SectionLabel,
Sidebar,
SidebarFooter,
} from './layout'
export { LogLine, LogsContainer, LogTime } from './logs.tsx'
export {
EmptyState,
InfoLabel,
InfoRow,
InfoValue,
Link,
Section,
SectionTitle,
stateLabels,
StatusDot,
Tab,
TabBar,
TabContent,
} from './misc'

137
src/client/styles/layout.ts Normal file
View File

@ -0,0 +1,137 @@
import { define } from '@because/forge'
import { theme } from '../themes'
export const Layout = define('Layout', {
display: 'flex',
height: '100vh',
fontFamily: theme('fonts-sans'),
background: theme('colors-bg'),
color: theme('colors-text'),
})
export const Sidebar = define('Sidebar', {
width: 220,
borderRight: `1px solid ${theme('colors-border')}`,
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
})
export const Logo = define('Logo', {
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
fontSize: 20,
fontWeight: 'bold',
borderBottom: `1px solid ${theme('colors-border')}`,
})
export const HamburgerButton = define('HamburgerButton', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
selectors: {
'&:hover span': { background: theme('colors-text') },
},
})
export const HamburgerLine = define('HamburgerLine', {
width: 18,
height: 2,
background: theme('colors-textMuted'),
borderRadius: 1,
transition: 'background 0.15s',
})
export const SectionLabel = define('SectionLabel', {
padding: '16px 16px 8px',
fontSize: 12,
fontWeight: 600,
color: theme('colors-textFaint'),
textTransform: 'uppercase',
letterSpacing: '0.05em',
})
export const AppList = define('AppList', {
flex: 1,
overflow: 'auto',
})
export const AppItem = define('AppItem', {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: '8px 16px',
color: theme('colors-textMuted'),
textDecoration: 'none',
fontSize: 14,
cursor: 'pointer',
selectors: {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
},
variants: {
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
},
})
export const SidebarFooter = define('SidebarFooter', {
padding: 16,
borderTop: `1px solid ${theme('colors-border')}`,
})
export const Main = define('Main', {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})
export const MainHeader = define('MainHeader', {
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
borderBottom: `1px solid ${theme('colors-border')}`,
})
export const MainTitle = define('MainTitle', {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 20,
fontWeight: 600,
margin: 0,
})
export const ClickableAppName = define('ClickableAppName', {
cursor: 'pointer',
borderRadius: theme('radius-md'),
padding: '2px 6px',
margin: '-2px -6px',
selectors: {
'&:hover': {
background: theme('colors-bgHover'),
},
},
})
export const HeaderActions = define('HeaderActions', {
display: 'flex',
gap: 8,
})
export const MainContent = define('MainContent', {
flex: 1,
padding: '10px 24px',
overflow: 'auto',
})

View File

@ -0,0 +1,32 @@
import { define } from '@because/forge'
import { theme } from '../themes'
export const LogsContainer = define('LogsContainer', {
background: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
padding: 12,
fontFamily: theme('fonts-mono'),
fontSize: 12,
color: theme('colors-textMuted'),
maxHeight: 200,
overflow: 'auto',
render({ props: { children }, parts: { Root } }) {
return <Root ref={(el: HTMLElement | null) => {
if (el) requestAnimationFrame(() => el.scrollTop = el.scrollHeight)
}}>{children}</Root>
}
})
export const LogLine = define('LogLine', {
marginBottom: 4,
selectors: {
'&:last-child': { marginBottom: 0 },
},
})
export const LogTime = define('LogTime', {
color: theme('colors-textFaintest'),
marginRight: 12,
display: 'inline',
})

121
src/client/styles/misc.ts Normal file
View File

@ -0,0 +1,121 @@
import { define } from '@because/forge'
import { theme } from '../themes'
import type { AppState } from '../../shared/types'
export const StatusDot = define('StatusDot', {
width: 8,
height: 8,
borderRadius: '50%',
flexShrink: 0,
variants: {
state: {
invalid: { background: theme('colors-statusInvalid') },
stopped: { background: theme('colors-statusStopped') },
starting: { background: theme('colors-statusStarting') },
running: { background: theme('colors-statusRunning') },
stopping: { background: theme('colors-statusStarting') },
},
inline: {
display: 'inline'
}
},
})
export const Section = define('Section', {
marginBottom: 32,
})
export const SectionTitle = define('SectionTitle', {
fontSize: 12,
fontWeight: 600,
color: theme('colors-textFaint'),
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 12,
paddingBottom: 8,
borderBottom: `1px solid ${theme('colors-border')}`,
})
export const InfoRow = define('InfoRow', {
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
fontSize: 14,
})
export const InfoLabel = define('InfoLabel', {
color: theme('colors-textFaint'),
width: 80,
flexShrink: 0,
})
export const InfoValue = define('InfoValue', {
color: theme('colors-text'),
display: 'flex',
alignItems: 'center',
gap: 8,
})
export const Link = define('Link', {
base: 'a',
color: theme('colors-link'),
textDecoration: 'none',
selectors: {
'&:hover': { textDecoration: 'underline' },
},
})
export const EmptyState = define('EmptyState', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme('colors-textFaint'),
fontSize: 14,
})
export const TabBar = define('TabBar', {
display: 'flex',
gap: 24,
marginBottom: 20,
})
export const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: 14,
color: theme('colors-textMuted'),
selectors: {
'&:hover': { color: theme('colors-text') },
},
variants: {
active: {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: 500,
},
},
})
export const TabContent = define('TabContent', {
display: 'none',
variants: {
active: {
display: 'block'
}
}
})
export const stateLabels: Record<AppState, string> = {
invalid: 'Invalid',
stopped: 'Stopped',
starting: 'Starting',
running: 'Running',
stopping: 'Stopping',
}