Compare commits
No commits in common. "f12854fc045029de595299b68d8190b412e3d2c1" and "060c93c37874d697471a0556d8862da1558b7b3f" have entirely different histories.
f12854fc04
...
060c93c378
52
CLAUDE.md
52
CLAUDE.md
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
## What It Is
|
## What It Is
|
||||||
|
|
||||||
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
|
Personal web server framework that auto-discovers and runs multiple web apps on your home network. "Set it up, turn it on, forget about the cloud."
|
||||||
|
|
||||||
"Plug it in, turn it on, and forget about the cloud."
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|
@ -15,38 +13,17 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
### Server (`src/server/`)
|
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle
|
||||||
- `apps.ts` - **The heart**: app discovery, process management, health checks, auto-restart
|
- `src/server/index.tsx` - Entry point (minimal, just initializes Hype)
|
||||||
- `api/apps.ts` - REST API for app lifecycle (start/stop/restart, logs, icons, rename)
|
- `src/pages/index.tsx` - Dashboard UI
|
||||||
- `api/sync.ts` - File sync protocol (manifest, push/pull, watch)
|
|
||||||
- `index.tsx` - Entry point (minimal, initializes Hype)
|
|
||||||
- `shell.tsx` - HTML shell for web UI
|
|
||||||
|
|
||||||
### Client (`src/client/`)
|
|
||||||
- `components/` - Dashboard, Sidebar, AppDetail, Nav
|
|
||||||
- `modals/` - NewApp, RenameApp, DeleteApp dialogs
|
|
||||||
- `styles/` - Forge CSS-in-JS (themes, buttons, forms, layout)
|
|
||||||
- `state.ts` - Client state management
|
|
||||||
- `api.ts` - API client
|
|
||||||
|
|
||||||
### CLI (`src/cli/`)
|
|
||||||
- `commands/manage.ts` - list, start, stop, restart, info, new, rename, delete, open
|
|
||||||
- `commands/sync.ts` - push, pull, sync
|
|
||||||
- `commands/logs.ts` - log viewing with tail support
|
|
||||||
|
|
||||||
### Shared (`src/shared/`)
|
|
||||||
- `types.ts` - App, AppState, Manifest interfaces
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
|
||||||
- `TODO.txt` - Task list
|
- `TODO.md` - User-maintained task list (read this!)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Bun** runtime (not Node)
|
- **Bun** runtime (not Node)
|
||||||
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
|
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
|
||||||
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
||||||
- **Commander** + **kleur** for CLI
|
|
||||||
- TypeScript + Hono JSX
|
- TypeScript + Hono JSX
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
@ -59,7 +36,7 @@ bun run --hot src/server/index.tsx # Dev mode with hot reload
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// apps/example/index.tsx
|
// apps/example/index.tsx
|
||||||
import { Hype } from "@because/hype"
|
import { Hype } from "hype"
|
||||||
const app = new Hype()
|
const app = new Hype()
|
||||||
app.get("/", (c) => c.html(<h1>Content</h1>))
|
app.get("/", (c) => c.html(<h1>Content</h1>))
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
@ -71,23 +48,12 @@ export default app.defaults
|
||||||
- Each app is isolated process with own dependencies
|
- Each app is isolated process with own dependencies
|
||||||
- No path-based routing - apps run on separate ports
|
- No path-based routing - apps run on separate ports
|
||||||
- `DATA_DIR` env controls where apps are discovered
|
- `DATA_DIR` env controls where apps are discovered
|
||||||
- Path aliases: `$` → server, `@` → shared, `%` → lib
|
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
### Infrastructure (Complete)
|
- Core infrastructure: ✓ Complete (discovery, spawn, watch, ports, UI)
|
||||||
- App discovery, spawn, watch, auto-restart with exponential backoff
|
- Apps: basic, profile (working); risk, tictactoe (empty)
|
||||||
- Health checks every 30s (3 failures trigger restart)
|
- Check TODO.md for planned features
|
||||||
- Port pool (3001-3100), sticky allocation per app
|
|
||||||
- SSE streams for real-time app state and log updates
|
|
||||||
- File sync protocol with hash-based manifests
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
|
|
||||||
- File sync: `toes push|pull|sync`
|
|
||||||
- Logs: `toes logs [-f] <app>`
|
|
||||||
|
|
||||||
Check `TODO.txt` for planned features
|
|
||||||
|
|
||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Toes is a personal web server you run in your home.
|
Toes is a personal web server you run in your home.
|
||||||
|
|
||||||
Plug it in, turn it on, and forget about the cloud.
|
Turn it on and forget about the cloud.
|
||||||
|
|
||||||
## quickstart
|
## quickstart
|
||||||
|
|
||||||
|
|
|
||||||
5
TODO.txt
5
TODO.txt
|
|
@ -37,9 +37,8 @@
|
||||||
[x] `toes pull`
|
[x] `toes pull`
|
||||||
[x] `toes push`
|
[x] `toes push`
|
||||||
[x] `toes sync`
|
[x] `toes sync`
|
||||||
[x] `toes new --spa`
|
[ ] `toes new --spa`
|
||||||
[x] `toes new --ssr`
|
[ ] `toes new --ssr`
|
||||||
[x] `toes new --bare`
|
|
||||||
[ ] needs to either check toes.local or take something like TOES_URL
|
[ ] needs to either check toes.local or take something like TOES_URL
|
||||||
|
|
||||||
## webui
|
## webui
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
export { logApp } from './logs'
|
|
||||||
export {
|
|
||||||
infoApp,
|
|
||||||
listApps,
|
|
||||||
newApp,
|
|
||||||
openApp,
|
|
||||||
renameApp,
|
|
||||||
restartApp,
|
|
||||||
rmApp,
|
|
||||||
startApp,
|
|
||||||
stopApp,
|
|
||||||
} from './manage'
|
|
||||||
export { getApp, pullApp, pushApp, syncApp } from './sync'
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
import type { App } from '@types'
|
|
||||||
import { generateTemplates, type TemplateType } 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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NewAppOptions {
|
|
||||||
ssr?: boolean
|
|
||||||
bare?: boolean
|
|
||||||
spa?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function newApp(name: string | undefined, options: NewAppOptions) {
|
|
||||||
const appPath = name ? join(process.cwd(), name) : process.cwd()
|
|
||||||
const appName = name ?? basename(process.cwd())
|
|
||||||
|
|
||||||
// Determine template type from flags
|
|
||||||
let template: TemplateType = 'ssr'
|
|
||||||
if (options.bare) template = 'bare'
|
|
||||||
else if (options.spa) template = 'spa'
|
|
||||||
|
|
||||||
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 templateLabel = template === 'ssr' ? '' : ` (${template})`
|
|
||||||
const ok = await confirm(`Create ${color.bold(appName)}${templateLabel} in ${appPath}?`)
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
const templates = generateTemplates(appName, template)
|
|
||||||
|
|
||||||
// Create directories for all template files
|
|
||||||
for (const filename of Object.keys(templates)) {
|
|
||||||
const dir = join(appPath, filename, '..')
|
|
||||||
mkdirSync(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
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`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
793
src/cli/index.ts
793
src/cli/index.ts
|
|
@ -1,4 +1,793 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { program } from './setup'
|
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'
|
||||||
|
|
||||||
program.parse()
|
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()
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
137
src/cli/setup.ts
137
src/cli/setup.ts
|
|
@ -1,137 +0,0 @@
|
||||||
import { program } from 'commander'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
|
|
||||||
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('v0.0.3', '-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)')
|
|
||||||
.option('--ssr', 'SSR template with pages directory (default)')
|
|
||||||
.option('--bare', 'minimal template with no pages')
|
|
||||||
.option('--spa', 'single-page app with client-side rendering')
|
|
||||||
.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 }
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
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' })
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export { AppDetail } from './AppDetail'
|
|
||||||
export { Dashboard } from './Dashboard'
|
|
||||||
export { Nav } from './Nav'
|
|
||||||
export { Sidebar } from './Sidebar'
|
|
||||||
|
|
@ -1,11 +1,877 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
import { render as renderApp } from 'hono/jsx/dom'
|
||||||
import { Dashboard } from './components'
|
import { define, Styles } from '@because/forge'
|
||||||
import { apps, selectedApp, setApps, setSelectedApp } from './state'
|
import type { App, AppState } from '../shared/types'
|
||||||
import { initModal } from './components/modal'
|
import { generateTemplates } from '../shared/templates'
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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 render={render} />, document.getElementById('app')!)
|
renderApp(<Dashboard />, document.getElementById('app')!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize render functions
|
// Initialize render functions
|
||||||
|
|
@ -30,8 +896,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 => {
|
||||||
setApps(JSON.parse(e.data))
|
apps = 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) setSelectedApp(apps[0]!.name)
|
if (!valid && apps.length) selectedApp = apps[0]!.name
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { openDeleteAppModal } from './DeleteApp'
|
|
||||||
export { openNewAppModal } from './NewApp'
|
|
||||||
export { openRenameAppModal } from './RenameApp'
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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') },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
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',
|
|
||||||
})
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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',
|
|
||||||
})
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +1,69 @@
|
||||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
||||||
import { join, relative } from 'path'
|
|
||||||
import { DEFAULT_EMOJI } from './types'
|
import { DEFAULT_EMOJI } from './types'
|
||||||
|
|
||||||
export type TemplateType = 'ssr' | 'bare' | 'spa'
|
export interface AppTemplates {
|
||||||
|
'index.tsx': string
|
||||||
export type AppTemplates = Record<string, string>
|
'package.json': string
|
||||||
|
'tsconfig.json': string
|
||||||
interface TemplateVars {
|
'.npmrc': string
|
||||||
APP_NAME: string
|
'src/pages/index.tsx': string
|
||||||
APP_EMOJI: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEMPLATES_DIR = join(import.meta.dirname, '../../templates')
|
const tsconfig = {
|
||||||
|
compilerOptions: {
|
||||||
function readDir(dir: string): string[] {
|
lib: ['ESNext'],
|
||||||
const files: string[] = []
|
target: 'ESNext',
|
||||||
for (const entry of readdirSync(dir)) {
|
module: 'Preserve',
|
||||||
const path = join(dir, entry)
|
moduleDetection: 'force',
|
||||||
if (statSync(path).isDirectory()) {
|
jsx: 'react-jsx',
|
||||||
files.push(...readDir(path))
|
jsxImportSource: 'hono/jsx',
|
||||||
} else {
|
allowJs: true,
|
||||||
files.push(path)
|
moduleResolution: 'bundler',
|
||||||
}
|
allowImportingTsExtensions: true,
|
||||||
}
|
verbatimModuleSyntax: true,
|
||||||
return files
|
noEmit: true,
|
||||||
|
strict: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
noFallthroughCasesInSwitch: true,
|
||||||
|
noUncheckedIndexedAccess: true,
|
||||||
|
noImplicitOverride: true,
|
||||||
|
noUnusedLocals: false,
|
||||||
|
noUnusedParameters: false,
|
||||||
|
noPropertyAccessFromIndexSignature: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceVars(content: string, vars: TemplateVars): string {
|
export function generateTemplates(appName: string): AppTemplates {
|
||||||
return content
|
const packageJson = {
|
||||||
.replace(/\$\$APP_NAME\$\$/g, vars.APP_NAME)
|
name: appName,
|
||||||
.replace(/\$\$APP_EMOJI\$\$/g, vars.APP_EMOJI)
|
module: 'index.tsx',
|
||||||
}
|
type: 'module',
|
||||||
|
private: true,
|
||||||
export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
|
scripts: {
|
||||||
const vars: TemplateVars = {
|
toes: 'bun run --watch index.tsx',
|
||||||
APP_NAME: appName,
|
start: 'bun toes',
|
||||||
APP_EMOJI: DEFAULT_EMOJI,
|
dev: 'bun run --hot index.tsx',
|
||||||
}
|
},
|
||||||
|
toes: {
|
||||||
const result: AppTemplates = {}
|
icon: DEFAULT_EMOJI,
|
||||||
|
},
|
||||||
// Read shared files from templates/
|
devDependencies: {
|
||||||
for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) {
|
'@types/bun': 'latest',
|
||||||
const path = join(TEMPLATES_DIR, filename)
|
},
|
||||||
const content = readFileSync(path, 'utf-8')
|
peerDependencies: {
|
||||||
result[filename] = replaceVars(content, vars)
|
typescript: '^5.9.2',
|
||||||
}
|
},
|
||||||
|
dependencies: {
|
||||||
// Read template-specific files
|
'@because/hype': '*',
|
||||||
const templateDir = join(TEMPLATES_DIR, template)
|
'@because/forge': '*',
|
||||||
for (const path of readDir(templateDir)) {
|
'@because/howl': '*',
|
||||||
const filename = relative(templateDir, path)
|
},
|
||||||
const content = readFileSync(path, 'utf-8')
|
}
|
||||||
result[filename] = replaceVars(content, vars)
|
|
||||||
}
|
return {
|
||||||
|
'.npmrc': 'registry=https://npm.nose.space',
|
||||||
return result
|
'package.json': JSON.stringify(packageJson, null, 2) + '\n',
|
||||||
|
'src/pages/index.tsx': `export default () => <h1>${appName}</h1>`,
|
||||||
|
'index.tsx': `import { Hype } from '@because/hype'\nconst app = new Hype\nexport default app.defaults`,
|
||||||
|
'tsconfig.json': JSON.stringify(tsconfig, null, 2) + '\n',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
registry=https://npm.nose.space
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
const app = new Hype()
|
|
||||||
|
|
||||||
app.get('/', c => c.text('$$APP_NAME$$'))
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"name": "$$APP_NAME$$",
|
|
||||||
"module": "index.tsx",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx",
|
|
||||||
"start": "bun toes",
|
|
||||||
"dev": "bun run --hot index.tsx"
|
|
||||||
},
|
|
||||||
"toes": {
|
|
||||||
"icon": "$$APP_EMOJI$$"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/howl": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './src/server'
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
|
|
@ -1,36 +0,0 @@
|
||||||
import { render, useState } from 'hono/jsx/dom'
|
|
||||||
import { define } from '@because/forge'
|
|
||||||
|
|
||||||
const Wrapper = define({
|
|
||||||
margin: '0 auto',
|
|
||||||
marginTop: 50,
|
|
||||||
width: '50vw',
|
|
||||||
border: '1px solid black',
|
|
||||||
padding: 24,
|
|
||||||
textAlign: 'center'
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<h1>It works!</h1>
|
|
||||||
<h2>Count: {count}</h2>
|
|
||||||
<div>
|
|
||||||
<button onClick={() => setCount(c => c + 1)}>+</button>
|
|
||||||
|
|
||||||
<button onClick={() => setCount(c => c && c - 1)}>-</button>
|
|
||||||
</div>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Render error:', error)
|
|
||||||
return <><h1>Error</h1><pre>{error instanceof Error ? error : new Error(String(error))}</pre></>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.getElementById('root')!
|
|
||||||
render(<App />, root)
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
section {
|
|
||||||
max-width: 500px;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 200%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hype {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
background: linear-gradient(45deg,
|
|
||||||
#ff00ff 0%,
|
|
||||||
#00ffff 33%,
|
|
||||||
#ffff00 66%,
|
|
||||||
#ff00ff 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
animation: gradientShift 15s ease infinite;
|
|
||||||
color: black;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gradientShift {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { $ } from 'bun'
|
|
||||||
|
|
||||||
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|
|
||||||
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
|
|
||||||
|
|
||||||
export default () => <>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>hype</title>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="color-scheme" content="light dark" />
|
|
||||||
|
|
||||||
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
|
|
||||||
<script dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
window.GIT_HASH = '${GIT_HASH}';
|
|
||||||
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
|
|
||||||
`
|
|
||||||
}} />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="viewport">
|
|
||||||
<main>
|
|
||||||
<div id="root" />
|
|
||||||
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
</>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
const app = new Hype({ layout: false })
|
|
||||||
|
|
||||||
// custom routes go here
|
|
||||||
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './src/server'
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export default () => <h1>$$APP_NAME$$</h1>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
const app = new Hype()
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "Preserve",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "hono/jsx",
|
|
||||||
"allowJs": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$*": ["src/server/*"],
|
|
||||||
"#*": ["src/client/*"],
|
|
||||||
"@*": ["src/shared/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"exclude": ["templates"],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user