Remove ~200 lines of duplicated CRM seed objects, DuckDB seeding, and identity generation from the init route by importing from @repo/cli/workspace-seed.
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
} from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import {
|
|
discoverWorkspaces,
|
|
setUIActiveWorkspace,
|
|
getActiveWorkspaceName,
|
|
resolveOpenClawStateDir,
|
|
resolveWorkspaceDirForName,
|
|
isValidWorkspaceName,
|
|
resolveWorkspaceRoot,
|
|
ensureAgentInConfig,
|
|
} from "@/lib/workspace";
|
|
import {
|
|
seedWorkspaceFromAssets,
|
|
buildIronclawIdentity,
|
|
} from "@repo/cli/workspace-seed";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap file names (must match src/agents/workspace.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BOOTSTRAP_FILENAMES = [
|
|
"AGENTS.md",
|
|
"SOUL.md",
|
|
"TOOLS.md",
|
|
"IDENTITY.md",
|
|
"USER.md",
|
|
"HEARTBEAT.md",
|
|
"BOOTSTRAP.md",
|
|
] as const;
|
|
|
|
const FALLBACK_CONTENT: Record<string, string> = {
|
|
"AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n",
|
|
"SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n",
|
|
"TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n",
|
|
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n",
|
|
"USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n",
|
|
"HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n",
|
|
"BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
|
|
};
|
|
|
|
const WORKSPACE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function stripFrontMatter(content: string): string {
|
|
if (!content.startsWith("---")) {return content;}
|
|
const endIndex = content.indexOf("\n---", 3);
|
|
if (endIndex === -1) {return content;}
|
|
return content.slice(endIndex + "\n---".length).replace(/^\s+/, "");
|
|
}
|
|
|
|
/** Try multiple candidate paths to find the monorepo root. */
|
|
function resolveProjectRoot(): string | null {
|
|
const marker = join("docs", "reference", "templates", "AGENTS.md");
|
|
const cwd = process.cwd();
|
|
|
|
// CWD is the repo root (standalone builds)
|
|
if (existsSync(join(cwd, marker))) {return cwd;}
|
|
|
|
// CWD is apps/web/ (dev mode)
|
|
const fromApps = resolve(cwd, "..", "..");
|
|
if (existsSync(join(fromApps, marker))) {return fromApps;}
|
|
|
|
return null;
|
|
}
|
|
|
|
function loadTemplateContent(filename: string, projectRoot: string | null): string {
|
|
if (projectRoot) {
|
|
const templatePath = join(projectRoot, "docs", "reference", "templates", filename);
|
|
try {
|
|
const raw = readFileSync(templatePath, "utf-8");
|
|
return stripFrontMatter(raw);
|
|
} catch {
|
|
// fall through to fallback
|
|
}
|
|
}
|
|
return FALLBACK_CONTENT[filename] ?? "";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function POST(req: Request) {
|
|
const body = (await req.json().catch(() => ({}))) as {
|
|
workspace?: string;
|
|
profile?: string;
|
|
path?: string;
|
|
seedBootstrap?: boolean;
|
|
};
|
|
const workspaceName = (body.workspace ?? body.profile)?.trim() || "";
|
|
if (!workspaceName) {
|
|
return Response.json(
|
|
{ error: "Workspace name is required." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (body.path?.trim()) {
|
|
return Response.json(
|
|
{ error: "Custom workspace paths are currently disabled. Workspaces are created in ~/.openclaw-ironclaw." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (!WORKSPACE_NAME_RE.test(workspaceName) || !isValidWorkspaceName(workspaceName)) {
|
|
return Response.json(
|
|
{ error: "Invalid workspace name. Use letters, numbers, hyphens, or underscores." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const existingWorkspaces = discoverWorkspaces();
|
|
if (existingWorkspaces.some((workspace) => workspace.name.toLowerCase() === workspaceName.toLowerCase())) {
|
|
return Response.json(
|
|
{ error: `Workspace '${workspaceName}' already exists.` },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const stateDir = resolveOpenClawStateDir();
|
|
const workspaceDir = resolveWorkspaceDirForName(workspaceName);
|
|
const seedBootstrap = body.seedBootstrap !== false;
|
|
const seeded: string[] = [];
|
|
const copiedFiles: string[] = [];
|
|
|
|
const projectRoot = resolveProjectRoot();
|
|
|
|
try {
|
|
mkdirSync(stateDir, { recursive: true });
|
|
mkdirSync(workspaceDir, { recursive: false });
|
|
} catch (err) {
|
|
return Response.json(
|
|
{ error: `Failed to prepare workspace directory: ${(err as Error).message}` },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
if (seedBootstrap) {
|
|
// Seed bootstrap files from templates (IDENTITY.md is handled by
|
|
// seedWorkspaceFromAssets below, so skip it in this loop).
|
|
for (const filename of BOOTSTRAP_FILENAMES) {
|
|
if (filename === "IDENTITY.md") {continue;}
|
|
const filePath = join(workspaceDir, filename);
|
|
if (!existsSync(filePath)) {
|
|
const content = loadTemplateContent(filename, projectRoot);
|
|
try {
|
|
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
|
|
seeded.push(filename);
|
|
} catch {
|
|
// race / already exists
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Seed managed skills, Ironclaw identity, DuckDB, and CRM object projections.
|
|
// This is the single source of truth shared with the CLI bootstrap path.
|
|
if (projectRoot) {
|
|
const seedResult = seedWorkspaceFromAssets({ workspaceDir, packageRoot: projectRoot });
|
|
seeded.push(...seedResult.projectionFiles);
|
|
if (seedResult.seeded) {
|
|
seeded.push("workspace.duckdb");
|
|
}
|
|
} else {
|
|
// No project root available (e.g. standalone/production build without
|
|
// the repo tree). Still write the Ironclaw identity so the agent has
|
|
// a usable IDENTITY.md.
|
|
const identityPath = join(workspaceDir, "IDENTITY.md");
|
|
writeFileSync(identityPath, buildIronclawIdentity(workspaceDir) + "\n", "utf-8");
|
|
seeded.push("IDENTITY.md");
|
|
}
|
|
|
|
if (seedBootstrap) {
|
|
// Write workspace state so the gateway knows seeding was done.
|
|
const wsStateDir = join(workspaceDir, ".openclaw");
|
|
const statePath = join(wsStateDir, "workspace-state.json");
|
|
if (!existsSync(statePath)) {
|
|
try {
|
|
mkdirSync(wsStateDir, { recursive: true });
|
|
const state = {
|
|
version: 1,
|
|
bootstrapSeededAt: new Date().toISOString(),
|
|
duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb"))
|
|
? new Date().toISOString()
|
|
: undefined,
|
|
};
|
|
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
} catch {
|
|
// Best-effort state tracking
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register a per-workspace agent in openclaw.json and make it the default.
|
|
ensureAgentInConfig(workspaceName, workspaceDir);
|
|
|
|
// Switch the UI to the new workspace.
|
|
setUIActiveWorkspace(workspaceName);
|
|
const activeWorkspace = getActiveWorkspaceName();
|
|
|
|
return Response.json({
|
|
workspace: workspaceName,
|
|
activeWorkspace,
|
|
workspaceDir,
|
|
stateDir,
|
|
copiedFiles,
|
|
seededFiles: seeded,
|
|
crmSynced: !!projectRoot,
|
|
workspaceRoot: resolveWorkspaceRoot(),
|
|
// Backward-compat response fields while callers migrate.
|
|
profile: workspaceName,
|
|
activeProfile: activeWorkspace,
|
|
});
|
|
}
|