FN-5847: honor onboarding completion marker in auto-launch

CLI onboarding auto-launch now skips once the persisted completion marker is present.

- Load `cliOnboardingCompletedAt` from global settings before launching onboarding when the central DB is missing.
- Preserve existing skip precedence for non-TTY, command, flag, environment, and central DB guards.
- Add unit and backcompat coverage for completion-marker and skipped-central-DB scenarios.
- Document the updated auto-launch behavior and add a patch changeset.

Files changed:
 .changeset/fn-5847-onboard-autolaunch-marker.md    |  5 ++
 docs/cli-reference.md                              | 12 +--
 .../onboard-autolaunch-backcompat-e2e.test.ts      |  7 ++
 .../onboard-autolaunch-backcompat.test.ts          |  8 ++
 .../__tests__/onboard-autolaunch-bypass.test.ts    |  7 ++
 .../commands/__tests__/onboard-autolaunch.test.ts  | 93 ++++++++++++++++++++++
 packages/cli/src/commands/onboard-autolaunch.ts    | 46 ++++++++++-
 7 files changed, 171 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-5847

Fusion-Task-Lineage: db3e474e-e4b8-4299-8087-07b6cd1f4518
This commit is contained in:
gsxdsm
2026-06-01 18:52:28 -07:00
parent 2053f3f7b8
commit d4db0b0b00
7 changed files with 171 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
CLI auto-launch now honors the persisted `cliOnboardingCompletedAt` marker so onboarding fires only once, even when the Central DB step was skipped during `fn onboard`.

View File

@@ -72,13 +72,15 @@ On successful completion, Fusion records `cliOnboardingCompletedAt` in global
settings. settings.
Auto-launch behavior: before interactive commands, Fusion auto-launches onboarding Auto-launch behavior: before interactive commands, Fusion auto-launches onboarding
only when the central DB at `getDefaultCentralDbPath()` is missing. Auto-launch only when the central DB at `getDefaultCentralDbPath()` is missing and CLI
is skipped for `serve`, `daemon`, non-TTY runs, `--skip-onboarding`, and onboarding has not already completed. Auto-launch is skipped for `serve`,
`FUSION_SKIP_ONBOARDING`. `daemon`, non-TTY runs, `--skip-onboarding`, `FUSION_SKIP_ONBOARDING`, and once
`cliOnboardingCompletedAt` is set.
Backward-compatibility guard: existing setups are never blocked — when central DB Backward-compatibility guard: existing setups are never blocked — when central DB
already exists (including the central-DB + registered-project case), onboarding already exists (including the central-DB + registered-project case), or when the
does not auto-launch. CLI onboarding completion marker exists even if the central DB step was skipped,
onboarding does not auto-launch.
--- ---

View File

@@ -21,6 +21,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
cwd: "/repo/demo", cwd: "/repo/demo",
isTTY: true, isTTY: true,
pathExists, pathExists,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -38,6 +39,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db", centralDbPath: "/virtual/central.db",
isTTY: false, isTTY: false,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -74,6 +76,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db", centralDbPath: "/virtual/central.db",
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -92,6 +95,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
isTTY: true, isTTY: true,
env: { FUSION_SKIP_ONBOARDING: "true" }, env: { FUSION_SKIP_ONBOARDING: "true" },
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -110,6 +114,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db", centralDbPath: "/virtual/central.db",
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -130,6 +135,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db", centralDbPath: "/virtual/central.db",
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -151,6 +157,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
cwd: "/workspace/demo", cwd: "/workspace/demo",
isTTY: true, isTTY: true,
pathExists, pathExists,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();

View File

@@ -17,6 +17,7 @@ describe("onboard autolaunch backward-compat guard", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: true, centralDbExists: true,
projectInitialized: true, projectInitialized: true,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "central-db-and-project-exist" }); ).toEqual({ launch: false, reason: "central-db-and-project-exist" });
@@ -29,6 +30,7 @@ describe("onboard autolaunch backward-compat guard", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: true, centralDbExists: true,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "central-db-exists" }); ).toEqual({ launch: false, reason: "central-db-exists" });
@@ -41,6 +43,7 @@ describe("onboard autolaunch backward-compat guard", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: true, projectInitialized: true,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: true, reason: "central-db-missing" }); ).toEqual({ launch: true, reason: "central-db-missing" });
@@ -53,6 +56,7 @@ describe("onboard autolaunch backward-compat guard", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: false, isTTY: false,
}), }),
).toEqual({ launch: false, reason: "non-tty" }); ).toEqual({ launch: false, reason: "non-tty" });
@@ -66,6 +70,7 @@ describe("onboard autolaunch backward-compat guard", () => {
args: [command], args: [command],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "command-skip" }); ).toEqual({ launch: false, reason: "command-skip" });
@@ -85,6 +90,7 @@ describe("onboard autolaunch backward-compat guard", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbPath: "/virtual/central.db", centralDbPath: "/virtual/central.db",
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
@@ -104,6 +110,7 @@ describe("onboard autolaunch backward-compat guard", () => {
cwd: "/workspace/demo", cwd: "/workspace/demo",
isTTY: true, isTTY: true,
pathExists, pathExists,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}); });
@@ -123,6 +130,7 @@ describe("onboard autolaunch backward-compat guard", () => {
centralDbPath: "/virtual/central.db", centralDbPath: "/virtual/central.db",
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();

View File

@@ -20,6 +20,7 @@ describe("onboard bypass reasons", () => {
args: ["task", "list", "--skip-onboarding"], args: ["task", "list", "--skip-onboarding"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "skip-flag" }); ).toEqual({ launch: false, reason: "skip-flag" });
@@ -30,6 +31,7 @@ describe("onboard bypass reasons", () => {
args: ["task", "list", "--skip-onboarding"], args: ["task", "list", "--skip-onboarding"],
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}); });
expect(runOnboard).not.toHaveBeenCalled(); expect(runOnboard).not.toHaveBeenCalled();
@@ -42,6 +44,7 @@ describe("onboard bypass reasons", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
env: { FUSION_SKIP_ONBOARDING: "1" }, env: { FUSION_SKIP_ONBOARDING: "1" },
}), }),
@@ -54,6 +57,7 @@ describe("onboard bypass reasons", () => {
env: { FUSION_SKIP_ONBOARDING: "1" }, env: { FUSION_SKIP_ONBOARDING: "1" },
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}); });
expect(runOnboard).not.toHaveBeenCalled(); expect(runOnboard).not.toHaveBeenCalled();
@@ -76,6 +80,7 @@ describe("onboard bypass reasons", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: true, reason: "central-db-missing" }); ).toEqual({ launch: true, reason: "central-db-missing" });
@@ -122,6 +127,7 @@ describe("extractGlobalProjectFlag", () => {
skipOnboarding: parsed.skipOnboarding, skipOnboarding: parsed.skipOnboarding,
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}); });
@@ -132,6 +138,7 @@ describe("extractGlobalProjectFlag", () => {
skipOnboarding: parsed.skipOnboarding, skipOnboarding: parsed.skipOnboarding,
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "skip-flag" }); ).toEqual({ launch: false, reason: "skip-flag" });

View File

@@ -13,6 +13,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: true, reason: "central-db-missing" }); ).toEqual({ launch: true, reason: "central-db-missing" });
@@ -25,11 +26,53 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["dashboard"], args: ["dashboard"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: true, reason: "central-db-missing" }); ).toEqual({ launch: true, reason: "central-db-missing" });
}); });
it("skips when onboarding completion marker is present", () => {
expect(
shouldAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: true,
isTTY: true,
}),
).toEqual({ launch: false, reason: "onboarding-complete-marker" });
});
it("keeps non-TTY precedence over onboarding completion marker", () => {
expect(
shouldAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: true,
isTTY: false,
}),
).toEqual({ launch: false, reason: "non-tty" });
});
it("keeps command skip precedence over onboarding completion marker", () => {
for (const command of ["serve", "daemon"]) {
expect(
shouldAutoLaunchOnboarding({
command,
args: [command],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: true,
isTTY: true,
}),
).toEqual({ launch: false, reason: "command-skip" });
}
});
it("skips when skip flag is present in args", () => { it("skips when skip flag is present in args", () => {
const args = ["task", "list", "--skip-onboarding"]; const args = ["task", "list", "--skip-onboarding"];
expect(args).toContain("--skip-onboarding"); expect(args).toContain("--skip-onboarding");
@@ -39,6 +82,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args, args,
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "skip-flag" }); ).toEqual({ launch: false, reason: "skip-flag" });
@@ -51,6 +95,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: true, centralDbExists: true,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "central-db-exists" }); ).toEqual({ launch: false, reason: "central-db-exists" });
@@ -63,6 +108,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: false, isTTY: false,
}), }),
).toEqual({ launch: false, reason: "non-tty" }); ).toEqual({ launch: false, reason: "non-tty" });
@@ -75,6 +121,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["serve"], args: ["serve"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "command-skip" }); ).toEqual({ launch: false, reason: "command-skip" });
@@ -85,6 +132,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["daemon"], args: ["daemon"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "command-skip" }); ).toEqual({ launch: false, reason: "command-skip" });
@@ -97,6 +145,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["onboard"], args: ["onboard"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
}), }),
).toEqual({ launch: false, reason: "onboard-command" }); ).toEqual({ launch: false, reason: "onboard-command" });
@@ -109,6 +158,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"], args: ["task", "list"],
centralDbExists: false, centralDbExists: false,
projectInitialized: false, projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true, isTTY: true,
env: { FUSION_SKIP_ONBOARDING: "1" }, env: { FUSION_SKIP_ONBOARDING: "1" },
}), }),
@@ -130,6 +180,7 @@ describe("maybeAutoLaunchOnboarding", () => {
centralDbPath: "/virtual/fusion-central.db", centralDbPath: "/virtual/fusion-central.db",
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}); });
@@ -145,12 +196,53 @@ describe("maybeAutoLaunchOnboarding", () => {
centralDbPath: "/virtual/fusion-central.db", centralDbPath: "/virtual/fusion-central.db",
isTTY: true, isTTY: true,
pathExists: () => true, pathExists: () => true,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}); });
expect(runOnboard).not.toHaveBeenCalled(); expect(runOnboard).not.toHaveBeenCalled();
}); });
it("does not invoke runOnboard when injected marker is complete", async () => {
const runOnboard = vi.fn();
await maybeAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => false,
loadOnboardingComplete: () => true,
runOnboard,
});
expect(runOnboard).not.toHaveBeenCalled();
});
it("treats marker resolver failure as incomplete and emits diagnostic", async () => {
const runOnboard = vi.fn();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
await maybeAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => false,
loadOnboardingComplete: () => {
throw new Error("settings unavailable");
},
runOnboard,
});
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
"[onboard-autolaunch] onboarding marker probe failed; treating as incomplete: settings unavailable",
),
);
expect(runOnboard).toHaveBeenCalledTimes(1);
});
it("swallows runOnboard errors and emits diagnostic", async () => { it("swallows runOnboard errors and emits diagnostic", async () => {
const runOnboard = vi.fn().mockRejectedValue(new Error("boom")); const runOnboard = vi.fn().mockRejectedValue(new Error("boom"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -162,6 +254,7 @@ describe("maybeAutoLaunchOnboarding", () => {
centralDbPath: "/virtual/fusion-central.db", centralDbPath: "/virtual/fusion-central.db",
isTTY: true, isTTY: true,
pathExists: () => false, pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard, runOnboard,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();

View File

@@ -1,14 +1,16 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { getDefaultCentralDbPath } from "@fusion/core"; import { getDefaultCentralDbPath, GlobalSettingsStore } from "@fusion/core";
import { isTTYAvailable } from "./dashboard-tui/index.js"; import { isTTYAvailable } from "./dashboard-tui/index.js";
import { isCliOnboardingComplete } from "./onboard.js";
export interface AutoLaunchInput { export interface AutoLaunchInput {
command: string; command: string;
args: string[]; args: string[];
centralDbExists: boolean; centralDbExists: boolean;
projectInitialized: boolean; projectInitialized: boolean;
cliOnboardingCompleted: boolean;
isTTY: boolean; isTTY: boolean;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
skipOnboarding?: boolean; skipOnboarding?: boolean;
@@ -51,6 +53,10 @@ export function shouldAutoLaunchOnboarding(input: AutoLaunchInput): AutoLaunchDe
return { launch: false, reason: "skip-env" }; return { launch: false, reason: "skip-env" };
} }
if (input.cliOnboardingCompleted) {
return { launch: false, reason: "onboarding-complete-marker" };
}
if (input.centralDbExists && input.projectInitialized) { if (input.centralDbExists && input.projectInitialized) {
return { launch: false, reason: "central-db-and-project-exist" }; return { launch: false, reason: "central-db-and-project-exist" };
} }
@@ -88,6 +94,30 @@ export interface MaybeAutoLaunchDeps {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
runOnboard?: RunOnboard; runOnboard?: RunOnboard;
pathExists?: (path: string) => boolean; pathExists?: (path: string) => boolean;
cliOnboardingCompleted?: boolean;
loadOnboardingComplete?: () => Promise<boolean> | boolean;
}
async function loadCliOnboardingComplete(): Promise<boolean> {
const globalSettingsStore = new GlobalSettingsStore();
await globalSettingsStore.init();
const settings = await globalSettingsStore.getSettings();
return isCliOnboardingComplete(settings);
}
async function resolveCliOnboardingCompleted(deps: MaybeAutoLaunchDeps): Promise<boolean> {
if (deps.cliOnboardingCompleted !== undefined) {
return deps.cliOnboardingCompleted;
}
const loadOnboardingComplete = deps.loadOnboardingComplete ?? loadCliOnboardingComplete;
try {
return await loadOnboardingComplete();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[onboard-autolaunch] onboarding marker probe failed; treating as incomplete: ${message}`);
return false;
}
} }
export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Promise<void> { export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Promise<void> {
@@ -109,14 +139,26 @@ export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Prom
return; return;
} }
const decision = shouldAutoLaunchOnboarding({ const baseDecisionInput = {
command: deps.command, command: deps.command,
args: deps.args, args: deps.args,
centralDbExists, centralDbExists,
projectInitialized, projectInitialized,
cliOnboardingCompleted: false,
isTTY, isTTY,
env, env,
skipOnboarding: deps.skipOnboarding, skipOnboarding: deps.skipOnboarding,
};
const baseDecision = shouldAutoLaunchOnboarding(baseDecisionInput);
if (!baseDecision.launch) {
return;
}
const cliOnboardingCompleted = await resolveCliOnboardingCompleted(deps);
const decision = shouldAutoLaunchOnboarding({
...baseDecisionInput,
cliOnboardingCompleted,
}); });
if (!decision.launch) { if (!decision.launch) {