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:
7
.changeset/fn-8283-org-bundle.md
Normal file
7
.changeset/fn-8283-org-bundle.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
30
packages/cli/src/commands/__tests__/org-export.test.ts
Normal file
30
packages/cli/src/commands/__tests__/org-export.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
31
packages/cli/src/commands/__tests__/org-import.test.ts
Normal file
31
packages/cli/src/commands/__tests__/org-import.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
packages/cli/src/commands/org-export.ts
Normal file
19
packages/cli/src/commands/org-export.ts
Normal 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(); }
|
||||
}
|
||||
18
packages/cli/src/commands/org-import.ts
Normal file
18
packages/cli/src/commands/org-import.ts
Normal 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(); }
|
||||
}
|
||||
69
packages/core/src/__tests__/org-bundle.test.ts
Normal file
69
packages/core/src/__tests__/org-bundle.test.ts
Normal 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" } });
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
176
packages/core/src/org-bundle.ts
Normal file
176
packages/core/src/org-bundle.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user