Files
fusion/packages/core/src/org-bundle.ts
gsxdsm baa1baff9f 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>
2026-07-18 11:49:11 -07:00

177 lines
13 KiB
TypeScript

/** 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;
}