FN-5988: fix desktop startup by lazy-loading archive parser deps

Prevent desktop startup failures by deferring archive parser dependencies until archive parsing runs.

- lazy-load `extract-zip` and `tar` inside archive parsing paths instead of at module import time
- add regression tests covering helper-only imports and dynamic loading for zip/tgz archive parsing
- include the desktop package's required @fusion/core runtime dependency groups in electron-builder packaging

Files changed:
 .../src/__tests__/agent-companies-parser.test.ts   | 115 +++++++++++++++++++++
 packages/core/src/agent-companies-parser.ts        |   4 +-
 packages/desktop/electron-builder.yml              |  44 ++++++++
 3 files changed, 161 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-5988
Fusion-Task-Lineage: b31bf43b-791c-4cd1-a62f-7095badcb706
This commit is contained in:
gsxdsm
2026-06-07 15:16:04 -07:00
parent fab8a62b55
commit ec8e4d3b0d
3 changed files with 161 additions and 2 deletions

View File

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

View File

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

View File

@@ -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/**/*