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.
Auto-launch behavior: before interactive commands, Fusion auto-launches onboarding
only when the central DB at `getDefaultCentralDbPath()` is missing. Auto-launch
is skipped for `serve`, `daemon`, non-TTY runs, `--skip-onboarding`, and
`FUSION_SKIP_ONBOARDING`.
only when the central DB at `getDefaultCentralDbPath()` is missing and CLI
onboarding has not already completed. Auto-launch is skipped for `serve`,
`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
already exists (including the central-DB + registered-project case), onboarding
does not auto-launch.
already exists (including the central-DB + registered-project case), or when the
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",
isTTY: true,
pathExists,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();
@@ -38,6 +39,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db",
isTTY: false,
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();
@@ -74,6 +76,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db",
isTTY: true,
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();
@@ -92,6 +95,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
isTTY: true,
env: { FUSION_SKIP_ONBOARDING: "true" },
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();
@@ -110,6 +114,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db",
isTTY: true,
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();
@@ -130,6 +135,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
centralDbPath: "/virtual/central.db",
isTTY: true,
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();
@@ -151,6 +157,7 @@ describe("maybeAutoLaunchOnboarding backward-compat e2e guard", () => {
cwd: "/workspace/demo",
isTTY: true,
pathExists,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).toEqual({ launch: true, reason: "central-db-missing" });
@@ -25,11 +26,53 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["dashboard"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).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", () => {
const args = ["task", "list", "--skip-onboarding"];
expect(args).toContain("--skip-onboarding");
@@ -39,6 +82,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args,
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "skip-flag" });
@@ -51,6 +95,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"],
centralDbExists: true,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "central-db-exists" });
@@ -63,6 +108,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: false,
}),
).toEqual({ launch: false, reason: "non-tty" });
@@ -75,6 +121,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["serve"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "command-skip" });
@@ -85,6 +132,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["daemon"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "command-skip" });
@@ -97,6 +145,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["onboard"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
}),
).toEqual({ launch: false, reason: "onboard-command" });
@@ -109,6 +158,7 @@ describe("shouldAutoLaunchOnboarding", () => {
args: ["task", "list"],
centralDbExists: false,
projectInitialized: false,
cliOnboardingCompleted: false,
isTTY: true,
env: { FUSION_SKIP_ONBOARDING: "1" },
}),
@@ -130,6 +180,7 @@ describe("maybeAutoLaunchOnboarding", () => {
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
});
@@ -145,12 +196,53 @@ describe("maybeAutoLaunchOnboarding", () => {
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => true,
cliOnboardingCompleted: false,
runOnboard,
});
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 () => {
const runOnboard = vi.fn().mockRejectedValue(new Error("boom"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -162,6 +254,7 @@ describe("maybeAutoLaunchOnboarding", () => {
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
pathExists: () => false,
cliOnboardingCompleted: false,
runOnboard,
}),
).resolves.toBeUndefined();

View File

@@ -1,14 +1,16 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { getDefaultCentralDbPath } from "@fusion/core";
import { getDefaultCentralDbPath, GlobalSettingsStore } from "@fusion/core";
import { isTTYAvailable } from "./dashboard-tui/index.js";
import { isCliOnboardingComplete } from "./onboard.js";
export interface AutoLaunchInput {
command: string;
args: string[];
centralDbExists: boolean;
projectInitialized: boolean;
cliOnboardingCompleted: boolean;
isTTY: boolean;
env?: NodeJS.ProcessEnv;
skipOnboarding?: boolean;
@@ -51,6 +53,10 @@ export function shouldAutoLaunchOnboarding(input: AutoLaunchInput): AutoLaunchDe
return { launch: false, reason: "skip-env" };
}
if (input.cliOnboardingCompleted) {
return { launch: false, reason: "onboarding-complete-marker" };
}
if (input.centralDbExists && input.projectInitialized) {
return { launch: false, reason: "central-db-and-project-exist" };
}
@@ -88,6 +94,30 @@ export interface MaybeAutoLaunchDeps {
env?: NodeJS.ProcessEnv;
runOnboard?: RunOnboard;
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> {
@@ -109,14 +139,26 @@ export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Prom
return;
}
const decision = shouldAutoLaunchOnboarding({
const baseDecisionInput = {
command: deps.command,
args: deps.args,
centralDbExists,
projectInitialized,
cliOnboardingCompleted: false,
isTTY,
env,
skipOnboarding: deps.skipOnboarding,
};
const baseDecision = shouldAutoLaunchOnboarding(baseDecisionInput);
if (!baseDecision.launch) {
return;
}
const cliOnboardingCompleted = await resolveCliOnboardingCompleted(deps);
const decision = shouldAutoLaunchOnboarding({
...baseDecisionInput,
cliOnboardingCompleted,
});
if (!decision.launch) {