feat(migration): implement workspace layout migration and testing

- Added `migrate-layout.ts` to handle migration from old workspace layouts to the new `devclaw/` structure.
- Introduced `migrate-layout.test.ts` for comprehensive tests covering various migration scenarios.
- Updated `workspace.ts` to ensure default files are created post-migration, including `workflow.yaml` and role-specific prompts.
- Refactored role instruction handling to accommodate new directory structure.
- Enhanced project registration to scaffold prompt files in the new `devclaw/projects/<project>/prompts/` directory.
- Adjusted setup tool descriptions and logic to reflect changes in file structure.
- Updated templates to align with the new workflow configuration and role instructions.
This commit is contained in:
Lauren ten Hoor
2026-02-15 20:19:09 +08:00
parent 89245f8ffa
commit a359ffed34
25 changed files with 1035 additions and 207 deletions

View File

@@ -0,0 +1,312 @@
/**
* Tests for workspace layout migration.
* Run with: npx tsx --test lib/setup/migrate-layout.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { migrateWorkspaceLayout } from "./migrate-layout.js";
async function fileExists(p: string): Promise<boolean> {
try { await fs.access(p); return true; } catch { return false; }
}
describe("migrateWorkspaceLayout — very old layout → devclaw/", () => {
it("should move projects/projects.json to devclaw/projects.json", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be at devclaw/");
assert.ok(!await fileExists(path.join(projDir, "projects.json")), "old projects.json should be removed");
await fs.rm(tmpDir, { recursive: true });
});
it("should rename projects/config.yaml to devclaw/workflow.yaml", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projDir = path.join(tmpDir, "projects");
await fs.mkdir(projDir, { recursive: true });
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(projDir, "config.yaml"), "roles:\n dev:\n defaultLevel: medior\n");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be at devclaw/");
assert.ok(!await fileExists(path.join(projDir, "config.yaml")), "old config.yaml should be removed");
const content = await fs.readFile(path.join(tmpDir, "devclaw", "workflow.yaml"), "utf-8");
assert.ok(content.includes("defaultLevel: medior"), "content should be preserved");
await fs.rm(tmpDir, { recursive: true });
});
it("should move roles/default/* to devclaw/prompts/ with renames", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projDir = path.join(tmpDir, "projects");
const defaultDir = path.join(projDir, "roles", "default");
await fs.mkdir(defaultDir, { recursive: true });
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(defaultDir, "dev.md"), "# Dev instructions");
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA instructions");
await fs.writeFile(path.join(defaultDir, "architect.md"), "# Architect instructions");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "architect.md")), "architect.md should stay");
const devContent = await fs.readFile(path.join(tmpDir, "devclaw", "prompts", "developer.md"), "utf-8");
assert.strictEqual(devContent, "# Dev instructions");
assert.ok(!await fileExists(defaultDir), "old default dir should be removed");
await fs.rm(tmpDir, { recursive: true });
});
it("should move roles/<project>/* to devclaw/projects/<project>/prompts/ with renames", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projDir = path.join(tmpDir, "projects");
const roleDir = path.join(projDir, "roles", "my-app");
await fs.mkdir(roleDir, { recursive: true });
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(roleDir, "dev.md"), "# My App Developer");
await fs.writeFile(path.join(roleDir, "qa.md"), "# My App Tester");
await fs.writeFile(path.join(roleDir, "architect.md"), "# My App Architect");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "architect.md")), "architect.md should be in prompts/");
const content = await fs.readFile(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md"), "utf-8");
assert.strictEqual(content, "# My App Developer");
await fs.rm(tmpDir, { recursive: true });
});
it("should rename projects/<project>/config.yaml to devclaw/projects/<project>/workflow.yaml", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projDir = path.join(tmpDir, "projects");
const appDir = path.join(projDir, "my-app");
await fs.mkdir(appDir, { recursive: true });
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(appDir, "config.yaml"), "roles:\n dev:\n defaultLevel: senior\n");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should exist");
assert.ok(!await fileExists(path.join(appDir, "config.yaml")), "old config.yaml should be removed");
await fs.rm(tmpDir, { recursive: true });
});
it("should move log/ to devclaw/log/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projDir = path.join(tmpDir, "projects");
const logDir = path.join(tmpDir, "log");
await fs.mkdir(projDir, { recursive: true });
await fs.mkdir(logDir, { recursive: true });
await fs.writeFile(path.join(projDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}');
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/");
assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old audit.log should be removed");
await fs.rm(tmpDir, { recursive: true });
});
});
describe("migrateWorkspaceLayout — intermediate layout → devclaw/", () => {
it("should move projects.json from root to devclaw/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects.json")), "projects.json should be in devclaw/");
assert.ok(!await fileExists(path.join(tmpDir, "projects.json")), "root projects.json should be removed");
await fs.rm(tmpDir, { recursive: true });
});
it("should move workflow.yaml from root to devclaw/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(tmpDir, "workflow.yaml"), "roles:\n dev:\n defaultLevel: medior\n");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "workflow.yaml")), "workflow.yaml should be in devclaw/");
assert.ok(!await fileExists(path.join(tmpDir, "workflow.yaml")), "root workflow.yaml should be removed");
await fs.rm(tmpDir, { recursive: true });
});
it("should move prompts/ from root to devclaw/prompts/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const promptsDir = path.join(tmpDir, "prompts");
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "developer.md should be in devclaw/prompts/");
assert.ok(!await fileExists(path.join(promptsDir, "developer.md")), "old prompts/developer.md should be removed");
await fs.rm(tmpDir, { recursive: true });
});
it("should move project .md files into prompts/ subdir", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projectDir = path.join(tmpDir, "projects", "my-app");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(projectDir, "developer.md"), "# My App Dev");
await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "developer.md should be in prompts/ subdir");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "workflow.yaml")), "workflow.yaml should stay at project root");
await fs.rm(tmpDir, { recursive: true });
});
it("should rename old role files (dev.md, qa.md) in prompts/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const promptsDir = path.join(tmpDir, "prompts");
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(promptsDir, "dev.md"), "# Old Dev");
await fs.writeFile(path.join(promptsDir, "qa.md"), "# Old QA");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "developer.md")), "dev.md should become developer.md");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "prompts", "tester.md")), "qa.md should become tester.md");
await fs.rm(tmpDir, { recursive: true });
});
it("should rename old role files in project prompts/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const projectDir = path.join(tmpDir, "projects", "my-app");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(projectDir, "dev.md"), "# My App Dev");
await fs.writeFile(path.join(projectDir, "qa.md"), "# My App QA");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "developer.md")), "dev.md should become prompts/developer.md");
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "projects", "my-app", "prompts", "tester.md")), "qa.md should become prompts/tester.md");
await fs.rm(tmpDir, { recursive: true });
});
it("should move log/ from root to devclaw/log/", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const logDir = path.join(tmpDir, "log");
await fs.mkdir(logDir, { recursive: true });
await fs.writeFile(path.join(tmpDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(logDir, "audit.log"), '{"ts":"2024-01-01"}');
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(tmpDir, "devclaw", "log", "audit.log")), "audit.log should be in devclaw/log/");
assert.ok(!await fileExists(path.join(logDir, "audit.log")), "old log/audit.log should be removed");
await fs.rm(tmpDir, { recursive: true });
});
});
describe("migrateWorkspaceLayout — flat project prompts → prompts/ subdir", () => {
it("should move flat .md files into prompts/ subdir", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const dataDir = path.join(tmpDir, "devclaw");
const projectDir = path.join(dataDir, "projects", "my-app");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(projectDir, "developer.md"), "# Dev");
await fs.writeFile(path.join(projectDir, "tester.md"), "# Tester");
await fs.writeFile(path.join(projectDir, "workflow.yaml"), "roles: {}");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "developer.md should be in prompts/");
assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "tester.md should be in prompts/");
assert.ok(!await fileExists(path.join(projectDir, "developer.md")), "flat developer.md should be removed");
assert.ok(await fileExists(path.join(projectDir, "workflow.yaml")), "workflow.yaml should stay");
await fs.rm(tmpDir, { recursive: true });
});
it("should rename old role files during subdir migration", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const dataDir = path.join(tmpDir, "devclaw");
const projectDir = path.join(dataDir, "projects", "my-app");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(projectDir, "dev.md"), "# Old Dev");
await fs.writeFile(path.join(projectDir, "qa.md"), "# Old QA");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(projectDir, "prompts", "developer.md")), "dev.md should become prompts/developer.md");
assert.ok(await fileExists(path.join(projectDir, "prompts", "tester.md")), "qa.md should become prompts/tester.md");
await fs.rm(tmpDir, { recursive: true });
});
it("should skip projects that already have prompts/ subdir", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const dataDir = path.join(tmpDir, "devclaw");
const projectDir = path.join(dataDir, "projects", "my-app");
const promptsDir = path.join(projectDir, "prompts");
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Already migrated");
await migrateWorkspaceLayout(tmpDir);
const content = await fs.readFile(path.join(promptsDir, "developer.md"), "utf-8");
assert.strictEqual(content, "# Already migrated", "existing prompts/ should not be touched");
await fs.rm(tmpDir, { recursive: true });
});
});
describe("migrateWorkspaceLayout — no-op cases", () => {
it("should no-op when already fully migrated", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
const dataDir = path.join(tmpDir, "devclaw");
const promptsDir = path.join(dataDir, "projects", "app", "prompts");
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(path.join(dataDir, "projects.json"), '{"projects":{}}');
await fs.writeFile(path.join(promptsDir, "developer.md"), "# Dev");
await migrateWorkspaceLayout(tmpDir);
assert.ok(await fileExists(path.join(promptsDir, "developer.md")), "prompts should still exist");
await fs.rm(tmpDir, { recursive: true });
});
it("should no-op when workspace is empty", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-migrate-"));
await migrateWorkspaceLayout(tmpDir);
await fs.rm(tmpDir, { recursive: true });
});
});