diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index eef98d4c744..02349962850 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -1,7 +1,21 @@ -import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + writeFileSync, + readFileSync, + copyFileSync, +} from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; -import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace"; +import { + discoverProfiles, + setUIActiveProfile, + getEffectiveProfile, + resolveWorkspaceRoot, + registerWorkspacePath, +} from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -106,6 +120,11 @@ const SEED_OBJECTS: SeedObject[] = [ }, ]; +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; +const DEFAULT_GATEWAY_PORT = 18_789; +const GATEWAY_PORT_STEP = 20; +const ONBOARD_TIMEOUT_MS = 12 * 60_000; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -221,58 +240,343 @@ function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean { return true; } +type SpawnResult = { + code: number; + stdout: string; + stderr: string; +}; + +function resolveCommandForPlatform(command: string): string { + if (process.platform === "win32" && !command.toLowerCase().endsWith(".cmd")) { + return `${command}.cmd`; + } + return command; +} + +function resolveOpenClawHomeDir(): string { + return process.env.OPENCLAW_HOME?.trim() || homedir(); +} + +function resolveProfileStateDir(profile: string): string { + if (!profile || profile.toLowerCase() === "default") { + return join(resolveOpenClawHomeDir(), ".openclaw"); + } + return join(resolveOpenClawHomeDir(), `.openclaw-${profile}`); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function parseGatewayPort(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +} + +function firstNonEmptyLine(...values: Array): string | undefined { + for (const value of values) { + const first = value + ?.split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (first) { + return first; + } + } + return undefined; +} + +function readOpenClawConfig(stateDir: string): Record { + for (const filename of ["openclaw.json", "config.json"]) { + const configPath = join(stateDir, filename); + if (!existsSync(configPath)) { + continue; + } + try { + const raw = JSON.parse(readFileSync(configPath, "utf-8")); + const parsed = asRecord(raw); + if (parsed) { + return parsed; + } + } catch { + // Try the next config candidate. + } + } + return {}; +} + +function writeOpenClawConfig(stateDir: string, config: Record): void { + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "openclaw.json"), JSON.stringify(config, null, 2) + "\n", "utf-8"); +} + +function updateProfileConfig(params: { + stateDir: string; + gatewayPort: number; + workspaceDir: string; +}): void { + const config = readOpenClawConfig(params.stateDir); + const gateway = asRecord(config.gateway) ?? {}; + gateway.mode = "local"; + gateway.port = params.gatewayPort; + config.gateway = gateway; + + const agents = asRecord(config.agents) ?? {}; + const defaults = asRecord(agents.defaults) ?? {}; + defaults.workspace = params.workspaceDir; + agents.defaults = defaults; + config.agents = agents; + + writeOpenClawConfig(params.stateDir, config); +} + +function resolveRequestedWorkspaceDir(rawPath: string | undefined, stateDir: string): string { + if (!rawPath?.trim()) { + return join(stateDir, "workspace"); + } + let workspaceDir = rawPath.trim(); + if (workspaceDir.startsWith("~")) { + workspaceDir = join(homedir(), workspaceDir.slice(1)); + } + return resolve(workspaceDir); +} + +function collectUsedGatewayPorts(): Set { + const used = new Set(); + for (const profile of discoverProfiles()) { + const config = readOpenClawConfig(profile.stateDir); + const port = parseGatewayPort(asRecord(config.gateway)?.port); + if (port) { + used.add(port); + } + } + return used; +} + +function allocateGatewayPort(): number { + const used = collectUsedGatewayPorts(); + let candidate = DEFAULT_GATEWAY_PORT; + while (used.has(candidate)) { + candidate += GATEWAY_PORT_STEP; + if (candidate > 65_535) { + throw new Error("Failed to allocate a free gateway port for the new profile."); + } + } + return candidate; +} + +async function runCommandWithTimeout( + command: string, + args: string[], + timeoutMs: number, +): Promise { + return await new Promise((resolveResult, reject) => { + const child = spawn(resolveCommandForPlatform(command), args, { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) { + return; + } + child.kill("SIGKILL"); + }, timeoutMs); + + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + child.once("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }); + + child.once("close", (code) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolveResult({ + code: typeof code === "number" ? code : 1, + stdout, + stderr, + }); + }); + }); +} + +async function runOnboardForProfile(profile: string, gatewayPort: number): Promise { + const args = [ + "--profile", + profile, + "onboard", + "--install-daemon", + "--gateway-bind", + "loopback", + "--gateway-port", + String(gatewayPort), + "--non-interactive", + "--accept-risk", + "--skip-ui", + ]; + const result = await runCommandWithTimeout("openclaw", args, ONBOARD_TIMEOUT_MS); + if (result.code === 0) { + return; + } + const detail = firstNonEmptyLine(result.stderr, result.stdout); + throw new Error(detail ? `OpenClaw onboarding failed: ${detail}` : "OpenClaw onboarding failed."); +} + +function copyIronclawProfileConfig(targetStateDir: string): string[] { + const copied: string[] = []; + const sourceStateDir = resolveProfileStateDir("ironclaw"); + const sourceConfig = join(sourceStateDir, "openclaw.json"); + const sourceAuthProfiles = join(sourceStateDir, "agents", "main", "agent", "auth-profiles.json"); + const targetConfig = join(targetStateDir, "openclaw.json"); + const targetAuthProfiles = join(targetStateDir, "agents", "main", "agent", "auth-profiles.json"); + + if (existsSync(sourceConfig)) { + mkdirSync(targetStateDir, { recursive: true }); + copyFileSync(sourceConfig, targetConfig); + copied.push("openclaw.json"); + } + + if (existsSync(sourceAuthProfiles)) { + mkdirSync(join(targetStateDir, "agents", "main", "agent"), { recursive: true }); + copyFileSync(sourceAuthProfiles, targetAuthProfiles); + copied.push("agents/main/agent/auth-profiles.json"); + } + + return copied; +} + +function syncManagedDenchSkill(stateDir: string, projectRoot: string | null): boolean { + if (!projectRoot) { + return false; + } + const sourceDir = join(projectRoot, "skills", "dench"); + const sourceSkillFile = join(sourceDir, "SKILL.md"); + if (!existsSync(sourceSkillFile)) { + return false; + } + const targetDir = join(stateDir, "skills", "dench"); + mkdirSync(join(stateDir, "skills"), { recursive: true }); + cpSync(sourceDir, targetDir, { recursive: true, force: true }); + return true; +} + // --------------------------------------------------------------------------- // Route handler // --------------------------------------------------------------------------- export async function POST(req: Request) { - const body = (await req.json()) as { + const body = (await req.json().catch(() => ({}))) as { profile?: string; path?: string; seedBootstrap?: boolean; + copyConfigAuth?: boolean; }; - - const profileName = body.profile?.trim() || null; - - if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) { + const profileName = body.profile?.trim() || ""; + if (!profileName) { + return Response.json( + { error: "Profile name is required." }, + { status: 400 }, + ); + } + if (profileName.toLowerCase() === "default") { + return Response.json( + { error: "The 'default' profile already exists. Create a named profile instead." }, + { status: 400 }, + ); + } + if (!PROFILE_NAME_RE.test(profileName)) { return Response.json( { error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." }, { status: 400 }, ); } - // Determine workspace directory - let workspaceDir: string; - if (body.path?.trim()) { - workspaceDir = body.path.trim(); - if (workspaceDir.startsWith("~")) { - workspaceDir = join(homedir(), workspaceDir.slice(1)); - } - workspaceDir = resolve(workspaceDir); - } else { - const stateDir = resolveOpenClawStateDir(); - if (profileName && profileName !== "default") { - workspaceDir = join(stateDir, `workspace-${profileName}`); - } else { - workspaceDir = join(stateDir, "workspace"); - } + const existingProfiles = discoverProfiles(); + if (existingProfiles.some((profile) => profile.name.toLowerCase() === profileName.toLowerCase())) { + return Response.json( + { error: `Profile '${profileName}' already exists.` }, + { status: 409 }, + ); } + const stateDir = resolveProfileStateDir(profileName); + const workspaceDir = resolveRequestedWorkspaceDir(body.path, stateDir); + const seedBootstrap = body.seedBootstrap !== false; + const shouldCopyConfigAuth = body.copyConfigAuth !== false; + const seeded: string[] = []; + const copiedFiles: string[] = []; + + const projectRoot = resolveProjectRoot(); + let gatewayPort: number; try { - mkdirSync(workspaceDir, { recursive: true }); - } catch (err) { + gatewayPort = allocateGatewayPort(); + } catch (error) { return Response.json( - { error: `Failed to create workspace directory: ${(err as Error).message}` }, + { error: (error as Error).message }, { status: 500 }, ); } - const seedBootstrap = body.seedBootstrap !== false; - const seeded: string[] = []; + try { + mkdirSync(stateDir, { recursive: true }); + if (shouldCopyConfigAuth) { + copiedFiles.push(...copyIronclawProfileConfig(stateDir)); + } + } catch (err) { + return Response.json( + { error: `Failed to prepare profile directory: ${(err as Error).message}` }, + { status: 500 }, + ); + } + + try { + await runOnboardForProfile(profileName, gatewayPort); + } catch (err) { + return Response.json( + { error: (err as Error).message }, + { status: 500 }, + ); + } + + try { + updateProfileConfig({ stateDir, gatewayPort, workspaceDir }); + mkdirSync(workspaceDir, { recursive: true }); + } catch (err) { + return Response.json( + { error: `Failed to configure profile workspace: ${(err as Error).message}` }, + { status: 500 }, + ); + } if (seedBootstrap) { - const projectRoot = resolveProjectRoot(); - // Seed all bootstrap files from templates for (const filename of BOOTSTRAP_FILENAMES) { const filePath = join(workspaceDir, filename); @@ -312,21 +616,25 @@ export async function POST(req: Request) { } } + const denchSynced = syncManagedDenchSkill(stateDir, projectRoot); + // Remember custom-path workspaces in the registry - if (body.path?.trim() && profileName) { + if (body.path?.trim()) { registerWorkspacePath(profileName, workspaceDir); } // Switch to the new profile - if (profileName) { - setUIActiveProfile(profileName === "default" ? null : profileName); - } + setUIActiveProfile(profileName); return Response.json({ workspaceDir, - profile: profileName || "default", + stateDir, + profile: profileName, activeProfile: getEffectiveProfile() || "default", + gatewayPort, + copiedFiles, seededFiles: seeded, + denchSynced, workspaceRoot: resolveWorkspaceRoot(), }); }