feat(web): overhaul workspace init with profile creation, onboarding, and port allocation
This commit is contained in:
parent
d8e2d455b8
commit
3568779912
@ -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<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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>): 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<string, unknown> {
|
||||
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<string, unknown>): 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<number> {
|
||||
const used = new Set<number>();
|
||||
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<SpawnResult> {
|
||||
return await new Promise<SpawnResult>((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<void> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user