FN-5807: add onboarding auto-launch hook before interactive commands

Ensure CLI onboarding auto-launch runs before interactive command execution when central DB is missing.

- Hooked auto-launch flow into bin.ts before interactive command handling.
- Added a dedicated onboard-autolaunch command module to detect missing central DB and trigger onboarding.
- Added focused CLI tests covering auto-launch behavior and guard conditions.
- Added a changeset for @runfusion/fusion patch release.

Files changed:
 .changeset/fn-5807-onboarding-autolaunch.md        |   5 +
 packages/cli/src/bin.ts                            |   3 +
 .../commands/__tests__/onboard-autolaunch.test.ts  | 164 +++++++++++++++++++++
 packages/cli/src/commands/onboard-autolaunch.ts    | 116 +++++++++++++++
 4 files changed, 288 insertions(+)

Fusion-Task-Id: FN-5807
Fusion-Task-Lineage: a12551b3-d8a4-492a-81f8-e92b4e138204
This commit is contained in:
gsxdsm
2026-06-01 13:03:09 -07:00
parent 3602fb9a23
commit 641b932631
4 changed files with 288 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a safe onboarding auto-launch hook in the CLI bootstrap path. When the central DB is missing, interactive TTY commands now trigger `fn onboard` automatically before command dispatch, while non-interactive contexts (non-TTY, `serve`, `daemon`, explicit skip signals) remain unchanged and never block execution.

View File

@@ -557,6 +557,9 @@ async function main() {
const command = args[0];
const { maybeAutoLaunchOnboarding } = await import("./commands/onboard-autolaunch.js");
await maybeAutoLaunchOnboarding({ command, args });
const {
runDashboard,
runServe,

View File

@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
maybeAutoLaunchOnboarding,
shouldAutoLaunchOnboarding,
} from "../onboard-autolaunch.js";
describe("shouldAutoLaunchOnboarding", () => {
it("returns launch true for interactive command when central DB missing", () => {
expect(
shouldAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbExists: false,
isTTY: true,
}),
).toEqual({ launch: true, reason: "central-db-missing" });
});
it("returns launch true for default dashboard path", () => {
expect(
shouldAutoLaunchOnboarding({
command: "dashboard",
args: ["dashboard"],
centralDbExists: false,
isTTY: true,
}),
).toEqual({ launch: true, reason: "central-db-missing" });
});
it("skips when skip flag is present in args", () => {
const args = ["task", "list", "--skip-onboarding"];
expect(args).toContain("--skip-onboarding");
expect(
shouldAutoLaunchOnboarding({
command: "task",
args,
centralDbExists: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "skip-flag" });
});
it("skips when central DB exists", () => {
expect(
shouldAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbExists: true,
isTTY: true,
}),
).toEqual({ launch: false, reason: "central-db-exists" });
});
it("skips on non-TTY", () => {
expect(
shouldAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbExists: false,
isTTY: false,
}),
).toEqual({ launch: false, reason: "non-tty" });
});
it("skips for serve and daemon", () => {
expect(
shouldAutoLaunchOnboarding({
command: "serve",
args: ["serve"],
centralDbExists: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "command-skip" });
expect(
shouldAutoLaunchOnboarding({
command: "daemon",
args: ["daemon"],
centralDbExists: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "command-skip" });
});
it("skips for onboard command", () => {
expect(
shouldAutoLaunchOnboarding({
command: "onboard",
args: ["onboard"],
centralDbExists: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "onboard-command" });
});
it("skips when FUSION_SKIP_ONBOARDING is truthy", () => {
expect(
shouldAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbExists: false,
isTTY: true,
env: { FUSION_SKIP_ONBOARDING: "1" },
}),
).toEqual({ launch: false, reason: "skip-env" });
});
});
describe("maybeAutoLaunchOnboarding", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("invokes runOnboard when gate passes", async () => {
const runOnboard = vi.fn();
await maybeAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => false,
runOnboard,
});
expect(runOnboard).toHaveBeenCalledTimes(1);
});
it("does not invoke runOnboard when gate fails", async () => {
const runOnboard = vi.fn();
await maybeAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => true,
runOnboard,
});
expect(runOnboard).not.toHaveBeenCalled();
});
it("swallows runOnboard errors and emits diagnostic", async () => {
const runOnboard = vi.fn().mockRejectedValue(new Error("boom"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(
maybeAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => false,
runOnboard,
}),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[onboard-autolaunch] non-fatal onboard launch failure: boom"),
);
});
});

View File

@@ -0,0 +1,116 @@
import { existsSync } from "node:fs";
import { getDefaultCentralDbPath } from "@fusion/core";
import { isTTYAvailable } from "./dashboard-tui/index.js";
export interface AutoLaunchInput {
command: string;
args: string[];
centralDbExists: boolean;
isTTY: boolean;
env?: NodeJS.ProcessEnv;
}
export interface AutoLaunchDecision {
launch: boolean;
reason: string;
}
export function shouldAutoLaunchOnboarding(input: AutoLaunchInput): AutoLaunchDecision {
const env = input.env ?? process.env;
if (input.command === "serve" || input.command === "daemon") {
return { launch: false, reason: "command-skip" };
}
if (input.command === "onboard") {
return { launch: false, reason: "onboard-command" };
}
if (
input.args.includes("--help") ||
input.args.includes("-h") ||
input.args.includes("--version") ||
input.args.includes("-v")
) {
return { launch: false, reason: "help-or-version" };
}
if (!input.isTTY) {
return { launch: false, reason: "non-tty" };
}
if (input.args.includes("--skip-onboarding")) {
return { launch: false, reason: "skip-flag" };
}
if (isTruthy(env.FUSION_SKIP_ONBOARDING)) {
return { launch: false, reason: "skip-env" };
}
if (input.centralDbExists) {
return { launch: false, reason: "central-db-exists" };
}
return { launch: true, reason: "central-db-missing" };
}
function isTruthy(value: string | undefined): boolean {
if (value === undefined) {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
}
interface RunOnboardOptions {
force?: boolean;
}
type RunOnboard = (options?: RunOnboardOptions) => Promise<void> | void;
export interface MaybeAutoLaunchDeps {
command: string;
args: string[];
centralDbPath?: string;
isTTY?: boolean;
env?: NodeJS.ProcessEnv;
runOnboard?: RunOnboard;
pathExists?: (path: string) => boolean;
}
export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Promise<void> {
const env = deps.env ?? process.env;
const isTTY = deps.isTTY ?? isTTYAvailable();
let centralDbExists = true;
try {
const centralDbPath = deps.centralDbPath ?? getDefaultCentralDbPath();
centralDbExists = (deps.pathExists ?? existsSync)(centralDbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[onboard-autolaunch] central DB probe failed; skipping auto-launch: ${message}`);
return;
}
const decision = shouldAutoLaunchOnboarding({
command: deps.command,
args: deps.args,
centralDbExists,
isTTY,
env,
});
if (!decision.launch) {
return;
}
try {
const runOnboard = deps.runOnboard ?? (await import("./onboard.js")).runOnboard;
await runOnboard();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[onboard-autolaunch] non-fatal onboard launch failure: ${message}`);
}
}