diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts index 023200786b6..5ac783e3784 100644 --- a/apps/web/app/api/workspace/init/route.test.ts +++ b/apps/web/app/api/workspace/init/route.test.ts @@ -111,8 +111,8 @@ describe("POST /api/workspace/init", () => { const workspaceDir = join(STATE_DIR, "workspace-work"); vi.mocked(existsSync).mockImplementation((p) => { const s = String(p); - if (s.endsWith("docs/reference/templates/AGENTS.md")) return true; - if (s.endsWith("skills/dench/SKILL.md")) return true; + if (s.endsWith("docs/reference/templates/AGENTS.md")) {return true;} + if (s.endsWith("skills/dench/SKILL.md")) {return true;} return false; }); @@ -142,7 +142,7 @@ describe("POST /api/workspace/init", () => { vi.mocked(existsSync).mockImplementation((p) => { const s = String(p); - if (s.endsWith("docs/reference/templates/AGENTS.md")) return true; + if (s.endsWith("docs/reference/templates/AGENTS.md")) {return true;} return false; }); @@ -155,7 +155,8 @@ describe("POST /api/workspace/init", () => { (call) => String(call[0]).endsWith("IDENTITY.md"), ); expect(identityWrites.length).toBeGreaterThan(0); - const identityContent = String(identityWrites[identityWrites.length - 1][1]); + const raw = identityWrites[identityWrites.length - 1][1]; + const identityContent = typeof raw === "string" ? raw : JSON.stringify(raw); expect(identityContent).toContain(expectedSkillPath); expect(identityContent).toContain("Ironclaw"); expect(identityContent).not.toContain("~skills"); diff --git a/apps/web/lib/workspace.test.ts b/apps/web/lib/workspace.test.ts index a7ac9089131..c7d0c0ebe43 100644 --- a/apps/web/lib/workspace.test.ts +++ b/apps/web/lib/workspace.test.ts @@ -94,12 +94,6 @@ describe("workspace utilities", () => { }; } - /** Set up mocks so resolveWorkspaceRoot() returns WS_DIR via OPENCLAW_WORKSPACE env. */ - function useEnvWorkspace(mockExists: ReturnType>) { - process.env.OPENCLAW_WORKSPACE = WS_DIR; - mockExists.mockImplementation((p) => String(p) === WS_DIR); - } - // ─── resolveWorkspaceRoot ──────────────────────────────────────── describe("resolveWorkspaceRoot", () => { diff --git a/src/cli/workspace-seed.test.ts b/src/cli/workspace-seed.test.ts new file mode 100644 index 00000000000..132d863b1f6 --- /dev/null +++ b/src/cli/workspace-seed.test.ts @@ -0,0 +1,131 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { seedWorkspaceFromAssets } from "./workspace-seed.js"; + +function createTempDir(): string { + const dir = path.join( + os.tmpdir(), + `ironclaw-seed-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createPackageRoot(tempDir: string): string { + const pkgRoot = path.join(tempDir, "pkg"); + const seedDir = path.join(pkgRoot, "assets", "seed"); + const skillsDir = path.join(pkgRoot, "skills", "dench"); + mkdirSync(seedDir, { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); + writeFileSync(path.join(seedDir, "workspace.duckdb"), "SEED_DB_CONTENT", "utf-8"); + writeFileSync( + path.join(skillsDir, "SKILL.md"), + "---\nname: database-crm-system\n---\n# Dench CRM\n", + "utf-8", + ); + return pkgRoot; +} + +describe("seedWorkspaceFromAssets", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("seeds Dench skill inside the workspace (not in state dir)", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-main"); + + seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + const skillPath = path.join(workspaceDir, "skills", "dench", "SKILL.md"); + expect(existsSync(skillPath)).toBe(true); + expect(readFileSync(skillPath, "utf-8")).toContain("database-crm-system"); + + const stateSkillPath = path.join(tempDir, "skills", "dench", "SKILL.md"); + expect(existsSync(stateSkillPath)).toBe(false); + }); + + it("generates IDENTITY.md referencing workspace CRM skill path (not virtual ~skills)", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-test"); + + seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + const identityPath = path.join(workspaceDir, "IDENTITY.md"); + expect(existsSync(identityPath)).toBe(true); + + const identityContent = readFileSync(identityPath, "utf-8"); + expect(identityContent).toContain("Ironclaw"); + expect(identityContent).toContain(path.join(workspaceDir, "skills", "dench", "SKILL.md")); + expect(identityContent).not.toContain("~skills/dench/SKILL.md"); + }); + + it("IDENTITY.md references Ironclaw system prompt contract", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-contract"); + + seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + const identityContent = readFileSync(path.join(workspaceDir, "IDENTITY.md"), "utf-8"); + expect(identityContent).toContain("Ironclaw system prompt contract"); + }); + + it("creates CRM object projection files on first seed", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-proj"); + + const result = seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + expect(result.seeded).toBe(true); + expect(result.reason).toBe("seeded"); + expect(existsSync(path.join(workspaceDir, "people", ".object.yaml"))).toBe(true); + expect(existsSync(path.join(workspaceDir, "company", ".object.yaml"))).toBe(true); + expect(existsSync(path.join(workspaceDir, "task", ".object.yaml"))).toBe(true); + expect(existsSync(path.join(workspaceDir, "WORKSPACE.md"))).toBe(true); + }); + + it("skips DuckDB seeding when workspace.duckdb already exists", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-existing"); + mkdirSync(workspaceDir, { recursive: true }); + writeFileSync(path.join(workspaceDir, "workspace.duckdb"), "EXISTING_DB", "utf-8"); + + const result = seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + expect(result.seeded).toBe(false); + expect(result.reason).toBe("already-exists"); + expect(readFileSync(path.join(workspaceDir, "workspace.duckdb"), "utf-8")).toBe("EXISTING_DB"); + }); + + it("always force-syncs IDENTITY.md even when workspace already exists (keeps updates current)", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-resync"); + mkdirSync(workspaceDir, { recursive: true }); + writeFileSync(path.join(workspaceDir, "workspace.duckdb"), "DB", "utf-8"); + writeFileSync(path.join(workspaceDir, "IDENTITY.md"), "# stale identity\n", "utf-8"); + + seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + const identityContent = readFileSync(path.join(workspaceDir, "IDENTITY.md"), "utf-8"); + expect(identityContent).toContain("Ironclaw"); + expect(identityContent).not.toContain("# stale identity"); + }); + + it("includes skills/dench/SKILL.md in projection files list", () => { + const packageRoot = createPackageRoot(tempDir); + const workspaceDir = path.join(tempDir, "workspace-list"); + + const result = seedWorkspaceFromAssets({ workspaceDir, packageRoot }); + + expect(result.projectionFiles).toContain("skills/dench/SKILL.md"); + expect(result.projectionFiles).toContain("IDENTITY.md"); + }); +}); diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts new file mode 100644 index 00000000000..2e4f1499d45 --- /dev/null +++ b/src/config/types.googlechat.ts @@ -0,0 +1,6 @@ +/** Placeholder for Google Chat channel config. Extension provides full schema. */ +export type GoogleChatConfig = { + enabled?: boolean; + accounts?: unknown[]; + [key: string]: unknown; +}; diff --git a/src/discord/pluralkit.ts b/src/discord/pluralkit.ts new file mode 100644 index 00000000000..4593c5c1dd5 --- /dev/null +++ b/src/discord/pluralkit.ts @@ -0,0 +1,7 @@ +/** PluralKit identity resolution for Discord proxied messages. */ +export type DiscordPluralKitConfig = { + /** Enable PluralKit resolution (default: false). */ + enabled?: boolean; + /** Optional PluralKit API token for private systems. */ + token?: string; +}; diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts new file mode 100644 index 00000000000..1c8aa027b19 --- /dev/null +++ b/src/infra/outbound/deliver.ts @@ -0,0 +1,9 @@ +/** Stub type for test setup; full implementation may live in upstream. */ +export type OutboundSendDeps = { + sendDiscord?: (to: string, text: string, opts?: { verbose?: boolean; mediaUrl?: string }) => Promise<{ channel?: string; messageId?: string }>; + sendSlack?: (to: string, text: string, opts?: { verbose?: boolean; mediaUrl?: string }) => Promise<{ channel?: string; messageId?: string }>; + sendTelegram?: (to: string, text: string, opts?: { verbose?: boolean; mediaUrl?: string }) => Promise<{ channel?: string; messageId?: string }>; + sendWhatsApp?: (to: string, text: string, opts?: { verbose?: boolean; mediaUrl?: string }) => Promise<{ channel?: string; messageId?: string }>; + sendSignal?: (to: string, text: string, opts?: { verbose?: boolean; mediaUrl?: string }) => Promise<{ channel?: string; messageId?: string }>; + sendIMessage?: (to: string, text: string, opts?: { verbose?: boolean; mediaUrl?: string }) => Promise<{ channel?: string; messageId?: string }>; +}; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts new file mode 100644 index 00000000000..1205c90fe94 --- /dev/null +++ b/src/plugins/runtime.ts @@ -0,0 +1,10 @@ +/** Stub for test setup; maintains active plugin registry for tests. */ +let activeRegistry: unknown = null; + +export function setActivePluginRegistry(registry: unknown): void { + activeRegistry = registry; +} + +export function getActivePluginRegistry(): unknown { + return activeRegistry; +} diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts new file mode 100644 index 00000000000..9398ec39a98 --- /dev/null +++ b/src/test-utils/channel-plugins.ts @@ -0,0 +1,10 @@ +/** Stub for test setup; creates an immutable plugin registry from entries. */ +export type RegistryEntry = { + pluginId: string; + plugin: unknown; + source: string; +}; + +export function createTestRegistry(entries: RegistryEntry[]): unknown { + return Object.freeze({ entries, plugins: new Map(entries.map((e) => [e.pluginId, e.plugin])) }); +}