## Summary - Extend the workspace content-hash skip cache to **all** packages (not just plugins), with `--force` / `--full` flags - Default local CLI packaging to a **fast mode** (bin/extension + migrations only); full desktop/plugin/DTS staging runs on CI or `pnpm build:full` - Enable TypeScript `incremental` builds for warm recompiles - Add `maxConcurrentVerifications` (default **1**) so concurrent tasks cannot stack monorepo typecheck/build and peg CPU Warm `pnpm build` measured ~**126s → ~0.8s** when nothing changed. ## Test plan - [x] `node --test scripts/__tests__/build-workspace.test.mjs` (12 pass) - [x] `pnpm --filter @fusion/engine exec vitest run src/__tests__/verification-concurrency.test.ts` - [x] `pnpm --filter @fusion/core exec vitest run src/__tests__/settings-parity.test.ts` - [x] Local: first `pnpm build` rebuilds as needed; second warm `pnpm build` skips all packages (~0.8s) - [x] Fast CLI packaging logs skip of desktop/plugin staging without `FUSION_CLI_FULL_PACKAGE` - [ ] CI: `pnpm build` still full-packages under `CI=true` (plugin staging / release surfaces) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a Scheduling setting to limit concurrent verification tasks from 1–8, with a default of 1. * Verification tasks now support cancellation while waiting or running. * Added options for forced and full workspace builds. * **Performance** * Local builds can skip unchanged packages and use incremental compilation for faster rebuilds. * Local CLI packaging is faster by default, while full packaging remains available when needed. * **Documentation** * Updated the settings reference with the new verification concurrency option. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
626 lines
26 KiB
JavaScript
626 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
FNXC:WorkspaceBuild 2026-06-30-00:00:
|
|
Root builds may skip unchanged plugin workspaces to keep local and CI feedback fast, but only after required dist outputs exist and a content hash proves plugin package inputs match the last successful plugin build. Non-plugin packages still build every run so the root command preserves the pre-existing recursive build contract outside plugins.
|
|
|
|
FNXC:WorkspaceBuild 2026-07-15-03:20:
|
|
Root `pnpm build` was pegging CPU for ~2 minutes even when nothing changed: non-plugin packages always rebuilt, CLI packaging always staged desktop + 15 plugins + DTS, and tsc had no incremental cache. Extend the content-hash skip cache to ALL workspace packages (not just plugins), support `--force` / `--full`, and default CLI packaging to a fast local mode (full package on CI or FUSION_CLI_FULL_PACKAGE=1).
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import fg from "fast-glob";
|
|
import YAML from "yaml";
|
|
import {
|
|
computeContentHash,
|
|
createRepoContentSnapshot,
|
|
defaultGitRunner,
|
|
readJsonCache,
|
|
} from "./lib/content-hash.mjs";
|
|
|
|
/*
|
|
FNXC:WorkspaceBuild 2026-07-15-03:20:
|
|
Bump cache version when the skip contract expands from plugins-only to all packages so stale plugin-only entries cannot incorrectly interact with the broader skip set. File name stays plugin-build-cache.json for path stability under .fusion/cache.
|
|
*/
|
|
export const BUILD_CACHE_VERSION = 2;
|
|
export const BUILD_CACHE_FILE = "plugin-build-cache.json";
|
|
export const ROOT_BUILD_EXCLUDED_PACKAGES = new Set(["@fusion/desktop", "@fusion/mobile"]);
|
|
export const PACKAGE_BUILD_GLOBAL_INPUT_PATHS = [
|
|
"package.json",
|
|
"pnpm-lock.yaml",
|
|
"pnpm-workspace.yaml",
|
|
"tsconfig.json",
|
|
"tsconfig.base.json",
|
|
"plugins/tsconfig.base.json",
|
|
"scripts/build-workspace.mjs",
|
|
"scripts/lib/content-hash.mjs",
|
|
];
|
|
/** @deprecated Use PACKAGE_BUILD_GLOBAL_INPUT_PATHS — kept for existing tests. */
|
|
export const PLUGIN_BUILD_GLOBAL_INPUT_PATHS = PACKAGE_BUILD_GLOBAL_INPUT_PATHS;
|
|
|
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(scriptDir, "..");
|
|
|
|
/**
|
|
* Resolve the plugin build cache under .fusion/cache as a repo-local build
|
|
* artifact. The cache is only an optimization: missing, unreadable, or stale
|
|
* entries force a plugin build rather than allowing a skip.
|
|
*
|
|
* @param {string} rootDir
|
|
* @returns {string}
|
|
*/
|
|
export function pluginBuildCachePath(rootDir) {
|
|
return path.join(rootDir, ".fusion", "cache", BUILD_CACHE_FILE);
|
|
}
|
|
|
|
/**
|
|
* Read the plugin build cache, normalizing invalid or older formats to an empty
|
|
* cache so a format bump rebuilds plugins once and then records fresh hashes.
|
|
*
|
|
* @param {string} rootDir
|
|
* @returns {{ version: number, entries: Record<string, { sourceHash?: string, builtAt?: string }> }}
|
|
*/
|
|
export function readPluginBuildCache(rootDir) {
|
|
const cache = readJsonCache(pluginBuildCachePath(rootDir), null);
|
|
if (!cache || cache.version !== BUILD_CACHE_VERSION || typeof cache.entries !== "object") {
|
|
return { version: BUILD_CACHE_VERSION, entries: {} };
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
/**
|
|
* Best-effort write for the plugin build cache. A successful package build must
|
|
* not become a failed root build just because the local optimization cache is
|
|
* not writable.
|
|
*
|
|
* @param {string} rootDir
|
|
* @param {{ version: number, entries: Record<string, { sourceHash?: string, builtAt?: string }> }} cache
|
|
*/
|
|
export function writePluginBuildCache(rootDir, cache) {
|
|
try {
|
|
const cachePath = pluginBuildCachePath(rootDir);
|
|
mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
} catch {
|
|
// Best-effort optimization cache; the next run will rebuild missing entries.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse pnpm-workspace.yaml and return workspace package globs.
|
|
*
|
|
* @param {string} rootDir
|
|
* @returns {string[]}
|
|
*/
|
|
export function readWorkspacePackagePatterns(rootDir) {
|
|
const workspacePath = path.join(rootDir, "pnpm-workspace.yaml");
|
|
const parsed = YAML.parse(readFileSync(workspacePath, "utf8"));
|
|
return Array.isArray(parsed?.packages) ? parsed.packages.filter((entry) => typeof entry === "string") : [];
|
|
}
|
|
|
|
/**
|
|
* Discover workspace package manifests from pnpm workspace patterns instead of
|
|
* hard-coding the current plugin list.
|
|
*
|
|
* @param {string} rootDir
|
|
* @param {string[]} [patterns]
|
|
* @returns {{ name: string, dir: string, manifest: object, hasBuild: boolean, isPlugin: boolean, requiredOutputs: string[], inputPaths: string[] }[]}
|
|
*/
|
|
export function discoverWorkspacePackages(rootDir, patterns = readWorkspacePackagePatterns(rootDir)) {
|
|
const manifestPatterns = patterns.map((pattern) => `${pattern.replace(/\/$/, "")}/package.json`);
|
|
const manifestPaths = fg.sync(manifestPatterns, {
|
|
cwd: rootDir,
|
|
onlyFiles: true,
|
|
unique: true,
|
|
dot: false,
|
|
ignore: ["**/node_modules/**"],
|
|
}).sort((a, b) => a.localeCompare(b));
|
|
|
|
const packages = [];
|
|
for (const manifestPath of manifestPaths) {
|
|
const manifest = JSON.parse(readFileSync(path.join(rootDir, manifestPath), "utf8"));
|
|
if (typeof manifest.name !== "string" || !manifest.name) continue;
|
|
const dir = path.dirname(manifestPath).replaceAll(path.sep, "/");
|
|
packages.push({
|
|
name: manifest.name,
|
|
dir,
|
|
manifest,
|
|
hasBuild: typeof manifest.scripts?.build === "string",
|
|
isPlugin: isPluginPackageDir(dir),
|
|
requiredOutputs: requiredPluginOutputs(rootDir, dir, manifest),
|
|
inputPaths: [dir],
|
|
});
|
|
}
|
|
|
|
const packagesByName = new Map(packages.map((pkg) => [pkg.name, pkg]));
|
|
for (const pkg of packages) {
|
|
// FNXC:WorkspaceBuild 2026-07-15-03:20: Hash inputs for every package (plugins and non-plugins) so core/engine/dashboard/cli can skip when unchanged.
|
|
pkg.inputPaths = collectPackageHashInputPaths(pkg, packagesByName);
|
|
}
|
|
|
|
return packages;
|
|
}
|
|
|
|
const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
|
|
function declaredDependencyNames(manifest) {
|
|
return DEPENDENCY_FIELDS.flatMap((field) => Object.keys(manifest?.[field] ?? {}));
|
|
}
|
|
|
|
/**
|
|
* Resolve a package's content-hash input directories. Include local workspace
|
|
* dependency directories and root build config/tooling files as invalidators so
|
|
* skipping cannot hide a compile break against changed shared package types,
|
|
* TypeScript settings, pnpm resolution, or build wrapper behavior.
|
|
*
|
|
* FNXC:WorkspaceBuild 2026-06-30-00:00:
|
|
* Plugin skip decisions must include declared local workspace dependencies and
|
|
* root build config/tooling in the content hash, not just the plugin package
|
|
* directory, because root pnpm builds previously recompiled plugins after shared
|
|
* package API/type changes and root TypeScript/build-tooling changes.
|
|
*
|
|
* FNXC:WorkspaceBuild 2026-07-15-03:20:
|
|
* Same contract now applies to non-plugin packages so unchanged core/engine/
|
|
* dashboard/cli skip the multi-minute full rebuild.
|
|
*
|
|
* @param {object} pkg
|
|
* @param {Map<string, object>} packagesByName
|
|
* @returns {string[]}
|
|
*/
|
|
export function collectPackageHashInputPaths(pkg, packagesByName) {
|
|
const inputPaths = new Set([...PACKAGE_BUILD_GLOBAL_INPUT_PATHS, pkg.dir]);
|
|
const seen = new Set();
|
|
const visit = (current) => {
|
|
if (seen.has(current.name)) return;
|
|
seen.add(current.name);
|
|
for (const dependencyName of declaredDependencyNames(current.manifest)) {
|
|
const dependency = packagesByName.get(dependencyName);
|
|
if (!dependency) continue;
|
|
inputPaths.add(dependency.dir);
|
|
visit(dependency);
|
|
}
|
|
};
|
|
visit(pkg);
|
|
return [...inputPaths].sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
/** @deprecated Use collectPackageHashInputPaths */
|
|
export function collectPluginHashInputPaths(pkg, packagesByName) {
|
|
return collectPackageHashInputPaths(pkg, packagesByName);
|
|
}
|
|
|
|
/**
|
|
* Plugin workspaces live under plugins/ (including plugins/examples/). This
|
|
* directory classification keeps future plugin packages covered by the skip
|
|
* cache without requiring a code edit for each new package name.
|
|
*
|
|
* @param {string} dir
|
|
* @returns {boolean}
|
|
*/
|
|
export function isPluginPackageDir(dir) {
|
|
return dir === "plugins" || dir.startsWith("plugins/");
|
|
}
|
|
|
|
function distPathFromExportValue(value) {
|
|
if (typeof value !== "string") return null;
|
|
if (value.startsWith("./dist/")) return value.slice(2);
|
|
if (!value.startsWith("./src/")) return null;
|
|
const withoutPrefix = value.slice("./src/".length);
|
|
if (/\.d\.[cm]?ts$/.test(withoutPrefix)) return null;
|
|
if (!/\.[cm]?[tj]sx?$/.test(withoutPrefix)) return null;
|
|
return path.posix.join("dist", withoutPrefix.replace(/\.[cm]?[tj]sx?$/, ".js"));
|
|
}
|
|
|
|
function collectDistExports(exportsField, outputPaths = new Set()) {
|
|
if (typeof exportsField === "string") {
|
|
const output = distPathFromExportValue(exportsField);
|
|
if (output) outputPaths.add(output);
|
|
return outputPaths;
|
|
}
|
|
if (!exportsField || typeof exportsField !== "object") return outputPaths;
|
|
for (const value of Object.values(exportsField)) {
|
|
if (typeof value === "string") {
|
|
const output = distPathFromExportValue(value);
|
|
if (output) outputPaths.add(output);
|
|
} else {
|
|
collectDistExports(value, outputPaths);
|
|
}
|
|
}
|
|
return outputPaths;
|
|
}
|
|
|
|
function collectDistEntrypoints(manifest, outputPaths = new Set()) {
|
|
for (const key of ["main", "module", "types"] ) {
|
|
const output = distPathFromExportValue(manifest[key]);
|
|
if (output) outputPaths.add(output);
|
|
}
|
|
if (typeof manifest.bin === "string") {
|
|
const output = distPathFromExportValue(manifest.bin);
|
|
if (output) outputPaths.add(output);
|
|
} else if (manifest.bin && typeof manifest.bin === "object") {
|
|
for (const value of Object.values(manifest.bin)) {
|
|
const output = distPathFromExportValue(value);
|
|
if (output) outputPaths.add(output);
|
|
}
|
|
}
|
|
return outputPaths;
|
|
}
|
|
|
|
/**
|
|
* Infer the required plugin build outputs. Exported dist paths are required as
|
|
* declared, and source entrypoints are mapped to their dist JS counterparts so
|
|
* packages that export source during development still cannot be skipped when
|
|
* their tsc output is absent.
|
|
*
|
|
* @param {string} rootDir
|
|
* @param {string} dir
|
|
* @param {object} manifest
|
|
* @returns {string[]}
|
|
*/
|
|
export function requiredPluginOutputs(rootDir, dir, manifest) {
|
|
const outputs = collectDistEntrypoints(manifest, collectDistExports(manifest.exports));
|
|
const buildScript = typeof manifest.scripts?.build === "string" ? manifest.scripts.build : "";
|
|
/*
|
|
FNXC:WorkspaceBuild 2026-07-15-03:50:
|
|
Bundlers (tsup/esbuild without tsc) emit entry bundles only — never per-source dist mirrors.
|
|
Mapping src/** → dist/** for @runfusion/fusion made every warm build report missing-output and
|
|
force a full CLI rebuild. Only tsc-style packages require per-file dist outputs.
|
|
*/
|
|
const isBundledPackage =
|
|
/\b(tsup|esbuild)\b/.test(buildScript) && !/\btsc\b/.test(buildScript);
|
|
|
|
if (!isBundledPackage) {
|
|
const sourceFiles = fg.sync(["src/**/*.{ts,tsx,mts,cts}"], {
|
|
cwd: path.join(rootDir, dir),
|
|
onlyFiles: true,
|
|
unique: true,
|
|
ignore: [
|
|
"**/*.d.ts",
|
|
"**/*.test.*",
|
|
"**/__tests__/**",
|
|
"**/__test-utils__/**",
|
|
"**/node_modules/**",
|
|
"**/dist/**",
|
|
],
|
|
});
|
|
for (const sourceFile of sourceFiles) {
|
|
outputs.add(sourceFile.replace(/^src\//, "dist/").replace(/\.[cm]?[tj]sx?$/, ".js"));
|
|
}
|
|
}
|
|
if (buildScript.includes("copy-css")) {
|
|
const cssFiles = fg.sync(["src/**/*.css"], {
|
|
cwd: path.join(rootDir, dir),
|
|
onlyFiles: true,
|
|
unique: true,
|
|
ignore: ["**/node_modules/**", "**/dist/**"],
|
|
});
|
|
for (const cssFile of cssFiles) {
|
|
outputs.add(cssFile.replace(/^src\//, "dist/"));
|
|
}
|
|
}
|
|
/*
|
|
FNXC:WorkspaceBuild 2026-07-15-03:20:
|
|
Dashboard client is produced by Vite into dist/client (from app/), not by mapping package src to dist.
|
|
Require the client index so a warm tsc-only dist cannot skip a missing UI build.
|
|
*/
|
|
if (/\bvite\b/.test(buildScript)) {
|
|
outputs.add("dist/client/index.html");
|
|
}
|
|
if (manifest.name === "@runfusion/fusion") {
|
|
outputs.add("dist/bin.js");
|
|
outputs.add("dist/extension.js");
|
|
}
|
|
if (outputs.size === 0) outputs.add("dist/index.js");
|
|
return [...outputs].sort((a, b) => a.localeCompare(b)).map((output) => path.posix.join(dir, output));
|
|
}
|
|
|
|
/** Alias — required outputs apply to every workspace package, not only plugins. */
|
|
export const requiredPackageOutputs = requiredPluginOutputs;
|
|
|
|
/**
|
|
* Compute a package input hash using the shared git-backed content hash.
|
|
* Returns null when git is unavailable; callers must build rather than skip in
|
|
* that case.
|
|
*
|
|
* @param {object} pkg
|
|
* @param {string} rootDir
|
|
* @param {object} [options]
|
|
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
|
* @param {ReturnType<typeof createRepoContentSnapshot>} [options.snapshot]
|
|
* @returns {string|null}
|
|
*/
|
|
export function computePackageSourceHash(pkg, rootDir, { gitFn = defaultGitRunner, snapshot } = {}) {
|
|
const probe = gitFn(["rev-parse", "--is-inside-work-tree"], rootDir);
|
|
if (probe !== "true") return null;
|
|
return computeContentHash({
|
|
rootDir,
|
|
inputPaths: pkg.inputPaths?.length ? pkg.inputPaths : [pkg.dir],
|
|
versionPrefix: `package-build-v${BUILD_CACHE_VERSION}`,
|
|
gitFn,
|
|
snapshot,
|
|
});
|
|
}
|
|
|
|
/** @deprecated Use computePackageSourceHash */
|
|
export function computePluginSourceHash(pkg, rootDir, options) {
|
|
return computePackageSourceHash(pkg, rootDir, options);
|
|
}
|
|
|
|
/**
|
|
* Explain whether a package must be built. A skip requires every required
|
|
* output to exist plus a matching successful-build source hash.
|
|
*
|
|
* @param {object} pkg
|
|
* @param {object} options
|
|
* @param {string} options.rootDir
|
|
* @param {{ entries?: Record<string, { sourceHash?: string }> }} options.cache
|
|
* @param {(p: string) => boolean} [options.existsFn]
|
|
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
|
* @param {ReturnType<typeof createRepoContentSnapshot>} [options.snapshot]
|
|
* @param {boolean} [options.force]
|
|
* @returns {{ shouldBuild: boolean, reason: string, sourceHash: string|null, missingOutputs: string[] }}
|
|
*/
|
|
export function evaluatePackageBuild(pkg, { rootDir, cache, existsFn = existsSync, gitFn = defaultGitRunner, snapshot, force = false } = {}) {
|
|
const missingOutputs = pkg.requiredOutputs.filter((output) => !existsFn(path.join(rootDir, output)));
|
|
const sourceHash = computePackageSourceHash(pkg, rootDir, { gitFn, snapshot });
|
|
if (force) return { shouldBuild: true, reason: "force", sourceHash, missingOutputs };
|
|
if (missingOutputs.length > 0) return { shouldBuild: true, reason: "missing-output", sourceHash, missingOutputs };
|
|
if (sourceHash === null) return { shouldBuild: true, reason: "no-git-hash", sourceHash, missingOutputs };
|
|
const entry = cache?.entries?.[pkg.name];
|
|
if (!entry?.sourceHash) return { shouldBuild: true, reason: "no-cache", sourceHash, missingOutputs };
|
|
if (entry.sourceHash !== sourceHash) return { shouldBuild: true, reason: "changed-inputs", sourceHash, missingOutputs };
|
|
return { shouldBuild: false, reason: "unchanged", sourceHash, missingOutputs };
|
|
}
|
|
|
|
/** @deprecated Use evaluatePackageBuild */
|
|
export function evaluatePluginBuild(pkg, options) {
|
|
return evaluatePackageBuild(pkg, options);
|
|
}
|
|
|
|
/**
|
|
* Plan the root build. Every buildable workspace package (plugins and non-plugins)
|
|
* is planned only when the content-hash cache says inputs changed or required
|
|
* outputs/cache entries are missing. Desktop/mobile stay excluded.
|
|
*
|
|
* @param {object} options
|
|
* @param {string} [options.rootDir]
|
|
* @param {object[]} [options.packages]
|
|
* @param {ReturnType<typeof readPluginBuildCache>} [options.cache]
|
|
* @param {(p: string) => boolean} [options.existsFn]
|
|
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
|
* @param {ReturnType<typeof createRepoContentSnapshot>} [options.snapshot]
|
|
* @param {boolean} [options.force]
|
|
* @returns {{ plannedPackages: object[], skippedPackages: object[], skippedPlugins: object[], excludedPackages: object[], packageEvaluations: Map<string, object>, pluginEvaluations: Map<string, object> }}
|
|
*/
|
|
export function planWorkspaceBuild({ rootDir = repoRoot, packages = discoverWorkspacePackages(rootDir), cache = readPluginBuildCache(rootDir), existsFn = existsSync, gitFn = defaultGitRunner, snapshot, force = false } = {}) {
|
|
const plannedPackages = [];
|
|
const skippedPackages = [];
|
|
const excludedPackages = [];
|
|
const packageEvaluations = new Map();
|
|
|
|
for (const pkg of packages) {
|
|
if (!pkg.hasBuild) continue;
|
|
if (ROOT_BUILD_EXCLUDED_PACKAGES.has(pkg.name)) {
|
|
excludedPackages.push(pkg);
|
|
continue;
|
|
}
|
|
const evaluation = evaluatePackageBuild(pkg, { rootDir, cache, existsFn, gitFn, snapshot, force });
|
|
packageEvaluations.set(pkg.name, evaluation);
|
|
if (evaluation.shouldBuild) {
|
|
plannedPackages.push({ ...pkg, buildReason: evaluation.reason, sourceHash: evaluation.sourceHash });
|
|
} else {
|
|
skippedPackages.push({ ...pkg, buildReason: evaluation.reason, sourceHash: evaluation.sourceHash });
|
|
}
|
|
}
|
|
|
|
// Back-compat: callers/tests that only inspect skippedPlugins keep working.
|
|
const skippedPlugins = skippedPackages.filter((pkg) => pkg.isPlugin);
|
|
return {
|
|
plannedPackages,
|
|
skippedPackages,
|
|
skippedPlugins,
|
|
excludedPackages,
|
|
packageEvaluations,
|
|
pluginEvaluations: packageEvaluations,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build all planned packages through pnpm filters so each package's existing
|
|
* build script and workspace dependency behavior remain intact.
|
|
*
|
|
* @param {object[]} plannedPackages
|
|
* @param {string} rootDir
|
|
* @param {(command: string, args: string[], options: object) => { status: number|null }} [spawnFn]
|
|
* @returns {{ status: number, packageNames: string[] }}
|
|
*/
|
|
export function runPlannedBuilds(plannedPackages, rootDir, spawnFn = spawnSync, { fullPackage = false, env = process.env } = {}) {
|
|
if (plannedPackages.length === 0) return { status: 0, packageNames: [] };
|
|
const packageNames = plannedPackages.map((pkg) => pkg.name);
|
|
const args = [...packageNames.flatMap((name) => ["--filter", name]), "build"];
|
|
/*
|
|
* FNXC:WorkspaceBuild 2026-07-02-15:10:
|
|
* On Windows `pnpm` resolves to a `.cmd` shim; Node refuses to spawn .cmd/.bat without a
|
|
* shell (ENOENT / EINVAL since CVE-2024-27980). Without shell:true the root build failed
|
|
* with `spawn pnpm ENOENT` on Windows. The args are workspace filters + package names
|
|
* (no spaces or shell metacharacters), so shell quoting is safe.
|
|
*
|
|
* FNXC:WorkspaceBuild 2026-07-15-03:20:
|
|
* Propagate FUSION_CLI_FULL_PACKAGE so @runfusion/fusion tsup stages desktop + bundled
|
|
* plugins + DTS only when root build was invoked with --full (or CI already set the env).
|
|
* Day-to-day local builds skip that multi-minute packaging tail.
|
|
*/
|
|
const childEnv = {
|
|
...env,
|
|
...(fullPackage ? { FUSION_CLI_FULL_PACKAGE: "1" } : {}),
|
|
};
|
|
const result = spawnFn("pnpm", args, {
|
|
cwd: rootDir,
|
|
stdio: "inherit",
|
|
shell: process.platform === "win32",
|
|
env: childEnv,
|
|
});
|
|
return { status: result.status ?? 1, packageNames };
|
|
}
|
|
|
|
/**
|
|
* Record hashes for packages that built successfully (plugins and non-plugins).
|
|
*
|
|
* @param {object[]} builtPackages
|
|
* @param {object} options
|
|
* @param {string} options.rootDir
|
|
* @param {ReturnType<typeof readPluginBuildCache>} options.cache
|
|
* @param {(args: string[], cwd: string) => string|null} [options.gitFn]
|
|
*/
|
|
export function recordSuccessfulPackageBuilds(builtPackages, { rootDir, cache, gitFn = defaultGitRunner } = {}) {
|
|
const nextCache = { version: BUILD_CACHE_VERSION, entries: { ...(cache?.entries ?? {}) } };
|
|
let changed = false;
|
|
const snapshot = createRepoContentSnapshot({ rootDir, gitFn });
|
|
for (const pkg of builtPackages) {
|
|
const sourceHash = computePackageSourceHash(pkg, rootDir, { gitFn, snapshot });
|
|
if (sourceHash === null) continue;
|
|
nextCache.entries[pkg.name] = { sourceHash, builtAt: new Date().toISOString() };
|
|
changed = true;
|
|
}
|
|
if (changed) writePluginBuildCache(rootDir, nextCache);
|
|
}
|
|
|
|
/** @deprecated Use recordSuccessfulPackageBuilds */
|
|
export function recordSuccessfulPluginBuilds(builtPackages, options) {
|
|
return recordSuccessfulPackageBuilds(builtPackages, options);
|
|
}
|
|
|
|
function formatPlanLine(pkg) {
|
|
return `${pkg.name} (${pkg.buildReason})`;
|
|
}
|
|
|
|
/*
|
|
* FNXC:WorkspaceBuild 2026-07-10-15:40:
|
|
* FN-7779 stale-plugin-dist: `--plugins-only` narrows the plan to plugin
|
|
* packages so the fast `pnpm dev dashboard` prebuild can incrementally rebuild
|
|
* ONLY changed plugins (reusing the content-hash skip cache) without also
|
|
* rebuilding every non-plugin workspace package. Plugins load their built
|
|
* dist/ at runtime, so a never-rebuilt plugin dist silently runs phantom-old
|
|
* code — exactly the Grok "messages aren't sending" wrong-CLI-flags failure.
|
|
*
|
|
* FNXC:WorkspaceBuild 2026-07-15-03:20:
|
|
* `--force` rebuilds every package ignoring the skip cache. `--full` sets
|
|
* FUSION_CLI_FULL_PACKAGE for the CLI packaging path (desktop + plugins + DTS).
|
|
*/
|
|
/**
|
|
* FNXC:WorkspaceBuild 2026-07-15-03:25 / 2026-07-15-09:05:
|
|
* Mirror packages/cli wantsFullCliPackage so build-workspace and tsup agree on when
|
|
* full CLI packaging runs. CLI enables full via FUSION_CLI_FULL_PACKAGE, CI=true, or prepack;
|
|
* root also enables via --full. Explicit FUSION_CLI_FULL_PACKAGE=0/false opts out.
|
|
*
|
|
* @param {NodeJS.ProcessEnv} [env]
|
|
* @param {{ fullFlag?: boolean }} [options]
|
|
* @returns {boolean}
|
|
*/
|
|
export function wantsFullCliPackage(env = process.env, { fullFlag = false } = {}) {
|
|
const explicit = env.FUSION_CLI_FULL_PACKAGE;
|
|
if (explicit === "0" || explicit === "false") return false;
|
|
if (explicit === "1" || explicit === "true") return true;
|
|
if (fullFlag) return true;
|
|
if (env.CI === "true" || env.CI === "1") return true;
|
|
if (env.npm_lifecycle_event === "prepack") return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* FNXC:WorkspaceBuild 2026-07-15-08:15:
|
|
* Greptile P1: a warm fast build caches CLI after emitting only bin.js/extension.js.
|
|
* Full packaging modes must still run tsup so desktop/plugins/DTS stage. Force-include
|
|
* @runfusion/fusion whenever full packaging is active, even if content-hash says skip.
|
|
*
|
|
* FNXC:WorkspaceBuild 2026-07-15-09:05:
|
|
* fullPackage must include env-driven modes (CI / FUSION_CLI_FULL_PACKAGE), not only --full.
|
|
*/
|
|
export function ensureFullPackageCliPlanned(plannedPackages, skippedPackages, { fullPackage = false } = {}) {
|
|
if (!fullPackage) {
|
|
return { plannedPackages, skippedPackages };
|
|
}
|
|
const cliName = "@runfusion/fusion";
|
|
if (plannedPackages.some((pkg) => pkg.name === cliName)) {
|
|
return { plannedPackages, skippedPackages };
|
|
}
|
|
const skippedCli = (skippedPackages ?? []).find((pkg) => pkg.name === cliName);
|
|
if (!skippedCli) {
|
|
return { plannedPackages, skippedPackages };
|
|
}
|
|
return {
|
|
plannedPackages: [
|
|
...plannedPackages,
|
|
{ ...skippedCli, buildReason: "full-package", sourceHash: skippedCli.sourceHash },
|
|
],
|
|
skippedPackages: (skippedPackages ?? []).filter((pkg) => pkg.name !== cliName),
|
|
};
|
|
}
|
|
|
|
export function main({
|
|
rootDir = repoRoot,
|
|
spawnFn = spawnSync,
|
|
gitFn = defaultGitRunner,
|
|
pluginsOnly = false,
|
|
force = false,
|
|
fullPackage = false,
|
|
env = process.env,
|
|
} = {}) {
|
|
/*
|
|
FNXC:WorkspaceBuild 2026-07-15-09:05:
|
|
Align with CLI tsup wantsFullCliPackage: --full OR CI OR FUSION_CLI_FULL_PACKAGE (unless explicitly 0).
|
|
*/
|
|
const effectiveFullPackage = wantsFullCliPackage(env, { fullFlag: fullPackage });
|
|
const cache = force ? { version: BUILD_CACHE_VERSION, entries: {} } : readPluginBuildCache(rootDir);
|
|
const snapshot = createRepoContentSnapshot({ rootDir, gitFn });
|
|
const plan = planWorkspaceBuild({ rootDir, cache, gitFn, snapshot, force });
|
|
let plannedPackages = pluginsOnly ? plan.plannedPackages.filter((pkg) => pkg.isPlugin) : plan.plannedPackages;
|
|
let skippedPackages = plan.skippedPackages ?? plan.skippedPlugins;
|
|
if (!pluginsOnly) {
|
|
({ plannedPackages, skippedPackages } = ensureFullPackageCliPlanned(plannedPackages, skippedPackages, {
|
|
fullPackage: effectiveFullPackage,
|
|
}));
|
|
}
|
|
const plannedNames = plannedPackages.map(formatPlanLine);
|
|
const skippedNames = skippedPackages.map((pkg) => pkg.name);
|
|
|
|
const scope = pluginsOnly ? "changed plugins" : "planned builds";
|
|
console.log(`[build-workspace] ${scope}: ${plannedNames.join(", ") || "(none)"}`);
|
|
if (skippedNames.length > 0) {
|
|
console.log(`[build-workspace] skipped unchanged packages: ${skippedNames.join(", ")}`);
|
|
}
|
|
if (effectiveFullPackage) {
|
|
console.log("[build-workspace] full CLI packaging enabled (CI / FUSION_CLI_FULL_PACKAGE / --full)");
|
|
}
|
|
|
|
const result = runPlannedBuilds(plannedPackages, rootDir, spawnFn, { fullPackage: effectiveFullPackage, env });
|
|
if (result.status !== 0) {
|
|
process.stderr.write(`[build-workspace] FAILED packages: ${result.packageNames.join(", ") || "(none)"}\n`);
|
|
return result.status;
|
|
}
|
|
|
|
// When force used empty cache for planning, still merge into on-disk cache.
|
|
const persistCache = force ? readPluginBuildCache(rootDir) : cache;
|
|
recordSuccessfulPackageBuilds(plannedPackages, { rootDir, cache: persistCache, gitFn });
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* FNXC:WorkspaceBuild 2026-07-02-15:10:
|
|
* Cross-platform "run as main" guard. The old `import.meta.url === \`file://${process.argv[1]}\``
|
|
* check NEVER matched on Windows: import.meta.url is `file:///C:/…/build-workspace.mjs`
|
|
* (triple slash, forward slashes) while process.argv[1] is `C:\…\build-workspace.mjs`
|
|
* (backslashes, no scheme). So `pnpm build` at the repo root silently no-opped on Windows
|
|
* (exit 0, no output, no dist) — packaging then shipped empty/stale dist. Compare against the
|
|
* file URL of argv[1] so the guard is correct on Windows, macOS, and Linux.
|
|
*/
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
const args = process.argv.slice(2);
|
|
const pluginsOnly = args.includes("--plugins-only");
|
|
const force = args.includes("--force");
|
|
const fullPackage = args.includes("--full");
|
|
process.exit(main({ pluginsOnly, force, fullPackage }));
|
|
}
|