Files
fusion/packages/core/src/fn-binary.ts
gsxdsm 5a41ce42e9 fix(FN-XXXX): narrow backup matcher and isolate version probes
Two follow-up corrections to the in-process auto-backup interception:

- The matcher previously hijacked any `fn backup …` / `fusion backup …` /
  `runfusion.ai backup …` form. The in-process replacement only knows how
  to do `--create` + cleanup, so scheduling `--list`, `--cleanup`, or
  `--restore <file>` would have silently executed a create instead of the
  requested operation. The matcher is now anchored to `backup --create`
  (with optional trailing flags), with positive/negative unit tests.
- Step-based automations (`AutomationStep` with `type: "command"`) also
  shell out — the legacy-command interception alone left that path
  vulnerable. `executeCommandStep` now applies the same in-process backup
  detour, factored through a shared `runBackupActionInProcess` helper.

Independently, `runProbe` in fn-binary now spawns with `cwd: tmpdir()`.
The dashboard's `/system/fn-binary/status` route runs `<bin> --version`
on whatever fusion binary happens to be on PATH — older releases (e.g.
v0.13.0) initialise an engine and create a fresh `.fusion/<project>/
.fusion/` tree as a side effect. Pinning the probe's cwd to the OS temp
directory keeps any such artefacts off the developer's project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:49:03 -07:00

149 lines
5.3 KiB
TypeScript

/**
* Resolve how to invoke the Fusion CLI from server-side code (automations,
* generated commands, docs snippets).
*
* Order of preference:
* 1. `fn` — short canonical name
* 2. `fusion` — long alias name
* 3. `npx -y runfusion.ai` — zero-install fallback that always works
*
* The npm bin name on disk varies by install path and platform; the version
* is read by spawning `<bin> --version` so we report the actually-runnable
* binary, not just the first match on PATH.
*/
import { spawn } from "node:child_process";
import { platform, tmpdir } from "node:os";
interface ProbeResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
/**
* Run a command with an explicit argv (no shell) and capture stdout/stderr.
* Always resolves; on spawn failure exitCode is null and stderr carries the
* error message. Used here for safe, dependency-free PATH lookups and
* version probes — do not use for general command execution.
*/
function runProbe(command: string, args: string[], timeoutMs: number): Promise<ProbeResult> {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
// Run probes from the OS temp directory so a buggy CLI version (older
// `runfusion.ai` releases initialise an engine — and a fresh
// `.fusion/<project>/.fusion/` tree — even on `--version`) cannot leave
// artefacts under whichever project happens to be the parent's cwd.
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
shell: false,
cwd: tmpdir(),
});
const timer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch { /* ignore */ }
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
child.on("error", (err) => {
clearTimeout(timer);
resolve({ exitCode: null, stdout, stderr: stderr || err.message });
});
child.on("close", (exitCode) => {
clearTimeout(timer);
resolve({ exitCode, stdout, stderr });
});
});
}
/** npm package that publishes the `fn`/`fusion` bins. Used for npx fallback. */
export const FN_NPM_PACKAGE = "runfusion.ai";
/** Recommended one-line installer URL surfaced in UI/docs. */
export const FN_INSTALL_CURL = "curl -fsSL https://runfusion.ai/install.sh | sh";
/** Recommended npm install command surfaced in UI/docs. */
export const FN_INSTALL_NPM = `npm install -g ${FN_NPM_PACKAGE}`;
/** Zero-install invocation prefix used when no global binary is present. */
export const FN_NPX_INVOCATION = `npx -y ${FN_NPM_PACKAGE}`;
/** Candidate binary names checked, in preference order. */
const CANDIDATES = ["fn", "fusion"] as const;
export type FnBinaryName = (typeof CANDIDATES)[number];
export interface FnBinaryStatus {
/** True if a working `fn` or `fusion` binary was found on PATH. */
installed: boolean;
/** Which binary name resolved, if any. */
binary?: FnBinaryName;
/** Absolute path to the resolved binary, when available. */
path?: string;
/** Version reported by `<bin> --version`, when available. */
version?: string;
/**
* Command prefix to use when scripting against the CLI. This is either
* the binary name itself (when installed) or {@link FN_NPX_INVOCATION}.
*/
invocation: string;
}
/**
* Look up an executable on PATH using the platform-appropriate command.
* Returns the first absolute path or undefined.
*/
async function whichBinary(name: string): Promise<string | undefined> {
const isWindows = platform() === "win32";
const lookup = isWindows ? "where" : "which";
const result = await runProbe(lookup, [name], 5_000);
if (result.exitCode !== 0) return undefined;
const firstLine = result.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
return firstLine || undefined;
}
/**
* Best-effort version probe. Returns undefined if the binary refuses the
* flag or produces no parseable output — the caller should treat undefined
* as "installed but version unknown" rather than "not installed".
*/
async function probeVersion(binary: string): Promise<string | undefined> {
const result = await runProbe(binary, ["--version"], 10_000);
if (result.exitCode !== 0) return undefined;
const text = (result.stdout || result.stderr).trim();
if (!text) return undefined;
// Match the first semver-ish token so we strip prefixes like "fn v0.13.0".
const match = text.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/);
return match ? match[0] : text.split(/\s+/)[0];
}
/**
* Detect whether the `fn` (or `fusion`) CLI is installed on PATH and
* return the recommended invocation prefix.
*
* Never throws — on any error it falls through to the npx fallback so
* callers can rely on `invocation` always being usable.
*/
export async function detectFnBinary(): Promise<FnBinaryStatus> {
for (const candidate of CANDIDATES) {
try {
const resolvedPath = await whichBinary(candidate);
if (!resolvedPath) continue;
const version = await probeVersion(candidate);
return {
installed: true,
binary: candidate,
path: resolvedPath,
version,
invocation: candidate,
};
} catch {
// Try the next candidate.
}
}
return {
installed: false,
invocation: FN_NPX_INVOCATION,
};
}