diff --git a/packages/core/src/__tests__/agent-companies-parser.test.ts b/packages/core/src/__tests__/agent-companies-parser.test.ts index 541bc00f57..af0a56ad61 100644 --- a/packages/core/src/__tests__/agent-companies-parser.test.ts +++ b/packages/core/src/__tests__/agent-companies-parser.test.ts @@ -659,6 +659,121 @@ name: Nested Archive CEO }); }); + describe("lazy-load isolation", () => { + it("does not load extract-zip or tar for pure manifest helpers", async () => { + const extractZipModuleFactory = vi.fn(() => ({ + default: vi.fn(async () => undefined), + })); + const tarModuleFactory = vi.fn(() => ({ + x: vi.fn(async () => undefined), + })); + + vi.resetModules(); + vi.doMock("extract-zip", extractZipModuleFactory); + vi.doMock("tar", tarModuleFactory); + + const parserModule = await import("../agent-companies-parser.js"); + + const parsed = parserModule.parseYamlFrontmatter(`--- +name: Pure Helper +--- +Body`); + expect(parsed.frontmatter.name).toBe("Pure Helper"); + expect(parsed.body).toBe("Body"); + + const manifest = parserModule.parseAgentManifest(`--- +name: Pure Agent +skills: + - review +--- +Keep things tidy.`); + expect(manifest.name).toBe("Pure Agent"); + + const prepared = parserModule.prepareAgentCompaniesImport({ + company: { name: "Pure Company" }, + agents: [{ name: "Pure Agent", title: "Reviewer", instructionBody: "Keep things tidy." }], + teams: [], + projects: [], + tasks: [], + }); + expect(prepared.items).toHaveLength(1); + + const converted = parserModule.convertAgentCompanies({ + company: { name: "Pure Company" }, + agents: [{ name: "Pure Agent", title: "Reviewer", instructionBody: "Keep things tidy." }], + teams: [], + projects: [], + tasks: [], + skills: [], + }); + expect(converted.inputs).toHaveLength(1); + + expect(extractZipModuleFactory).not.toHaveBeenCalled(); + expect(tarModuleFactory).not.toHaveBeenCalled(); + + vi.doUnmock("extract-zip"); + vi.doUnmock("tar"); + vi.resetModules(); + }); + + it("dynamically imports extract-zip when parsing zip archives", async () => { + const extractZip = vi.fn(async (_archivePath: string, options: { dir: string }) => { + writeTextFile(join(options.dir, "zip-company", "COMPANY.md"), `---\nname: Mock Zip Company\n---`); + writeTextFile(join(options.dir, "zip-company", "agents", "ceo", "AGENTS.md"), `---\nname: Mock Zip CEO\n---`); + }); + const extractZipModuleFactory = vi.fn(() => ({ default: extractZip })); + + vi.resetModules(); + vi.doMock("extract-zip", extractZipModuleFactory); + + const parserModule = await import("../agent-companies-parser.js"); + const root = createTempDir(); + const archivePath = join(root, "company.zip"); + writeFileSync(archivePath, Buffer.from("placeholder zip contents")); + + const pkg = await parserModule.parseCompanyArchive(archivePath); + expect(pkg.company?.name).toBe("Mock Zip Company"); + expect(pkg.agents[0]?.name).toBe("Mock Zip CEO"); + expect(extractZipModuleFactory).toHaveBeenCalledTimes(1); + expect(extractZip).toHaveBeenCalledTimes(1); + expect(extractZip).toHaveBeenCalledWith( + archivePath, + expect.objectContaining({ dir: expect.any(String) }), + ); + + vi.doUnmock("extract-zip"); + vi.resetModules(); + }); + + it("dynamically imports tar when parsing tgz archives", async () => { + const tarExtract = vi.fn(async (options: { file: string; cwd: string }) => { + writeTextFile(join(options.cwd, "tar-company", "COMPANY.md"), `---\nname: Mock Tar Company\n---`); + writeTextFile(join(options.cwd, "tar-company", "agents", "ceo", "AGENTS.md"), `---\nname: Mock Tar CEO\n---`); + }); + const tarModuleFactory = vi.fn(() => ({ x: tarExtract })); + + vi.resetModules(); + vi.doMock("tar", tarModuleFactory); + + const parserModule = await import("../agent-companies-parser.js"); + const root = createTempDir(); + const archivePath = join(root, "company.tgz"); + writeFileSync(archivePath, Buffer.from("placeholder tgz contents")); + + const pkg = await parserModule.parseCompanyArchive(archivePath); + expect(pkg.company?.name).toBe("Mock Tar Company"); + expect(pkg.agents[0]?.name).toBe("Mock Tar CEO"); + expect(tarModuleFactory).toHaveBeenCalledTimes(1); + expect(tarExtract).toHaveBeenCalledTimes(1); + expect(tarExtract).toHaveBeenCalledWith( + expect.objectContaining({ file: archivePath, cwd: expect.any(String) }), + ); + + vi.doUnmock("tar"); + vi.resetModules(); + }); + }); + describe("conversion", () => { it("maps AgentManifest to AgentCreateInput", () => { const input = agentManifestToAgentCreateInput({ diff --git a/packages/core/src/agent-companies-parser.ts b/packages/core/src/agent-companies-parser.ts index f1a07e42ef..afb50e151e 100644 --- a/packages/core/src/agent-companies-parser.ts +++ b/packages/core/src/agent-companies-parser.ts @@ -8,8 +8,6 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } import { tmpdir } from "node:os"; import { isAbsolute, join, normalize, resolve } from "node:path"; -import extractZip from "extract-zip"; -import { x as tarExtract } from "tar"; import { parse as parseYaml } from "yaml"; import type { @@ -481,6 +479,7 @@ function resolveExtractionRoot(tempDir: string): string { } async function extractTarArchive(archivePath: string, outputDir: string): Promise { + const { x: tarExtract } = await import("tar"); await tarExtract({ file: archivePath, cwd: outputDir, @@ -530,6 +529,7 @@ export async function parseCompanyArchive( if (resolvedArchivePath.endsWith(".tar.gz") || resolvedArchivePath.endsWith(".tgz")) { await extractTarArchive(resolvedArchivePath, tempDir); } else if (resolvedArchivePath.endsWith(".zip")) { + const extractZip = (await import("extract-zip")).default; await extractZip(resolvedArchivePath, { dir: tempDir }); } else { throw new AgentCompaniesParseError( diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 285df9e51f..94d0878f21 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -7,6 +7,12 @@ directories: buildResources: src/icons files: + # pnpm installs workspace/runtime deps as symlinked package entries backed by the + # virtual store. These flat node_modules/X/**/* patterns only work when electron-builder + # follows the symlink chain from the published package entry, so every @fusion/core + # runtime dependency group that the desktop app can load must be listed here. + # If @fusion/core gains a new runtime dependency, add it (and any required leaf deps) + # here or packaged desktop builds can fail at startup/runtime with missing-module errors. - dist/**/* - package.json - node_modules/@fusion/core/**/* @@ -29,6 +35,44 @@ files: - node_modules/debug/**/* - node_modules/ms/**/* - node_modules/sax/**/* + # @fusion/core archive parsing deps + - node_modules/extract-zip/**/* + - node_modules/get-stream/**/* + - node_modules/pump/**/* + - node_modules/end-of-stream/**/* + - node_modules/once/**/* + - node_modules/wrappy/**/* + - node_modules/yauzl/**/* + - node_modules/fd-slicer/**/* + - node_modules/pend/**/* + - node_modules/buffer-crc32/**/* + - node_modules/tar/**/* + - node_modules/@isaacs/fs-minipass/**/* + - node_modules/chownr/**/* + - node_modules/minipass/**/* + - node_modules/minizlib/**/* + - node_modules/yallist/**/* + - node_modules/yaml/**/* + # @fusion/core scheduling/automation deps + - node_modules/cron-parser/**/* + - node_modules/luxon/**/* + # @fusion/core discovery/system deps + - node_modules/bonjour-service/**/* + - node_modules/fast-deep-equal/**/* + - node_modules/multicast-dns/**/* + - node_modules/dns-packet/**/* + - node_modules/thunky/**/* + - node_modules/@leichtgewicht/ip-codec/**/* + - node_modules/check-disk-space/**/* + # @fusion/core optional docker runtime deps + - node_modules/dockerode/**/* + - node_modules/@balena/dockerignore/**/* + - node_modules/@grpc/grpc-js/**/* + - node_modules/@grpc/proto-loader/**/* + - node_modules/docker-modem/**/* + - node_modules/protobufjs/**/* + - node_modules/tar-fs/**/* + - node_modules/uuid/**/* asarUnpack: - node_modules/better-sqlite3/**/*