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:
5
.changeset/fn-5807-onboarding-autolaunch.md
Normal file
5
.changeset/fn-5807-onboarding-autolaunch.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
164
packages/cli/src/commands/__tests__/onboard-autolaunch.test.ts
Normal file
164
packages/cli/src/commands/__tests__/onboard-autolaunch.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
116
packages/cli/src/commands/onboard-autolaunch.ts
Normal file
116
packages/cli/src/commands/onboard-autolaunch.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user