fix(FN-2897): harden npm bundle packaging and merge recovery flows
- Strip private @fusion/* workspace devDependencies from the published CLI manifest via prepare-publish-manifest and package metadata updates - Replace cross-spawn usage and add staged bundle layout assertions to verify resolver output in dist packaging - Add per-task/project model override resolution across core, dashboard settings/task modals, and route coverage with new regression tests - Strengthen engine merge/recovery handling for paused/interrupted/squash paths and surface merger timeline activity with additional self-healing and merger tests - Add changesets for npm bundle dependency fixes, project model override stabilization, and FTS5 corruption recovery Fusion-Task-Id: FN-2897
This commit is contained in:
5
.changeset/fix-npm-bundle-deps.md
Normal file
5
.changeset/fix-npm-bundle-deps.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix npm bundle reliability for the published CLI package by removing the vendored pi-claude-cli `cross-spawn` runtime dependency, validating bundled pi-claude-cli resolution from `dist/`, and preventing private `@fusion/*` workspace dev dependencies from leaking into the packed manifest.
|
||||
@@ -40,6 +40,8 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/bin.ts",
|
||||
"prebuild": "node ../../scripts/sync-fusion-skill-tools.mjs",
|
||||
"prepack": "node ./scripts/prepare-publish-manifest.mjs prepack",
|
||||
"postpack": "node ./scripts/prepare-publish-manifest.mjs postpack",
|
||||
"build": "tsup",
|
||||
"build:exe": "bun run build.ts",
|
||||
"build:exe:all": "bun run build.ts --all",
|
||||
|
||||
42
packages/cli/scripts/prepare-publish-manifest.mjs
Normal file
42
packages/cli/scripts/prepare-publish-manifest.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
/* global process, URL, console */
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
|
||||
const mode = process.argv[2];
|
||||
const packageJsonPath = new URL("../package.json", import.meta.url);
|
||||
const backupPath = new URL("../package.json.pack-backup", import.meta.url);
|
||||
|
||||
if (mode === "prepack") {
|
||||
if (existsSync(backupPath)) {
|
||||
// Clean up stale backup from interrupted runs.
|
||||
unlinkSync(backupPath);
|
||||
}
|
||||
|
||||
const original = readFileSync(packageJsonPath, "utf8");
|
||||
writeFileSync(backupPath, original, "utf8");
|
||||
|
||||
const pkg = JSON.parse(original);
|
||||
const devDependencies = { ...(pkg.devDependencies || {}) };
|
||||
delete devDependencies["@fusion/core"];
|
||||
delete devDependencies["@fusion/dashboard"];
|
||||
delete devDependencies["@fusion/engine"];
|
||||
delete devDependencies["@fusion/pi-claude-cli"];
|
||||
|
||||
pkg.devDependencies = devDependencies;
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === "postpack") {
|
||||
if (!existsSync(backupPath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const backup = readFileSync(backupPath, "utf8");
|
||||
writeFileSync(packageJsonPath, backup, "utf8");
|
||||
unlinkSync(backupPath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Usage: node ./scripts/prepare-publish-manifest.mjs <prepack|postpack>");
|
||||
process.exit(1);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
buildCliWithRealDashboardAssets,
|
||||
bundlePath,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
dashboardClientStubMarker,
|
||||
readClientIndexHtml,
|
||||
} from "./bundle-output-helpers";
|
||||
import { resolveClaudeCliExtensionFromModuleUrl } from "../commands/claude-cli-extension";
|
||||
|
||||
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
|
||||
|
||||
@@ -96,6 +98,41 @@ describe("CLI bundle output", () => {
|
||||
expect(content).toMatch(/from\s+["']node:path["']/);
|
||||
});
|
||||
|
||||
it("resolveClaudeCliExtension succeeds against the staged dist/ layout", () => {
|
||||
const result = resolveClaudeCliExtensionFromModuleUrl(pathToFileURL(bundlePath).href);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.path).toBe(join(cliRoot, "dist", "pi-claude-cli", "index.ts"));
|
||||
expect(result.packageVersion).toMatch(/\d+\.\d+\.\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/pi-claude-cli/ is staged with correct files", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "pi-claude-cli");
|
||||
|
||||
expect(existsSync(join(stagedRoot, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("pi-claude-cli source does not import cross-spawn", () => {
|
||||
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
|
||||
|
||||
expect(processManagerSource).not.toMatch(/import\s+.*cross-spawn/);
|
||||
expect(processManagerSource).toMatch(/import\s*\{[^}]*spawn[^}]*\}\s*from\s*["']node:child_process["']/);
|
||||
});
|
||||
|
||||
it("pi-claude-cli package.json has no cross-spawn dependency", () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(cliRoot, "dist", "pi-claude-cli", "package.json"), "utf-8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(packageJson.dependencies?.["cross-spawn"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runtime native assets are staged after build:exe", () => {
|
||||
const runtimeDir = join(cliRoot, "dist", "runtime");
|
||||
if (!existsSync(runtimeDir)) return;
|
||||
|
||||
@@ -49,13 +49,15 @@ export type ClaudeCliExtensionResolution =
|
||||
* module's location, and fall back to `require.resolve` for monorepo
|
||||
* dev/test runs where this file executes from `src/` rather than `dist/`.
|
||||
*/
|
||||
export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
|
||||
export function resolveClaudeCliExtensionFromModuleUrl(
|
||||
moduleUrl: string,
|
||||
): ClaudeCliExtensionResolution {
|
||||
let pkgJsonPath: string | undefined;
|
||||
|
||||
// Bundled lookup: when running from dist/, sibling dir dist/pi-claude-cli/
|
||||
// holds the staged extension. Walk up a few levels to also catch nested
|
||||
// layouts (e.g. dist/commands/foo.js) without hard-coding depth.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const here = dirname(fileURLToPath(moduleUrl));
|
||||
for (const rel of ["pi-claude-cli", "../pi-claude-cli", "../../pi-claude-cli"]) {
|
||||
const candidate = resolve(here, rel, "package.json");
|
||||
if (existsSync(candidate)) {
|
||||
@@ -113,6 +115,10 @@ export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
|
||||
return resolveClaudeCliExtensionFromModuleUrl(import.meta.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the paths to append to `discoverAndLoadExtensions`' configuredPaths
|
||||
* based on the user's `useClaudeCli` setting.
|
||||
|
||||
@@ -19,15 +19,11 @@
|
||||
"url": "https://github.com/Runfusion/Fusion",
|
||||
"directory": "packages/pi-claude-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-ai": "*",
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
// Mock cross-spawn before importing process-manager
|
||||
vi.mock("cross-spawn", () => ({
|
||||
default: vi.fn(() => {
|
||||
// Mock child_process.spawn before importing process-manager
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(() => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
||||
@@ -16,10 +16,6 @@ vi.mock("cross-spawn", () => ({
|
||||
proc.pid = 12345;
|
||||
return proc;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child_process.execSync for validation tests
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -42,8 +38,7 @@ vi.mock("node:os", () => ({
|
||||
tmpdir: mocks.tmpdir,
|
||||
}));
|
||||
|
||||
import spawn from "cross-spawn";
|
||||
import { execSync } from "node:child_process";
|
||||
import { spawn, execSync } from "node:child_process";
|
||||
import {
|
||||
spawnClaude,
|
||||
writeUserMessage,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
// Mock cross-spawn with PassThrough streams for readline compatibility
|
||||
vi.mock("cross-spawn", () => ({
|
||||
default: vi.fn(() => {
|
||||
// Mock child_process.spawn with PassThrough streams for readline compatibility
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(() => {
|
||||
const proc = new EventEmitter();
|
||||
const stdin = { write: vi.fn(), end: vi.fn() };
|
||||
const stdout = new PassThrough();
|
||||
@@ -20,10 +20,6 @@ vi.mock("cross-spawn", () => ({
|
||||
(proc as any).pid = 99999;
|
||||
return proc;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child_process.execSync for validateCliPresence/validateCliAuth
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => Buffer.from("1.0.0")),
|
||||
}));
|
||||
|
||||
@@ -69,7 +65,7 @@ vi.mock("@mariozechner/pi-ai", () => ({
|
||||
calculateCost: vi.fn(),
|
||||
}));
|
||||
|
||||
import spawn from "cross-spawn";
|
||||
import { spawn } from "node:child_process";
|
||||
import { streamViaCli } from "../provider";
|
||||
|
||||
describe("provider registration (default export)", () => {
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
* Also provides startup validation for CLI presence and authentication.
|
||||
*/
|
||||
|
||||
import spawn from "cross-spawn";
|
||||
import { execSync } from "node:child_process";
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Spawn a Claude CLI subprocess with all required flags for stream-json communication.
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -431,13 +431,7 @@ importers:
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: '*'
|
||||
version: 0.62.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
cross-spawn:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
devDependencies:
|
||||
'@types/cross-spawn':
|
||||
specifier: ^6.0.6
|
||||
version: 6.0.6
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
@@ -2574,9 +2568,6 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cross-spawn@6.0.6':
|
||||
resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -9259,10 +9250,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
|
||||
'@types/cross-spawn@6.0.6':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
Reference in New Issue
Block a user