// Scaffolding for a workspace's `.moi/` root, laid down by `moi init` (and // `moi openclaw init`). Creates `.moi/widgets/`, writes the widget // dependency manifest, and installs dependencies — so the agent never has to // bootstrap the folder itself. import { mkdir, stat } from 'node:fs/promises' import { join, resolve, sep } from 'node:path' // Dependency set available to widgets. `react`/`react-dom` are stubs — at // runtime they resolve to moi's locally-vendored ESM via the browser importmap // (/vendor/react); they're listed so editors pick up the correct types. export const MOI_PACKAGE_JSON = { name: 'widgets', private: true, dependencies: { '@tabler/icons-react': '^3.40.0', tailwindcss: '^4.3.3', react: '^19.0.0', 'react-dom': '^19.0.0' }, devDependencies: { '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0' } } as const // Dependency installation is helpful for editor types and widget builds, but // it's not critical — the agent installs on demand if it's missing. So it must // never block workspace creation: we wait briefly, then let it finish in the // background, with a hard kill as a backstop against a hung registry. const INSTALL_WAIT_MS = 10_000 const INSTALL_TIMEOUT_MS = 120_000 type InstallDependencies = (moiDir: string) => Promise async function runBunInstall(moiDir: string): Promise { const install = Bun.spawn(['bun', 'install'], { cwd: moiDir, stdout: 'ignore', stderr: 'inherit', timeout: INSTALL_TIMEOUT_MS, killSignal: 'SIGKILL' }) // Don't hold the event loop open for a backgrounded install: a short-lived // CLI (`moi init`) must exit after the wait, not linger until the child // does. The child survives parent exit and finishes the install on its own // (verified: orphaned bun processes complete; only the 2-minute kill is no // longer enforced once the parent is gone). install.unref() return install.exited } // Keep machine-local state out of workspaces that are git repos: build output, // derived caches (applet thumbnails), and installed dependencies are all // re-creatable and would otherwise churn or bloat the repo. The applet sources, // package.json, and lockfile stay committable — they ARE the workspace. const REQUIRED_IGNORES = ['.build/', '.cache/', 'node_modules/'] as const export const MOI_GITIGNORE = `# moi internals — machine-local, re-creatable state ${REQUIRED_IGNORES.join('\n')} ` // Create `.moi/.gitignore` when missing, or append required entries a // pre-gitignore or hand-edited file lacks. Never rewrites existing content — // user additions survive. Safe to call often; no-ops once the file is right. export async function ensureMoiGitignore(workspacePath: string): Promise { const moiDir = join(workspacePath, '.moi') if (!(await isDirectory(moiDir))) return const path = join(moiDir, '.gitignore') const file = Bun.file(path) if (!(await file.exists())) { await Bun.write(path, MOI_GITIGNORE) return } const text = await file.text() // Match entries with or without the trailing slash or a leading slash. const present = new Set(text.split('\n').map(line => line.trim().replace(/^\/|\/$/g, ''))) const missing = REQUIRED_IGNORES.filter(entry => !present.has(entry.replace(/\/$/, ''))) if (missing.length === 0) return await Bun.write(path, `${text.replace(/\n*$/, '\n')}${missing.join('\n')}\n`) } // Ambient types for applets (widgets & views). Editor DX only — the moi // bundler resolves the `moi` module and asset imports at build time without any // declarations. Lives at `.moi/` root, NOT inside widgets/views, so it isn't // picked up by the `.ts` build glob and compiled as an applet. export const APPLET_ENV_DTS = `// Auto-generated by \`moi init\`. Ambient types for widgets & views. // Editor/\`tsc\` only — the moi bundler needs no declarations. declare module 'moi' { // Absolute URL to a workspace file, streamed by the server. Pass a // workspace-relative path (e.g. 'clips/001.mp4'). Media/asset files only. export function fileUrl(path: string): string // Switch the workspace to a tab (replace navigation). \`params\` reach the // target view as its \`params\` prop — JSON-plain values only. No-ops outside // the moi host. Tab ids: 'agent' | 'widgets' | 'scratchpad' | 'view:'. export function focusTab(tab: string, params?: Record): void // Send a chat message to the workspace's active chat, as if the user typed // \`message\`. \`context\` rides along as structured data the agent sees but // the user does not — JSON-plain values only. Call it from event handlers, // never during render: each call starts an agent run, and repeats are // rate-limited. export function sendChatMessage(message: string, context?: Record): void export type WidgetConfig = { rowSpan: 1 | 2 | 3 | 4 colSpan: 1 | 2 | 3 | 4 requiredEnv?: string[] } export type ViewConfig = { title?: string; icon?: string; requiredEnv?: string[] } } // Bundled asset imports (\`import logo from './logo.png'\`) resolve to a URL string. declare module '*.png' { const s: string; export default s } declare module '*.jpg' { const s: string; export default s } declare module '*.jpeg' { const s: string; export default s } declare module '*.gif' { const s: string; export default s } declare module '*.webp' { const s: string; export default s } declare module '*.avif' { const s: string; export default s } declare module '*.svg' { const s: string; export default s } ` // Write `.moi/applet-env.d.ts` from the template this CLI ships, overwriting // whatever is there. The file is auto-generated and declares the `moi` module's // public API, so it drifts the moment the CLI grows an applet-facing function // (`focusTab`, `sendChatMessage`) while a workspace keeps the copy written at // `moi init` time — leaving the agent's editor and `tsc` insisting a real API // doesn't exist. Regenerated alongside skills, the other agent-facing contract // moi ships. Refreshes an existing `.moi/` only — never creates one, so a // directory that was never scaffolded doesn't sprout a `.moi/` holding nothing // but a declaration file. Returns whether it wrote. export async function writeAppletEnvDts(workspacePath: string): Promise { const moiDir = join(workspacePath, '.moi') if (!(await isDirectory(moiDir))) return false await Bun.write(join(moiDir, 'applet-env.d.ts'), APPLET_ENV_DTS) return true } async function isDirectory(path: string): Promise { try { return (await stat(path)).isDirectory() } catch { return false } } // Bootstraps `.moi/` ONLY when it doesn't exist yet. Re-running `moi init` // on an existing workspace overwrites skills but must leave the user's // `.moi/` (their deps, widgets, lockfile) completely untouched. // Returns 'exists' when skipped, 'installing' when `bun install` outlived the // wait and continues in the background, otherwise the install exit code. export async function scaffoldMoiDir( workspacePath: string, installDependencies: InstallDependencies = runBunInstall, installWaitMs: number = INSTALL_WAIT_MS ): Promise<'exists' | 'installing' | number> { // Backstop against the nested-workspace bug: never scaffold a `.moi/` *inside* // another workspace's `.moi/` (which produces the junk `.moi/.moi`). Callers // (`moi init`) lift to the workspace root via `liftToWorkspaceRoot` first, so // this only fires on a programmer error. if (resolve(workspacePath).split(sep).includes('.moi')) { throw new Error( `Refusing to scaffold a workspace inside a .moi directory: ${workspacePath}. ` + 'Run from the workspace root.' ) } const moiDir = join(workspacePath, '.moi') const packagePath = join(moiDir, 'package.json') if (await Bun.file(packagePath).exists()) { // Repair path: workspaces scaffolded before `.moi/.gitignore` existed pick // it up on the next `moi init` instead of leaking cache files into git. await ensureMoiGitignore(workspacePath) return 'exists' } // A bare `.moi/` dir without package.json counts as not-bootstrapped — // fill in the missing pieces. await mkdir(join(moiDir, 'widgets'), { recursive: true }) await Bun.write(packagePath, JSON.stringify(MOI_PACKAGE_JSON, null, 2) + '\n') await ensureMoiGitignore(workspacePath) await writeAppletEnvDts(workspacePath) const exited = installDependencies(moiDir) let timer: ReturnType | undefined const result = await Promise.race([ exited, new Promise<'installing'>(r => (timer = setTimeout(() => r('installing'), installWaitMs))) ]) clearTimeout(timer) if (result === 'installing') { console.log(`[scaffold] bun install in ${moiDir} still running — continuing in the background`) exited.then(code => { if (code === 0) console.log(`[scaffold] background bun install in ${moiDir} finished`) else console.warn( `[scaffold] background bun install in ${moiDir} failed (exit ${code}) — the agent will install deps on demand` ) }) } return result }