FN-8283: add secret-scrubbed organization bundle CLI

Add portable, secret-scrubbed organization export and import workflows.

- Assemble agents, raw skills, routines, automations, and settings into versioned bundles.
- Add org-export and org-import CLI commands with dry-run and collision controls.
- Preserve existing skills by default or materialize suffixed destinations, with CLI coverage.

Files changed: .changeset/fn-8283-org-bundle.md                   |   7 +
 docs/cli-reference.md                              |   9 ++
 docs/secrets.md                                    |  10 ++
 packages/cli/src/__tests__/bin.test.ts             |  17 ++
 packages/cli/src/bin.ts                            |  26 ++-
 .../cli/src/commands/__tests__/org-export.test.ts  |  30 ++++
 .../cli/src/commands/__tests__/org-import.test.ts  |  31 ++++
 packages/cli/src/commands/org-export.ts            |  19 +++
 packages/cli/src/commands/org-import.ts            |  18 +++
 packages/core/src/__tests__/org-bundle.test.ts     |  69 ++++++++
 packages/core/src/index.ts                         |  17 ++
 packages/core/src/org-bundle.ts                    | 176 +++++++++++++++++++++
 12 files changed, 428 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-8283

Fusion-Task-Lineage: 93877746-8e57-4c79-bdbe-8e4ec7a2efe6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 11:49:11 -07:00
parent bcb1256d6b
commit baa1baff9f
12 changed files with 428 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 <file> [--project <name>]` 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 <file> [--project <name>] [--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.

View File

@@ -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 <file>` 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.

View File

@@ -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"]);

View File

@@ -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 <fail|fallback-native>
fn settings export [opts] Export settings to a JSON file
fn settings import <file> [opts] Import settings from a JSON file
fn org-export <file> [--project <name>] Export one project plus global settings as a secret-scrubbed org bundle
fn org-import <file> [--dry-run] [--collision-mode <skip|suffix>] [--project <name>]
Import a portable org bundle
fn mcp list [--project <name>] [--json] List MCP servers by scope and effective resolution
fn mcp add <name> --scope <global|project> --transport <stdio|sse|http> [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 <file> [--project <name>]"); process.exit(1); }
await runOrgExport(output, { project: projectName });
break;
}
case "org-import": {
const file = args[1];
if (!file) { console.error("Usage: fn org-import <file> [--dry-run] [--collision-mode <skip|suffix>] [--project <name>]"); 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 <id> [key] [value]"); process.exit(1); }
await runPluginSettings(id, args[3], args[4], { projectName });

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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<void> {
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(); }
}

View File

@@ -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<void> {
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(); }
}

View File

@@ -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" } });
});
});

View File

@@ -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 {

View File

@@ -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<string[]> {
const out: string[] = [];
async function visit(directory: string): Promise<void> {
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<OrgBundleSkill[]> {
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<T>(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<string, unknown>)
.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<OrgBundle> {
const agents = await stores.agentStore.listAgents();
const used = new Set<string>();
const keys = new Map<string, string>();
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<string, string>;
}
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>): 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<OrgBundleMaterializeResult> {
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;
}