diff --git a/CLAUDE.md b/CLAUDE.md index 0e3af9b..670804d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,14 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho - `commands/logs.ts` - log viewing with tail support ### Shared (`src/shared/`) +- Code shared between frontend (browser) and backend (server) - `types.ts` - App, AppState, Manifest interfaces +- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser) + +### Lib (`src/lib/`) +- Code shared between CLI and server (server-side only) +- `templates.ts` - Template generation for new apps +- Can use filesystem and Node APIs (never runs in browser) ### Other - `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts index e9c59c1..16e2bb1 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -1,5 +1,5 @@ import type { App } from '@types' -import { generateTemplates, type TemplateType } from '@templates' +import { generateTemplates, type TemplateType } from '%templates' import color from 'kleur' import { existsSync, mkdirSync, writeFileSync } from 'fs' import { basename, join } from 'path' diff --git a/src/client/modals/NewApp.tsx b/src/client/modals/NewApp.tsx index ab4fa5a..c3978e6 100644 --- a/src/client/modals/NewApp.tsx +++ b/src/client/modals/NewApp.tsx @@ -1,4 +1,3 @@ -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' @@ -32,16 +31,16 @@ async function createNewApp(input: HTMLInputElement) { rerenderModal() try { - const templates = generateTemplates(name) + const res = await fetch('/api/apps', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 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}`) - } + const data = await res.json() + + if (!res.ok || !data.ok) { + throw new Error(data.error || 'Failed to create app') } // Success - close modal and select the new app diff --git a/src/shared/templates.ts b/src/lib/templates.ts similarity index 93% rename from src/shared/templates.ts rename to src/lib/templates.ts index bcc0170..c09dd0b 100644 --- a/src/shared/templates.ts +++ b/src/lib/templates.ts @@ -1,17 +1,17 @@ +import { DEFAULT_EMOJI } from '@types' import { readdirSync, readFileSync, statSync } from 'fs' import { join, relative } from 'path' -import { DEFAULT_EMOJI } from './types' export type TemplateType = 'ssr' | 'bare' | 'spa' export type AppTemplates = Record interface TemplateVars { - APP_NAME: string APP_EMOJI: string + APP_NAME: string } -const TEMPLATES_DIR = join(import.meta.dirname, '../../templates') +const TEMPLATES_DIR = join(import.meta.dir, '../../templates') function readDir(dir: string): string[] { const files: string[] = [] @@ -34,8 +34,8 @@ function replaceVars(content: string, vars: TemplateVars): string { export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates { const vars: TemplateVars = { - APP_NAME: appName, APP_EMOJI: DEFAULT_EMOJI, + APP_NAME: appName, } const result: AppTemplates = {} diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 6d7e9b4..f3789d0 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -1,7 +1,10 @@ -import { allApps, onChange, renameApp, startApp, stopApp, updateAppIcon } from '$apps' +import { APPS_DIR, allApps, onChange, renameApp, startApp, stopApp, updateAppIcon } from '$apps' import type { App as BackendApp } from '$apps' import type { App as SharedApp } from '@types' +import { generateTemplates, type TemplateType } from '%templates' import { Hype } from '@because/hype' +import { existsSync, mkdirSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' const router = Hype.router() @@ -55,6 +58,42 @@ router.get('/:app/logs', c => { return c.json(app.logs ?? []) }) +router.post('/', async c => { + let body: { name?: string, template?: TemplateType } + try { + body = await c.req.json() + } catch { + return c.json({ ok: false, error: 'Invalid JSON body' }, 400) + } + + const name = body.name?.trim().toLowerCase().replace(/\s+/g, '-') + if (!name) return c.json({ ok: false, error: 'App name is required' }, 400) + + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + return c.json({ ok: false, error: 'Name must start with a letter and contain only lowercase letters, numbers, and hyphens' }, 400) + } + + const appPath = join(APPS_DIR, name) + if (existsSync(appPath)) { + return c.json({ ok: false, error: 'An app with this name already exists' }, 400) + } + + const template = body.template ?? 'ssr' + const templates = generateTemplates(name, template) + + // Create directories and write files + for (const [filename, content] of Object.entries(templates)) { + const fullPath = join(appPath, filename) + const dir = dirname(fullPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(fullPath, content) + } + + return c.json({ ok: true, name }) +}) + router.sse('/:app/logs/stream', (send, c) => { const appName = c.req.param('app') const targetApp = allApps().find(a => a.name === appName) diff --git a/src/server/apps.ts b/src/server/apps.ts index 8df3ee8..27b4293 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -2,11 +2,11 @@ import type { App as SharedApp, AppState } from '@types' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs' -import { join } from 'path' +import { join, resolve } from 'path' export type { AppState } from '@types' -export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') +export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps')) const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3 const HEALTH_CHECK_INTERVAL = 30000 @@ -402,7 +402,7 @@ async function runApp(dir: string, port: number) { const proc = Bun.spawn(['bun', 'run', 'toes'], { cwd, - env: { ...process.env, PORT: String(port), NO_AUTOPORT: 'true' }, + env: { ...process.env, PORT: String(port), NO_AUTOPORT: 'true', APPS_DIR }, stdout: 'pipe', stderr: 'pipe', }) diff --git a/src/server/index.tsx b/src/server/index.tsx index 55c9c21..c315e00 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -5,6 +5,18 @@ import { Hype } from '@because/hype' const app = new Hype({ layout: false }) +// Serve pre-bundled client in production +if (process.env.NODE_ENV === 'production') { + app.get('/client/index.js', async (c) => { + const file = Bun.file('./dist/client/index.js') + if (!(await file.exists())) { + console.error('⚠️ Bundled client not found. Run `bun run build` first.') + return c.text('Client bundle not found', 500) + } + return new Response(file, { headers: { 'Content-Type': 'text/javascript' } }) + }) +} + app.route('/api/apps', appsRouter) app.route('/api/sync', syncRouter)