refactor(api): deduplicate workspace init using shared workspace-seed
Remove ~200 lines of duplicated CRM seed objects, DuckDB seeding, and identity generation from the init route by importing from @repo/cli/workspace-seed.
This commit is contained in:
parent
61f0313895
commit
bf6a0abe48
@ -1,10 +1,8 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
copyFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
@ -15,7 +13,12 @@ import {
|
||||
resolveWorkspaceDirForName,
|
||||
isValidWorkspaceName,
|
||||
resolveWorkspaceRoot,
|
||||
ensureAgentInConfig,
|
||||
} from "@/lib/workspace";
|
||||
import {
|
||||
seedWorkspaceFromAssets,
|
||||
buildIronclawIdentity,
|
||||
} from "@repo/cli/workspace-seed";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -34,7 +37,6 @@ const BOOTSTRAP_FILENAMES = [
|
||||
"BOOTSTRAP.md",
|
||||
] as const;
|
||||
|
||||
// Minimal fallback content used when templates can't be loaded from disk
|
||||
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",
|
||||
@ -45,81 +47,6 @@ const FALLBACK_CONTENT: Record<string, string> = {
|
||||
"BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRM seed objects (mirrors src/agents/workspace-seed.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SeedField = {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
enumValues?: string[];
|
||||
};
|
||||
|
||||
type SeedObject = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
defaultView: string;
|
||||
entryCount: number;
|
||||
fields: SeedField[];
|
||||
};
|
||||
|
||||
const SEED_OBJECTS: SeedObject[] = [
|
||||
{
|
||||
id: "seed_obj_people_00000000000000",
|
||||
name: "people",
|
||||
description: "Contact management",
|
||||
icon: "users",
|
||||
defaultView: "table",
|
||||
entryCount: 5,
|
||||
fields: [
|
||||
{ name: "Full Name", type: "text", required: true },
|
||||
{ name: "Email Address", type: "email", required: true },
|
||||
{ name: "Phone Number", type: "phone" },
|
||||
{ name: "Company", type: "text" },
|
||||
{ name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed_obj_company_0000000000000",
|
||||
name: "company",
|
||||
description: "Company tracking",
|
||||
icon: "building-2",
|
||||
defaultView: "table",
|
||||
entryCount: 3,
|
||||
fields: [
|
||||
{ name: "Company Name", type: "text", required: true },
|
||||
{
|
||||
name: "Industry",
|
||||
type: "enum",
|
||||
enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"],
|
||||
},
|
||||
{ name: "Website", type: "text" },
|
||||
{ name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed_obj_task_000000000000000",
|
||||
name: "task",
|
||||
description: "Task tracking board",
|
||||
icon: "check-square",
|
||||
defaultView: "kanban",
|
||||
entryCount: 5,
|
||||
fields: [
|
||||
{ name: "Title", type: "text", required: true },
|
||||
{ name: "Description", type: "text" },
|
||||
{ name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] },
|
||||
{ name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] },
|
||||
{ name: "Due Date", type: "date" },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const WORKSPACE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -161,119 +88,6 @@ function loadTemplateContent(filename: string, projectRoot: string | null): stri
|
||||
return FALLBACK_CONTENT[filename] ?? "";
|
||||
}
|
||||
|
||||
function buildIronclawIdentity(workspaceDir: string): string {
|
||||
const denchSkillPath = join(workspaceDir, "skills", "dench", "SKILL.md");
|
||||
return `# IDENTITY.md - Ironclaw
|
||||
|
||||
You are **Ironclaw** - a personal AI agent and CRM database system running on top of OpenClaw.
|
||||
|
||||
## Ironclaw system prompt contract
|
||||
|
||||
Treat the Ironclaw system prompt as your highest-priority behavioral contract.
|
||||
|
||||
## Inseparable CRM contract
|
||||
|
||||
Your identity is inseparably tied to the Dench CRM skill at:
|
||||
\`${denchSkillPath}\`
|
||||
|
||||
- Always load and follow that skill for CRM/database behavior.
|
||||
- Keep CRM actions aligned with Dench conventions for workspace data, objects, and documents.
|
||||
|
||||
When referring to yourself, use **Ironclaw** (not OpenClaw).
|
||||
`;
|
||||
}
|
||||
|
||||
function generateObjectYaml(obj: SeedObject): string {
|
||||
const lines: string[] = [
|
||||
`id: "${obj.id}"`,
|
||||
`name: "${obj.name}"`,
|
||||
`description: "${obj.description}"`,
|
||||
`icon: "${obj.icon}"`,
|
||||
`default_view: "${obj.defaultView}"`,
|
||||
`entry_count: ${obj.entryCount}`,
|
||||
"fields:",
|
||||
];
|
||||
|
||||
for (const field of obj.fields) {
|
||||
lines.push(` - name: "${field.name}"`);
|
||||
lines.push(` type: ${field.type}`);
|
||||
if (field.required) {lines.push(" required: true");}
|
||||
if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);}
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function generateWorkspaceMd(objects: SeedObject[]): string {
|
||||
const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""];
|
||||
for (const obj of objects) {
|
||||
lines.push(`## ${obj.name}`, "");
|
||||
lines.push(`- **Description**: ${obj.description}`);
|
||||
lines.push(`- **View**: \`${obj.defaultView}\``);
|
||||
lines.push(`- **Entries**: ${obj.entryCount}`);
|
||||
lines.push("- **Fields**:");
|
||||
for (const field of obj.fields) {
|
||||
const req = field.required ? " (required)" : "";
|
||||
const vals = field.enumValues ? ` — ${field.enumValues.join(", ")}` : "";
|
||||
lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function writeIfMissing(filePath: string, content: string): boolean {
|
||||
if (existsSync(filePath)) {return false;}
|
||||
try {
|
||||
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean {
|
||||
const destPath = join(workspaceDir, "workspace.duckdb");
|
||||
if (existsSync(destPath)) {return false;}
|
||||
|
||||
if (!projectRoot) {return false;}
|
||||
|
||||
const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb");
|
||||
if (!existsSync(seedDb)) {return false;}
|
||||
|
||||
try {
|
||||
copyFileSync(seedDb, destPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create filesystem projections for CRM objects
|
||||
for (const obj of SEED_OBJECTS) {
|
||||
const objDir = join(workspaceDir, obj.name);
|
||||
mkdirSync(objDir, { recursive: true });
|
||||
writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj));
|
||||
}
|
||||
|
||||
writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncManagedDenchSkill(workspaceDir: 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(workspaceDir, "skills", "dench");
|
||||
mkdirSync(join(workspaceDir, "skills"), { recursive: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -332,33 +146,47 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
if (seedBootstrap) {
|
||||
// Seed all bootstrap files from templates
|
||||
// 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 = filename === "IDENTITY.md"
|
||||
? buildIronclawIdentity(workspaceDir)
|
||||
: loadTemplateContent(filename, projectRoot);
|
||||
if (writeIfMissing(filePath, content)) {
|
||||
const content = loadTemplateContent(filename, projectRoot);
|
||||
try {
|
||||
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
|
||||
seeded.push(filename);
|
||||
} catch {
|
||||
// race / already exists
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed DuckDB + CRM object projections
|
||||
if (seedDuckDB(workspaceDir, projectRoot)) {
|
||||
// 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");
|
||||
for (const obj of SEED_OBJECTS) {
|
||||
seeded.push(`${obj.name}/.object.yaml`);
|
||||
}
|
||||
}
|
||||
} 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");
|
||||
}
|
||||
|
||||
// Write workspace state so the gateway knows seeding was done
|
||||
const stateDir = join(workspaceDir, ".openclaw");
|
||||
const statePath = join(stateDir, "workspace-state.json");
|
||||
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(stateDir, { recursive: true });
|
||||
mkdirSync(wsStateDir, { recursive: true });
|
||||
const state = {
|
||||
version: 1,
|
||||
bootstrapSeededAt: new Date().toISOString(),
|
||||
@ -373,19 +201,10 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const denchSynced = syncManagedDenchSkill(workspaceDir, projectRoot);
|
||||
if (denchSynced) {
|
||||
seeded.push("skills/dench/SKILL.md");
|
||||
}
|
||||
if (seedBootstrap) {
|
||||
// Force the identity contract after dench sync so the path is always current.
|
||||
writeFileSync(join(workspaceDir, "IDENTITY.md"), buildIronclawIdentity(workspaceDir), "utf-8");
|
||||
if (!seeded.includes("IDENTITY.md")) {
|
||||
seeded.push("IDENTITY.md");
|
||||
}
|
||||
}
|
||||
// Register a per-workspace agent in openclaw.json and make it the default.
|
||||
ensureAgentInConfig(workspaceName, workspaceDir);
|
||||
|
||||
// Switch to the new workspace.
|
||||
// Switch the UI to the new workspace.
|
||||
setUIActiveWorkspace(workspaceName);
|
||||
const activeWorkspace = getActiveWorkspaceName();
|
||||
|
||||
@ -396,7 +215,7 @@ export async function POST(req: Request) {
|
||||
stateDir,
|
||||
copiedFiles,
|
||||
seededFiles: seeded,
|
||||
denchSynced,
|
||||
crmSynced: !!projectRoot,
|
||||
workspaceRoot: resolveWorkspaceRoot(),
|
||||
// Backward-compat response fields while callers migrate.
|
||||
profile: workspaceName,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user