diff --git a/.changeset/fn-8283-org-bundle.md b/.changeset/fn-8283-org-bundle.md new file mode 100644 index 0000000000..ca72bf0761 --- /dev/null +++ b/.changeset/fn-8283-org-bundle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add portable secret-scrubbed organization export and import commands. +category: feature +dev: Adds `fn org-export` and `fn org-import` for one project plus global settings. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9f2d16f2a5..92d321af87 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1281,3 +1281,12 @@ Subcommands: `search`, `install`. | `--poll-ms` | `fn chat` | For configuration details used by these commands, see [Settings Reference](./settings-reference.md). + +### Organization portability + +- `fn org-export [--project ]` writes a single secret-scrubbed bundle of + the selected project's agents, raw skill files, routines, automations, and settings + plus global settings. +- `fn org-import [--project ] [--dry-run] [--collision-mode skip|suffix]` + materializes a bundle. `--dry-run` reports the plan without modifying stores or files; + collision mode defaults to `skip` and `suffix` creates deterministically named copies. diff --git a/docs/secrets.md b/docs/secrets.md index 86d9d2af20..f704276e3d 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -211,3 +211,13 @@ Track follow-up: **FN-5031** (missing `packages/core/src/__tests__/secrets-env.t - Pending advanced capabilities: - Master-key rotation UX and key lifecycle tooling - TTL/rotation automation, env-set profiles, KMS/Vault backends, per-node asymmetric sync + +## Organization bundles + +`fn org-export ` creates a portable bundle for one selected project plus global +settings. Exports scrub credential values by default: provider keys, daemon and remote +access tokens, webhook secrets, and inline `secretsEnv` values are omitted. MCP and +other secret references remain key-only, so importing a bundle requires the destination +operator to provision the referenced secrets. `secretsAccessPolicy` and +`secretsSyncPassphraseConfigured` remain because they are configuration/state rather +than secret values. diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index 62f54dc240..57c418ba6b 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -55,6 +55,8 @@ const commandMocks = vi.hoisted(() => ({ runSettingsSet: vi.fn(), runSettingsExport: vi.fn(), runSettingsImport: vi.fn(), + runOrgExport: vi.fn(), + runOrgImport: vi.fn(), runGitStatus: vi.fn(), runGitFetch: vi.fn(), @@ -212,6 +214,8 @@ vi.mock("../commands/settings.js", () => ({ })); vi.mock("../commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport })); vi.mock("../commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport })); +vi.mock("../commands/org-export.js", () => ({ runOrgExport: commandMocks.runOrgExport })); +vi.mock("../commands/org-import.js", () => ({ runOrgImport: commandMocks.runOrgImport })); vi.mock("../commands/git.js", () => ({ runGitStatus: commandMocks.runGitStatus, @@ -423,6 +427,19 @@ describe("bin command routing and fallbacks", () => { }); }); + it("routes organization export and import flags", async () => { + await runBin(["org-export", "bundle.json", "-P", "demo"]); + await runBin(["org-import", "bundle.json", "--dry-run", "--collision-mode", "suffix", "-P", "demo"]); + + expect(commandMocks.runOrgExport).toHaveBeenCalledWith("bundle.json", { project: "demo" }); + expect(commandMocks.runOrgImport).toHaveBeenCalledWith("bundle.json", { project: "demo", dryRun: true, collisionMode: "suffix" }); + }); + + it("rejects an invalid organization import collision mode", async () => { + await expect(runBin(["org-import", "bundle.json", "--collision-mode", "replace"])).rejects.toThrow("process.exit:1"); + expect(errorSpy).toHaveBeenCalledWith("--collision-mode must be skip or suffix"); + }); + it("routes settings import with file and flags", async () => { await runBin(["settings", "import", "file.json", "--scope", "global", "--merge", "--yes", "-P", "demo"]); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index e401d8a08d..27bd580c37 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -142,6 +142,8 @@ async function loadCommandHandlers() { const { runAgentStop, runAgentStart } = await import("./commands/agent.js"); const { runAgentImport } = await import("./commands/agent-import.js"); const { runAgentExport } = await import("./commands/agent-export.js"); + const { runOrgExport } = await import("./commands/org-export.js"); + const { runOrgImport } = await import("./commands/org-import.js"); const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js"); const { runChatInteractive } = await import("./commands/chat.js"); const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js"); @@ -256,6 +258,8 @@ async function loadCommandHandlers() { runAgentStart, runAgentImport, runAgentExport, + runOrgExport, + runOrgImport, runMessageInbox, runMessageOutbox, runMessageSend, @@ -407,6 +411,9 @@ PR: fn settings set worktrunk.onFailure fn settings export [opts] Export settings to a JSON file fn settings import [opts] Import settings from a JSON file + fn org-export [--project ] Export one project plus global settings as a secret-scrubbed org bundle + fn org-import [--dry-run] [--collision-mode ] [--project ] + Import a portable org bundle fn mcp list [--project ] [--json] List MCP servers by scope and effective resolution fn mcp add --scope --transport [opts] Add an MCP server using secret references for env/header values @@ -775,6 +782,8 @@ async function main() { runAgentStart, runAgentImport, runAgentExport, + runOrgExport, + runOrgImport, runMessageInbox, runMessageOutbox, runMessageSend, @@ -1662,6 +1671,21 @@ async function main() { break; } + case "org-export": { + const output = args[1]; + if (!output) { console.error("Usage: fn org-export [--project ]"); process.exit(1); } + await runOrgExport(output, { project: projectName }); + break; + } + case "org-import": { + const file = args[1]; + if (!file) { console.error("Usage: fn org-import [--dry-run] [--collision-mode ] [--project ]"); process.exit(1); } + const collisionMode = getFlagValue(args.slice(2), "--collision-mode"); + if (collisionMode && collisionMode !== "skip" && collisionMode !== "suffix") { console.error("--collision-mode must be skip or suffix"); process.exit(1); } + await runOrgImport(file, { project: projectName, dryRun: args.includes("--dry-run"), collisionMode: collisionMode as "skip" | "suffix" | undefined }); + break; + } + case "settings": { const subcommand = args[1]; if (!subcommand || subcommand === "show") { @@ -2145,7 +2169,7 @@ async function main() { await runPluginAvailable(); break; } - case "settings": { + case "settings": { const id = args[2]; if (!id) { console.error("Usage: fn plugin settings [key] [value]"); process.exit(1); } await runPluginSettings(id, args[3], args[4], { projectName }); diff --git a/packages/cli/src/commands/__tests__/org-export.test.ts b/packages/cli/src/commands/__tests__/org-export.test.ts new file mode 100644 index 0000000000..8f06053ce8 --- /dev/null +++ b/packages/cli/src/commands/__tests__/org-export.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assembleOrgBundle: vi.fn(), writeFile: vi.fn(), rename: vi.fn(), agentInit: vi.fn(), agentClose: vi.fn(), shutdown: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ writeFile: mocks.writeFile, rename: mocks.rename })); +vi.mock("@fusion/core", () => ({ + AgentStore: vi.fn(function AgentStore() { return { init: mocks.agentInit, close: mocks.agentClose }; }), + RoutineStore: vi.fn(function RoutineStore() {}), AutomationStore: vi.fn(function AutomationStore() {}), + createTaskStoreForBackend: vi.fn(async () => ({ taskStore: { asyncLayer: {} }, shutdown: mocks.shutdown })), + assembleOrgBundle: mocks.assembleOrgBundle, +})); +vi.mock("../../project-context.js", () => ({ resolveProjectPathOnly: vi.fn(async () => "/projects/demo") })); + +import { runOrgExport } from "../org-export.js"; + +describe("runOrgExport", () => { + it("writes the secret-scrubbed organization bundle atomically", async () => { + mocks.assembleOrgBundle.mockResolvedValue({ agents: [], skills: [], routines: [], automations: [] }); + mocks.writeFile.mockResolvedValue(undefined); + mocks.rename.mockResolvedValue(undefined); + await runOrgExport("./bundle.json", { project: "demo" }); + expect(mocks.assembleOrgBundle).toHaveBeenCalledWith(expect.objectContaining({ projectRoot: "/projects/demo" })); + expect(mocks.writeFile).toHaveBeenCalledWith(expect.stringMatching(/bundle\.json\.tmp$/), expect.any(String)); + expect(mocks.rename).toHaveBeenCalledWith(expect.stringMatching(/bundle\.json\.tmp$/), expect.stringMatching(/bundle\.json$/)); + expect(mocks.agentClose).toHaveBeenCalled(); + expect(mocks.shutdown).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/__tests__/org-import.test.ts b/packages/cli/src/commands/__tests__/org-import.test.ts new file mode 100644 index 0000000000..80cf109208 --- /dev/null +++ b/packages/cli/src/commands/__tests__/org-import.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readFile: vi.fn(), materializeOrgBundle: vi.fn(), agentInit: vi.fn(), agentClose: vi.fn(), shutdown: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ readFile: mocks.readFile })); +vi.mock("@fusion/core", () => ({ + AgentStore: vi.fn(function AgentStore() { return { init: mocks.agentInit, close: mocks.agentClose }; }), + RoutineStore: vi.fn(function RoutineStore() {}), AutomationStore: vi.fn(function AutomationStore() {}), + createTaskStoreForBackend: vi.fn(async () => ({ taskStore: { asyncLayer: {} }, shutdown: mocks.shutdown })), + materializeOrgBundle: mocks.materializeOrgBundle, +})); +vi.mock("../../project-context.js", () => ({ resolveProjectPathOnly: vi.fn(async () => "/projects/demo") })); + +import { runOrgImport } from "../org-import.js"; + +describe("runOrgImport", () => { + it("passes dry-run and collision policy to bundle materialization", async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ version: 1 })); + mocks.materializeOrgBundle.mockResolvedValue({ created: {}, skipped: {}, errors: [] }); + await runOrgImport("bundle.json", { project: "demo", dryRun: true, collisionMode: "suffix" }); + expect(mocks.materializeOrgBundle).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: "/projects/demo" }), + { version: 1 }, + { dryRun: true, collisionMode: "suffix" }, + ); + expect(mocks.agentClose).toHaveBeenCalled(); + expect(mocks.shutdown).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/org-export.ts b/packages/cli/src/commands/org-export.ts new file mode 100644 index 0000000000..eae66cafc9 --- /dev/null +++ b/packages/cli/src/commands/org-export.ts @@ -0,0 +1,19 @@ +/** CLI entrypoint for portable, secret-scrubbed organization bundles. */ +import { resolve } from "node:path"; +import { AgentStore, AutomationStore, RoutineStore, assembleOrgBundle, createTaskStoreForBackend } from "@fusion/core"; +import { resolveProjectPathOnly } from "../project-context.js"; + +/* FNXC:OrgPortability 2026-07-16-00:00: org-export always writes the scrubbed composition returned by core, so the CLI artifact is safe to hand off or commit without an operator secret audit. */ +export async function runOrgExport(output: string, options: { project?: string } = {}): Promise { + const rootDir = options.project ? await resolveProjectPathOnly(options.project) : process.cwd(); + const boot = await createTaskStoreForBackend({ rootDir }); + const agents = new AgentStore({ rootDir: `${rootDir}/.fusion`, asyncLayer: boot.taskStore.asyncLayer! }); + try { + await agents.init(); + const bundle = await assembleOrgBundle({ projectRoot: rootDir, agentStore: agents, routineStore: new RoutineStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), automationStore: new AutomationStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), settingsStore: boot.taskStore }); + await import("node:fs/promises").then(({ writeFile, rename }) => writeFile(`${resolve(output)}.tmp`, JSON.stringify(bundle, null, 2)).then(() => rename(`${resolve(output)}.tmp`, resolve(output)))); + console.log(`Organization bundle exported to ${resolve(output)}`); + console.log(`Agents: ${bundle.agents.length}; skills: ${bundle.skills.length}; routines: ${bundle.routines.length}; automations: ${bundle.automations.length}`); + console.log("Secrets scrubbed: credentials are omitted; secret references are retained by key."); + } finally { agents.close(); await boot.shutdown(); } +} diff --git a/packages/cli/src/commands/org-import.ts b/packages/cli/src/commands/org-import.ts new file mode 100644 index 0000000000..f8c596ba6d --- /dev/null +++ b/packages/cli/src/commands/org-import.ts @@ -0,0 +1,18 @@ +/** CLI entrypoint for importing portable organization bundles. */ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { AgentStore, AutomationStore, RoutineStore, createTaskStoreForBackend, materializeOrgBundle, type OrgBundle } from "@fusion/core"; +import { resolveProjectPathOnly } from "../project-context.js"; + +export async function runOrgImport(file: string, options: { project?: string; dryRun?: boolean; collisionMode?: "skip" | "suffix" } = {}): Promise { + const rootDir = options.project ? await resolveProjectPathOnly(options.project) : process.cwd(); + const bundle = JSON.parse(await readFile(resolve(file), "utf8")) as OrgBundle; + const boot = await createTaskStoreForBackend({ rootDir }); + const agents = new AgentStore({ rootDir: `${rootDir}/.fusion`, asyncLayer: boot.taskStore.asyncLayer! }); + try { + await agents.init(); + const result = await materializeOrgBundle({ projectRoot: rootDir, agentStore: agents, routineStore: new RoutineStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), automationStore: new AutomationStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), settingsStore: boot.taskStore }, bundle, { dryRun: options.dryRun, collisionMode: options.collisionMode }); + console.log(options.dryRun ? "Organization import dry-run:" : "Organization imported:"); + console.log(JSON.stringify(result, null, 2)); + } finally { agents.close(); await boot.shutdown(); } +} diff --git a/packages/core/src/__tests__/org-bundle.test.ts b/packages/core/src/__tests__/org-bundle.test.ts new file mode 100644 index 0000000000..ba5988ee20 --- /dev/null +++ b/packages/core/src/__tests__/org-bundle.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { assembleOrgBundle, materializeOrgBundle, scrubOrgBundleSecrets, type OrgBundle } from "../org-bundle.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +const settings = { version: 2 as const, exportedAt: "2026-01-01T00:00:00.000Z", global: { daemonToken: "daemon-value", secretsAccessPolicy: { mode: "allow" }, secretsSyncPassphraseConfigured: true, customProviders: [{ apiKey: "provider-value" }], remoteAccess: { providers: { cloudflare: { tunnelToken: "tunnel-value" } }, tokenStrategy: { persistent: { token: "persistent-value" } } } }, project: { githubAuthToken: "github-value", secretsEnv: { values: { TOKEN: "env-value" } }, mcpServers: [{ secretRef: "kept-key" }] } }; +function stores(root: string) { + const createdAgents: Array<{ id: string; name: string }> = []; + const routines: any[] = []; + return { + projectRoot: root, + agentStore: { listAgents: vi.fn(async () => [{ id: "source-agent", name: "Source", role: "engineer", metadata: {}, instructionsText: "token=inline-value" }]), createAgent: vi.fn(async (input) => { const agent = { id: `destination-${createdAgents.length}`, ...input }; createdAgents.push(agent); return agent; }), updateAgent: vi.fn() }, + routineStore: { listRoutines: vi.fn(async () => routines), createRoutine: vi.fn(async (input) => { routines.push({ id: "routine", ...input }); return routines.at(-1); }) }, + automationStore: { listSchedules: vi.fn(async () => []), createSchedule: vi.fn() }, + settingsStore: { getGlobalSettingsStore: () => ({ getSettings: async () => settings.global }), getSettingsByScope: async () => ({ project: settings.project }), listWorkflowSettingValuesForProject: async () => ({}), getWorkflowSettingsProjectId: () => "project", updateWorkflowSettingValues: vi.fn(), updateGlobalSettings: vi.fn(), updateSettings: vi.fn() }, + createdAgents, routines, + }; +} +describe("org bundles", () => { + it("preserves raw custom SKILL.md content while scrubbing every secret-bearing setting", async () => { + const root = await mkdtemp(join(tmpdir(), "org-bundle-")); roots.push(root); + await mkdir(join(root, ".agents/skills/custom"), { recursive: true }); + const skill = "---\nname: custom\ncustomFrontmatter: survives\n---\n# Actual body\n"; + await writeFile(join(root, ".agents/skills/custom/SKILL.md"), skill); + const fixture = stores(root); + const bundle = await assembleOrgBundle(fixture as any); + expect(bundle.skills[0]).toEqual({ sourceRelativePath: ".agents/skills/custom/SKILL.md", rawSkillMd: skill }); + expect(JSON.stringify(bundle)).not.toContain("daemon-value"); + expect(JSON.stringify(bundle)).not.toContain("provider-value"); + expect(JSON.stringify(bundle)).not.toContain("tunnel-value"); + expect(bundle.settings.global).toMatchObject({ secretsAccessPolicy: { mode: "allow" }, secretsSyncPassphraseConfigured: true }); + expect(JSON.stringify(bundle)).toContain("kept-key"); + }); + it("remaps routine agents and has a no-write dry run", async () => { + const root = await mkdtemp(join(tmpdir(), "org-bundle-")); roots.push(root); + const fixture = stores(root); + fixture.agentStore.listAgents.mockResolvedValue([]); + const bundle: OrgBundle = { version: 1, assembledAt: new Date().toISOString(), agents: [{ key: "source", manifest: { name: "Source", role: "engineer", schema: "agentcompanies/v1" } }], skills: [{ sourceRelativePath: "skills/custom/SKILL.md", rawSkillMd: "# real" }], routines: [{ agentKey: "source", routine: { id: "old", agentId: "old", name: "Routine", trigger: { type: "manual" }, catchUpPolicy: "run_one", executionPolicy: "queue", enabled: true, runCount: 0, runHistory: [], createdAt: "", updatedAt: "" } }], automations: [], settings: { version: 2, exportedAt: new Date().toISOString(), project: {} } }; + const dry = await materializeOrgBundle(fixture as any, bundle, { dryRun: true }); + expect(dry.agentIdMap.source).toBe("planned:source"); + expect(fixture.createdAgents).toHaveLength(0); + const result = await materializeOrgBundle(fixture as any, bundle); + expect(fixture.routines[0].agentId).toBe(result.agentIdMap.source); + expect(await readFile(join(root, "skills/custom/SKILL.md"), "utf8")).toBe("# real"); + }); + it("preserves an existing skill by default and creates a suffixed directory on request", async () => { + const root = await mkdtemp(join(tmpdir(), "org-bundle-")); roots.push(root); + const fixture = stores(root); + fixture.agentStore.listAgents.mockResolvedValue([]); + await mkdir(join(root, "skills/custom"), { recursive: true }); + await writeFile(join(root, "skills/custom/SKILL.md"), "# existing"); + const bundle = { version: 1, assembledAt: "", agents: [], skills: [{ sourceRelativePath: "skills/custom/SKILL.md", rawSkillMd: "# imported" }], routines: [], automations: [], settings: { version: 2, exportedAt: "", project: {} } } as OrgBundle; + + const skipped = await materializeOrgBundle(fixture as any, bundle); + expect(skipped.skipped.skills).toEqual(["skills/custom/SKILL.md"]); + expect(await readFile(join(root, "skills/custom/SKILL.md"), "utf8")).toBe("# existing"); + + const suffixed = await materializeOrgBundle(fixture as any, bundle, { collisionMode: "suffix" }); + expect(suffixed.created.skills).toEqual(["skills/custom-2/SKILL.md"]); + expect(await readFile(join(root, "skills/custom-2/SKILL.md"), "utf8")).toBe("# imported"); + }); + it("keeps secret references but removes secret values", () => { + const bundle = { version: 1, assembledAt: "", agents: [], skills: [], routines: [], automations: [], settings: { version: 2, exportedAt: "", global: { daemonToken: "no", mcp: { secretRef: "name" } } } } as OrgBundle; + expect(scrubOrgBundleSecrets(bundle).settings.global).toEqual({ mcp: { secretRef: "name" } }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9ae555e2ca..3c32bddff3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2191,6 +2191,23 @@ export type { ExportResult, } from "./agent-companies-exporter.js"; +// ── Organization portability ────────────────────────────── +export { + ORG_BUNDLE_VERSION, + assembleOrgBundle, + materializeOrgBundle, + scrubOrgBundleSecrets, +} from "./org-bundle.js"; +export type { + OrgBundle, + OrgBundleAgent, + OrgBundleSkill, + OrgBundleRoutine, + OrgBundleStores, + OrgBundleMaterializeOptions, + OrgBundleMaterializeResult, +} from "./org-bundle.js"; + // ── Chat System ─────────────────────────────────────────── export type { diff --git a/packages/core/src/org-bundle.ts b/packages/core/src/org-bundle.ts new file mode 100644 index 0000000000..8220156943 --- /dev/null +++ b/packages/core/src/org-bundle.ts @@ -0,0 +1,176 @@ +/** Portable, secret-safe organization bundles. */ +import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +import type { AgentStore } from "./agent-store.js"; +import type { AgentManifest } from "./agent-companies-types.js"; +import { agentToCompaniesManifest, slugify } from "./agent-companies-exporter.js"; +import { prepareAgentCompaniesImport } from "./agent-companies-parser.js"; +import type { AutomationStore } from "./automation-store.js"; +import type { ScheduledTask, ScheduledTaskCreateInput } from "./automation.js"; +import { redactSecrets } from "./redact-secrets.js"; +import type { RoutineStore } from "./routine-store.js"; +import type { Routine, RoutineCreateInput } from "./routine.js"; +import { exportSettings, importSettings, validateImportData, type SettingsExportData } from "./settings-export.js"; +import type { TaskStore } from "./store.js"; + +export const ORG_BUNDLE_VERSION = 1 as const; + +/** A raw skill file, deliberately not a lossy SkillManifest. */ +export interface OrgBundleSkill { + sourceRelativePath: string; + rawSkillMd: string; +} +export interface OrgBundleAgent { key: string; manifest: AgentManifest; } +export interface OrgBundleRoutine { routine: Routine; agentKey: string; } +export interface OrgBundle { + version: typeof ORG_BUNDLE_VERSION; + assembledAt: string; + source?: string; + agents: OrgBundleAgent[]; + skills: OrgBundleSkill[]; + routines: OrgBundleRoutine[]; + automations: ScheduledTask[]; + settings: SettingsExportData; +} +export interface OrgBundleStores { + projectRoot: string; + agentStore: AgentStore; + routineStore: RoutineStore; + automationStore: AutomationStore; + settingsStore: TaskStore; +} + +/* +FNXC:OrgPortability 2026-07-16-00:00: +Whole-org portability is one selected project's agents, real skills, routines and automations plus global/project/workflow settings. SkillManifest is intentionally not used for skill files because its fixed frontmatter drops custom keys; raw SKILL.md bytes and source paths are the portable artifact. +*/ +const SKILL_ROOTS = ["skills", ".fusion/skills", ".agents/skills"]; +const SECRET_KEY = /(?:api[_-]?key|token|password|credential|auth|secret)(?!ref$)/i; +const SAFE_SECRET_CONFIG = new Set(["secretRef", "secretsAccessPolicy", "secretsSyncPassphraseConfigured"]); + +async function filesUnder(root: string): Promise { + const out: string[] = []; + async function visit(directory: string): Promise { + let entries; try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile() && entry.name === "SKILL.md") out.push(path); + } + } + await visit(root); return out; +} +async function readSkills(projectRoot: string): Promise { + const root = resolve(projectRoot); + const paths = (await Promise.all(SKILL_ROOTS.map((skillRoot) => filesUnder(join(root, skillRoot))))).flat(); + return Promise.all(paths.sort().map(async (path) => ({ + sourceRelativePath: relative(root, path).split(sep).join("/"), rawSkillMd: await readFile(path, "utf8"), + }))); +} +function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function scrubValue(value: unknown, key?: string): unknown { + if (SAFE_SECRET_CONFIG.has(key ?? "")) return value; + if (key && (key === "secretsEnv" || SECRET_KEY.test(key))) return undefined; + if (typeof value === "string") return redactSecrets(value); + if (Array.isArray(value)) return value.map((item) => scrubValue(item)); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value as Record) + .flatMap(([entryKey, entryValue]) => { + const scrubbed = scrubValue(entryValue, entryKey); + return scrubbed === undefined ? [] : [[entryKey, scrubbed]]; + })); +} +/** Removes secret values while retaining reference-only configuration such as MCP secretRef. */ +export function scrubOrgBundleSecrets(bundle: OrgBundle): OrgBundle { + const copy = clone(bundle); + copy.agents = copy.agents.map((agent) => ({ ...agent, manifest: scrubValue(agent.manifest) as AgentManifest })); + copy.skills = copy.skills.map((skill) => ({ ...skill, rawSkillMd: redactSecrets(skill.rawSkillMd) })); + copy.routines = copy.routines.map(({ routine, agentKey }) => ({ routine: scrubValue(routine) as Routine, agentKey })); + copy.automations = copy.automations.map((automation) => scrubValue(automation) as ScheduledTask); + copy.settings = scrubValue(copy.settings) as SettingsExportData; + return copy; +} + +export async function assembleOrgBundle(stores: OrgBundleStores): Promise { + const agents = await stores.agentStore.listAgents(); + const used = new Set(); + const keys = new Map(); + for (const agent of agents) { let key = slugify(agent.name, "agent"); let n = 2; while (used.has(key)) key = `${slugify(agent.name, "agent")}-${n++}`; used.add(key); keys.set(agent.id, key); } + const routines = await stores.routineStore.listRoutines(); + const bundle: OrgBundle = { + version: ORG_BUNDLE_VERSION, assembledAt: new Date().toISOString(), source: resolve(stores.projectRoot), + agents: agents.map((agent) => ({ key: keys.get(agent.id)!, manifest: agentToCompaniesManifest(agent, { reportsTo: agent.reportsTo ? keys.get(agent.reportsTo) ?? null : null }) })), + skills: await readSkills(stores.projectRoot), + routines: routines.flatMap((routine) => { const agentKey = keys.get(routine.agentId); return agentKey ? [{ routine, agentKey }] : []; }), + automations: await stores.automationStore.listSchedules(), settings: await exportSettings(stores.settingsStore, { scope: "both", source: resolve(stores.projectRoot) }), + }; + return scrubOrgBundleSecrets(bundle); +} + +export interface OrgBundleMaterializeOptions { dryRun?: boolean; collisionMode?: "skip" | "suffix"; } +export interface OrgBundleMaterializeResult { + created: { agents: string[]; skills: string[]; routines: string[]; automations: string[]; settings: boolean }; + skipped: { agents: string[]; skills: string[]; routines: string[]; automations: string[] }; + errors: Array<{ section: string; name: string; error: string }>; + agentIdMap: Record; +} +function validateBundle(bundle: OrgBundle): void { + if (bundle?.version !== ORG_BUNDLE_VERSION) throw new Error(`Unsupported org bundle version: ${bundle?.version}`); + if (!Array.isArray(bundle.agents) || !Array.isArray(bundle.skills) || !Array.isArray(bundle.routines) || !Array.isArray(bundle.automations)) throw new Error("Invalid org bundle shape"); + const errors = validateImportData(bundle.settings); if (errors.length) throw new Error(`Invalid bundle settings: ${errors.join("; ")}`); +} +function uniqueName(name: string, existing: Set): string { let result = name, index = 2; while (existing.has(result.toLowerCase())) result = `${name} (${index++})`; return result; } +/** Materialize an org bundle without ever restoring a secret value. */ +export async function materializeOrgBundle(stores: OrgBundleStores, input: OrgBundle, options: OrgBundleMaterializeOptions = {}): Promise { + const bundle = scrubOrgBundleSecrets(input); validateBundle(bundle); + const result: OrgBundleMaterializeResult = { created: { agents: [], skills: [], routines: [], automations: [], settings: false }, skipped: { agents: [], skills: [], routines: [], automations: [] }, errors: [], agentIdMap: {} }; + const existingAgents = await stores.agentStore.listAgents(); const existingNames = new Set(existingAgents.map((agent) => agent.name.toLowerCase())); + // Reuse the shared parser for manifest validation/normalization before creation. + const prepared = prepareAgentCompaniesImport({ agents: bundle.agents.map((agent) => agent.manifest), teams: [], projects: [], tasks: [], skills: [] }, { existingAgents }); + const preparedByName = new Map(prepared.items.map((item) => [item.input.name, item])); + for (const item of bundle.agents) { + const existing = existingAgents.find((agent) => agent.name.toLowerCase() === item.manifest.name.toLowerCase()); + if (existing && options.collisionMode !== "suffix") { result.skipped.agents.push(item.key); result.agentIdMap[item.key] = existing.id; continue; } + const parsed = preparedByName.get(item.manifest.name); if (!parsed) { result.errors.push({ section: "agents", name: item.key, error: "Invalid agent manifest" }); continue; } + const name = existing ? uniqueName(parsed.input.name, existingNames) : parsed.input.name; + if (options.dryRun) { result.created.agents.push(name); result.agentIdMap[item.key] = `planned:${item.key}`; existingNames.add(name.toLowerCase()); continue; } + try { const created = await stores.agentStore.createAgent({ ...parsed.input, name }); result.created.agents.push(created.id); result.agentIdMap[item.key] = created.id; existingNames.add(name.toLowerCase()); } + catch (error) { result.errors.push({ section: "agents", name, error: error instanceof Error ? error.message : String(error) }); } + } + // Assign manager links after all destination IDs exist. + if (!options.dryRun) for (const item of bundle.agents) { const destination = result.agentIdMap[item.key], manager = item.manifest.reportsTo; if (destination && manager && result.agentIdMap[manager] && !destination.startsWith("planned:")) await stores.agentStore.updateAgent(destination, { reportsTo: result.agentIdMap[manager] }).catch((error) => result.errors.push({ section: "agents", name: item.key, error: String(error) })); } + for (const skill of bundle.skills) { + const projectRoot = resolve(stores.projectRoot); + let target = resolve(projectRoot, skill.sourceRelativePath); + if (!target.startsWith(projectRoot + sep)) { result.errors.push({ section: "skills", name: skill.sourceRelativePath, error: "Skill path escapes project root" }); continue; } + const targetExists = await access(target).then(() => true).catch(() => false); + if (targetExists && options.collisionMode !== "suffix") { result.skipped.skills.push(skill.sourceRelativePath); continue; } + if (targetExists) { + const skillDirectory = dirname(target); + let index = 2; + do { target = `${skillDirectory}-${index++}${sep}SKILL.md`; } while (await access(target).then(() => true).catch(() => false)); + } + const destinationPath = relative(projectRoot, target).split(sep).join("/"); + /* + FNXC:OrgPortability 2026-07-18-11:44: + Imported SKILL.md files must honor the same collision policy as persisted entities. Default imports preserve an existing destination; suffix mode creates a sibling skill directory rather than overwriting its raw content. + */ + result.created.skills.push(destinationPath); if (!options.dryRun) { await mkdir(dirname(target), { recursive: true }); await writeFile(target, skill.rawSkillMd, "utf8"); } + } + const routineNames = new Set((await stores.routineStore.listRoutines()).map((routine) => routine.name.toLowerCase())); + for (const entry of bundle.routines) { + const agentId = result.agentIdMap[entry.agentKey]; if (!agentId) { result.skipped.routines.push(entry.routine.name); continue; } + if (routineNames.has(entry.routine.name.toLowerCase()) && options.collisionMode !== "suffix") { result.skipped.routines.push(entry.routine.name); continue; } + const name = routineNames.has(entry.routine.name.toLowerCase()) ? uniqueName(entry.routine.name, routineNames) : entry.routine.name; + result.created.routines.push(name); routineNames.add(name.toLowerCase()); if (!options.dryRun) await stores.routineStore.createRoutine({ ...entry.routine, id: undefined, name, agentId, lastRunAt: undefined, lastRunResult: undefined, nextRunAt: undefined, runCount: undefined, runHistory: undefined, createdAt: undefined, updatedAt: undefined } as unknown as RoutineCreateInput); + } + const automationNames = new Set((await stores.automationStore.listSchedules()).map((schedule) => schedule.name.toLowerCase())); + for (const schedule of bundle.automations) { + if (automationNames.has(schedule.name.toLowerCase()) && options.collisionMode !== "suffix") { result.skipped.automations.push(schedule.name); continue; } + const name = automationNames.has(schedule.name.toLowerCase()) ? uniqueName(schedule.name, automationNames) : schedule.name; + result.created.automations.push(name); automationNames.add(name.toLowerCase()); if (!options.dryRun) await stores.automationStore.createSchedule({ ...schedule, id: undefined, name, lastRunAt: undefined, lastRunResult: undefined, nextRunAt: undefined, runCount: undefined, runHistory: undefined, createdAt: undefined, updatedAt: undefined } as unknown as ScheduledTaskCreateInput); + } + if (!options.dryRun) { const imported = await importSettings(stores.settingsStore, bundle.settings, { scope: "both", merge: true }); if (!imported.success) result.errors.push({ section: "settings", name: "settings", error: imported.error ?? "Import failed" }); else result.created.settings = true; } + return result; +}