diff --git a/seed/project-templates/simple-app-template/AGENTS.md b/seed/project-templates/simple-app-template/AGENTS.md new file mode 100644 index 00000000..9e1f66bb --- /dev/null +++ b/seed/project-templates/simple-app-template/AGENTS.md @@ -0,0 +1,65 @@ +# AGENTS.md — Simple App Template + +## Project Structure + +``` +src/ +├── index.ts # Bun server entry (routes, HMR) +├── index.html # HTML entry point +├── frontend.tsx # React root mount +├── index.css # Tailwind + global styles +├── App.tsx # Main React component +└── components/ # UI components +build.ts # Build script (bun run build → dist/) +.officerdev/ +└── meta.json # Project metadata +``` + +## Tech Stack + +- **Runtime**: Bun +- **Language**: TypeScript (strict) +- **Framework**: React 19 +- **Styling**: Tailwind CSS v4 +- **UI**: shadcn/ui components (Radix UI primitives) +- **Icons**: lucide-react + +## Build System + +- `bun dev` — starts dev server with HMR +- `bun run build` — produces `dist/` via `build.ts` +- Build uses `bun-plugin-tailwind` for CSS processing +- Output: minified JS/CSS bundles + HTML in `dist/` + +## Publishing + +This project can be published as a standalone app in officer.dev. + +**Version**: Set in `package.json` → `version` field. Bump before republishing. + +**Icon**: When publishing, choose from these available lucide icon names: +Activity, Airplay, Archive, BarChart3, Bell, Blocks, BookOpen, Box, BrainCircuit, +Calendar, Camera, ChartPie, CircleDot, Cloud, Code, Compass, CreditCard, Database, +FileText, Folder, Gamepad2, Globe, Heart, Home, Image, Inbox, Layers, Layout, +LineChart, Link, List, Mail, Map, MessageCircle, Monitor, Music, Palette, PenTool, +Play, Puzzle, Radio, Rocket, Search, Settings, ShoppingCart, Star, Sun, Table, +Terminal, Timer, Users, Wand2, Zap + +## Iframe Constraints + +Published apps run inside an iframe in officer.dev workspace panels: + +- **Auth token injection**: The iframe URL includes a `?token=` param. A script is injected into the HTML `` that automatically adds `Authorization: Bearer ` to all fetch/XHR requests. +- **Path rewriting**: All absolute paths (`/api/...`, `/assets/...`) are rewritten to go through the serve proxy at `/api/app-serve/{slug}/`. Relative paths work as-is. +- **No direct DOM access**: The app cannot access the parent frame. +- **API access**: Use `fetch('/api/...')` — the injected script rewrites paths and adds auth headers automatically. + +## Coding Conventions + +- Functional components, arrow functions +- No default exports — use named exports +- Strict TypeScript, no `any` +- Semicolons, single quotes (JS), double quotes (JSX) +- 2-space indentation +- Prefer composition over inheritance +- Keep components small and focused diff --git a/src/servers/api/_shared/html-rewrite.ts b/src/servers/api/_shared/html-rewrite.ts new file mode 100644 index 00000000..9ad2a3ce --- /dev/null +++ b/src/servers/api/_shared/html-rewrite.ts @@ -0,0 +1,46 @@ +// Injected into proxied HTML to intercept fetch/XHR/WebSocket so absolute paths +// go through the proxy instead of hitting the host server directly. +// Also injects JWT auth headers from localStorage for authenticated requests. +export const proxyOverrideScript = (base: string) => + ``; + +// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override +export const rewriteHtml = (html: string, base: string) => + html + .replace(/]*)>/i, `${proxyOverrideScript(base)}`) + .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) + .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) + .replace(/url\(\//g, `url(${base}/`); + +// JS/CSS: only rewrite /_bun/ asset paths (Bun dev server specific) +export const rewriteAssetPaths = (text: string, base: string) => + text.replace(/\/_bun\//g, `${base}/_bun/`); diff --git a/src/servers/api/apps/index.ts b/src/servers/api/apps/index.ts new file mode 100644 index 00000000..71e82238 --- /dev/null +++ b/src/servers/api/apps/index.ts @@ -0,0 +1,2 @@ +export { appsRouter, appServeRouter } from './router'; +export type { AppManifest } from './router'; diff --git a/src/servers/api/apps/router.ts b/src/servers/api/apps/router.ts new file mode 100644 index 00000000..8bb6772e --- /dev/null +++ b/src/servers/api/apps/router.ts @@ -0,0 +1,298 @@ +import type { Context } from 'hono'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { readdir, cp, rm, mkdir } from 'node:fs/promises'; +import { createRouter } from '@@/create-router'; +import type { HonoVariables } from '@@/create-router'; +import { getUserAppsDir, getUserProjectsDir } from '@@/data-path'; +import * as errors from '@@/custom-errors'; +import { verify } from '@@/jwt'; +import { isTokenBlacklisted } from 'officerdb'; +import { rewriteHtml, rewriteAssetPaths } from '../_shared/html-rewrite'; + +export type AppManifest = { + slug: string; + name: string; + version: string; + icon: string; + description: string; + sourceProject: string; + commitHash: string; + publishedAt: string; + buildDir: string; +}; + +// ── Protected routes (inside protectedRouter) ── + +export const appsRouter = createRouter(); + +appsRouter.get('/', async (ctx) => { + const user = ctx.get('user'); + const appsDir = getUserAppsDir(user.email); + + if (!existsSync(appsDir)) return ctx.json({ apps: [] }); + + const entries = await readdir(appsDir, { withFileTypes: true }); + const apps: AppManifest[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = join(appsDir, entry.name, 'manifest.json'); + if (!existsSync(manifestPath)) continue; + try { + const manifest = JSON.parse(await Bun.file(manifestPath).text()) as AppManifest; + apps.push(manifest); + } catch { + // skip malformed manifests + } + } + + return ctx.json({ apps }); +}); + +appsRouter.post('/publish', async (ctx) => { + const user = ctx.get('user'); + const body = ctx.get('body') as { projectSlug: string; name?: string; icon?: string; description?: string }; + const { projectSlug, name, icon, description } = body; + + if (!projectSlug) throw errors.BAD_REQUEST('Missing projectSlug'); + + const projectDir = join(getUserProjectsDir(user.email), projectSlug); + if (!existsSync(projectDir)) throw errors.BAD_REQUEST(`Project not found: ${projectSlug}`); + + const pkgPath = join(projectDir, 'package.json'); + if (!existsSync(pkgPath)) throw errors.BAD_REQUEST('No package.json found in project'); + + let pkgJson: { name?: string; version?: string; scripts?: Record }; + try { + pkgJson = JSON.parse(await Bun.file(pkgPath).text()); + } catch { + throw errors.BAD_REQUEST('Failed to read package.json'); + } + + if (!pkgJson.scripts?.build) throw errors.BAD_REQUEST('No "build" script in package.json'); + + const version = pkgJson.version ?? '0.1.0'; + const appSlug = projectSlug; + const appName = name ?? pkgJson.name ?? projectSlug; + + // Read .officerdev/meta.json for fallback description + let metaDescription = ''; + const metaPath = join(projectDir, '.officerdev', 'meta.json'); + if (existsSync(metaPath)) { + try { + const meta = JSON.parse(await Bun.file(metaPath).text()); + metaDescription = meta.description ?? ''; + } catch { + // ignore + } + } + + // Get git commit hash + let commitHash = 'unknown'; + try { + const proc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { cwd: projectDir, stdout: 'pipe', stderr: 'pipe' }); + await proc.exited; + if (proc.exitCode === 0) { + commitHash = (await new Response(proc.stdout).text()).trim(); + } + } catch { + // no git — that's fine + } + + // Run build + const buildProc = Bun.spawn(['bun', 'run', 'build'], { + cwd: projectDir, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NODE_ENV: 'production' }, + }); + + const buildTimeout = setTimeout(() => buildProc.kill(), 60_000); + const exitCode = await buildProc.exited; + clearTimeout(buildTimeout); + + if (exitCode !== 0) { + const stderr = await new Response(buildProc.stderr).text(); + throw errors.BAD_REQUEST(`Build failed (exit ${exitCode}):\n${stderr.slice(-500)}`); + } + + // Verify dist output + const distDir = join(projectDir, 'dist'); + if (!existsSync(distDir)) throw errors.BAD_REQUEST('Build did not produce a dist/ directory'); + + const distFiles = await readdir(distDir); + const hasHtml = distFiles.some((f) => f.endsWith('.html')); + if (!hasHtml) throw errors.BAD_REQUEST('Build output has no HTML files'); + + // Copy to versioned directory + const appsDir = getUserAppsDir(user.email); + const appDir = join(appsDir, appSlug); + const versionDir = join(appDir, 'v', version); + + await mkdir(versionDir, { recursive: true }); + await cp(distDir, versionDir, { recursive: true }); + + // Write manifest + const manifest: AppManifest = { + slug: appSlug, + name: appName, + version, + icon: icon ?? 'Box', + description: description ?? metaDescription, + sourceProject: projectSlug, + commitHash, + publishedAt: new Date().toISOString(), + buildDir: `v/${version}`, + }; + + await Bun.write(join(appDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + + return ctx.json(manifest); +}); + +appsRouter.delete('/:slug', async (ctx) => { + const user = ctx.get('user'); + const slug = ctx.req.param('slug'); + if (!slug) throw errors.BAD_REQUEST('Missing slug'); + + const appDir = join(getUserAppsDir(user.email), slug); + if (!existsSync(appDir)) throw errors.NOT_FOUND('App not found'); + + await rm(appDir, { recursive: true, force: true }); + return ctx.json({ ok: true }); +}); + +// ── Static serving (outside protectedRouter) ── + +export const appServeRouter = createRouter(); + +const MIME_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject', + '.map': 'application/json', +}; + +const getMimeType = (filePath: string): string => { + const ext = filePath.slice(filePath.lastIndexOf('.')); + return MIME_TYPES[ext] ?? 'application/octet-stream'; +}; + +type ValidateResult = { email: string } | null; + +async function validateToken(req: Request): Promise { + const authHeader = req.headers.get('authorization'); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : new URL(req.url).searchParams.get('token'); + if (!token) return null; + try { + const payload = await verify(token); + if (!payload?.email) return null; + if (payload.jti && (await isTokenBlacklisted(payload.jti))) return null; + return { email: payload.email }; + } catch { + return null; + } +} + +appServeRouter.all('/:slug/*', async (ctx) => { + const slug = ctx.req.param('slug'); + if (!slug) return ctx.text('Not found', 404); + + // Validate auth — HTML requests require token, assets check referer + const pathname = new URL(ctx.req.url).pathname; + const isHtml = + ctx.req.header('accept')?.includes('text/html') || + pathname.endsWith('.html') || + !pathname.slice(pathname.lastIndexOf('/') + 1).includes('.'); + + const validated = await validateToken(ctx.req.raw); + + if (!validated) { + // For non-HTML assets, try referer-based resolution + if (!isHtml) { + const referer = ctx.req.header('referer'); + if (referer) { + const match = referer.match(/\/api\/app-serve\/([^/?]+)/); + if (match) { + // Extract token from referer URL if present + try { + const refUrl = new URL(referer); + const refToken = refUrl.searchParams.get('token'); + if (refToken) { + const payload = await verify(refToken); + if (payload?.email) { + if (!payload.jti || !(await isTokenBlacklisted(payload.jti))) { + return serveFile(ctx, payload.email, slug); + } + } + } + } catch { + // fall through + } + } + } + } + return ctx.text('Unauthorized', 401); + } + + return serveFile(ctx, validated.email, slug); +}); + +async function serveFile(ctx: Context<{ Variables: HonoVariables }>, email: string, slug: string) { + const appDir = join(getUserAppsDir(email), slug); + const manifestPath = join(appDir, 'manifest.json'); + + if (!existsSync(manifestPath)) return ctx.text('App not found', 404); + + let manifest: AppManifest; + try { + manifest = JSON.parse(await Bun.file(manifestPath).text()); + } catch { + return ctx.text('Invalid manifest', 500); + } + + const buildDir = join(appDir, manifest.buildDir); + const prefix = `/api/app-serve/${slug}`; + + const url = new URL(ctx.req.url); + let filePath = url.pathname.replace(prefix, '') || '/'; + if (filePath === '/') filePath = '/index.html'; + + const fullPath = join(buildDir, filePath); + + // Security: prevent directory traversal + if (!fullPath.startsWith(buildDir)) return ctx.text('Forbidden', 403); + + // If file doesn't exist, serve index.html for SPA routing + const targetPath = existsSync(fullPath) ? fullPath : join(buildDir, 'index.html'); + if (!existsSync(targetPath)) return ctx.text('Not found', 404); + + const file = Bun.file(targetPath); + const mimeType = getMimeType(targetPath); + const needsRewrite = + mimeType.includes('text/html') || mimeType.includes('javascript') || mimeType.includes('text/css'); + + if (needsRewrite) { + const content = await file.text(); + const rewritten = mimeType.includes('text/html') + ? rewriteHtml(content, prefix) + : rewriteAssetPaths(content, prefix); + return new Response(rewritten, { headers: { 'content-type': mimeType } }); + } + + return new Response(file, { headers: { 'content-type': mimeType } }); +} diff --git a/src/servers/api/dev-server/router.ts b/src/servers/api/dev-server/router.ts index 8e565cfe..ad276fcd 100644 --- a/src/servers/api/dev-server/router.ts +++ b/src/servers/api/dev-server/router.ts @@ -8,6 +8,7 @@ import { CustomError } from '@@/custom-errors'; import * as errors from '@@/custom-errors'; import { verify } from '@@/jwt'; import { isTokenBlacklisted } from 'officerdb'; +import { rewriteHtml, rewriteAssetPaths } from '../_shared/html-rewrite'; export const devServerRouter = createRouter(); export const devServerProxyRouter = createRouter(); @@ -311,49 +312,3 @@ devServerProxyRouter.all('/:proxyId/*', async (ctx) => { return ctx.text('No dev server running', 404); }); -// Injected into proxied HTML to intercept fetch/XHR/WebSocket so absolute paths -// go through the proxy instead of hitting the host server directly. -// Also injects JWT auth headers from localStorage for authenticated requests. -const proxyOverrideScript = (base: string) => - ``; - -// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override -const rewriteHtml = (html: string, base: string) => - html - .replace(/]*)>/i, `${proxyOverrideScript(base)}`) - .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) - .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) - .replace(/url\(\//g, `url(${base}/`); - -// JS/CSS: only rewrite /_bun/ asset paths (Bun dev server specific) -const rewriteAssetPaths = (text: string, base: string) => - text.replace(/\/_bun\//g, `${base}/_bun/`); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 6b527e39..97cfc6ff 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -92,3 +92,5 @@ export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails'); + +export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 5d008e6b..69b7c86d 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -26,6 +26,7 @@ import { queueRouter } from './api/queue/queue'; import { emailRouter } from './api/email/email'; import { channelsRouter } from './channels/routes'; import { browserRouter } from './api/browser/router'; +import { appsRouter, appServeRouter } from './api/apps'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares'; @@ -51,6 +52,7 @@ honoServer.route('/api/auth', authRouter); honoServer.route('/api/landing-page-data', landingPageDataRouter); honoServer.route('/api/waitlist', waitlistRouter); honoServer.route('/api/dev-server-proxy', devServerProxyRouter); +honoServer.route('/api/app-serve', appServeRouter); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); honoServer.get('/api/server-settings/onboarding-complete', async (ctx) => { const { readServerSettings } = await import('officerdb'); @@ -83,6 +85,7 @@ protectedRouter.route('/queue', queueRouter); protectedRouter.route('/email', emailRouter); protectedRouter.route('/channels', channelsRouter); protectedRouter.route('/browser', browserRouter); +protectedRouter.route('/apps', appsRouter); protectedRouter.route('/', piRestRouter); honoServer.route('/api', protectedRouter); diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 0e754a74..34ef31c1 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; @@ -9,10 +10,34 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as previewMetas } from '../apps/Preview'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { useAppRegistry } from './useAppRegistry'; +import { useUserApps } from 'state/useUserApps'; +import { createUserAppPanel } from '../apps/UserApp/UserAppPanel'; +import { createUserAppHeader } from '../apps/UserApp/UserAppHeader'; +import { resolveIcon } from '../utils/resolve-icon'; const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas]; export const AppRegistry = () => { - useAppRegistry(apps); + const { registerApp } = useAppRegistry(apps); + const { apps: userApps, email } = useUserApps(); + const registeredRef = useRef(new Set()); + + useEffect(() => { + if (!email || userApps.length === 0) return; + + for (const app of userApps) { + const key = `${email}/${app.slug}`; + if (registeredRef.current.has(key)) continue; + registeredRef.current.add(key); + + registerApp(key, { + name: app.name, + icon: resolveIcon(app.icon), + component: createUserAppPanel(app.slug), + header: createUserAppHeader(app.name, resolveIcon(app.icon)), + }); + } + }, [userApps, email, registerApp]); + return null; }; diff --git a/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx b/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx index fc74548a..00873c72 100644 --- a/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx +++ b/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; -import { FolderKanban, Plus, Pencil, Trash2, Search } from 'lucide-react'; +import { FolderKanban, Plus, Pencil, Trash2, Search, Rocket } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; import { generateSlug } from 'helpers/slug'; import { useWorkspacesState } from 'state/useWorkspacesState'; import type { ProjectDefinition, ProjectType } from '../../components/Workspace'; +import { PublishDialog } from './PublishDialog'; import { AlertDialog, AlertDialogAction, @@ -53,6 +54,7 @@ export const ProjectListApp = () => { const [search, setSearch] = useState(''); const [deleting, setDeleting] = useState(null); + const [publishing, setPublishing] = useState(null); const isProjectsPage = location.pathname === '/projects'; const filtered = search ? projects.filter((p) => { @@ -81,6 +83,11 @@ export const ProjectListApp = () => { setDeleting(p); }; + const handlePublish = (ev: React.MouseEvent, p: ProjectDefinition) => { + ev.stopPropagation(); + setPublishing(p); + }; + const confirmDelete = () => { if (!deleting) return; // Optimistic: remove from cache immediately @@ -166,6 +173,14 @@ export const ProjectListApp = () => { {PROJECT_TYPE_LABELS[p.projectType]} {isProjectsPage && ( <> + + {iconPickerOpen && ( +
+ {availableIconNames.map((iconName) => { + const IconComp = resolveIcon(iconName); + return ( + + ); + })} +
+ )} + + + +