fix: stop onboarding ambushing a working install with questions

Two defects, both visible as "why is it asking me about AI provider setup
when I just started a dev server?".

The auto-launch gate probed ~/.fusion/fusion-central.db to decide whether the
install was initialized. SQLite central was removed, so a Postgres install
never creates that file and the probe was permanently false: onboarding
auto-launched on every interactive start of a completely working Fusion,
until something happened to stamp the completion marker. The probe now also
accepts the embedded Postgres data directory.

And auto-launched onboarding ran the full interactive flow. It fires while
the operator is starting something else, so its questions interrupt work
nobody asked to interrupt — and a dev server stopped on a prompt never
listens, which is why `pnpm dev --tunnel` produced no dev server and so no
tunnel link. Auto-launch is now non-interactive: create the central database,
stamp the marker, point at the dashboard, ask nothing. `fn onboard` still
runs every step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 20:55:18 -07:00
parent 8d76af3e5f
commit b67e3aa8bc
5 changed files with 88 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Starting Fusion no longer interrupts you with onboarding questions on a working install.
category: fix
dev: Two defects. (1) `maybeAutoLaunchOnboarding` probed `~/.fusion/fusion-central.db` to decide whether the install was initialized, but SQLite central was removed — a Postgres install never creates that file, so `centralDbExists` was permanently false and onboarding auto-launched on every interactive start until something stamped the completion marker. The probe now also accepts the embedded Postgres data directory. (2) Auto-launched onboarding ran the full interactive flow, so a dashboard or `pnpm dev --tunnel` start could stop dead on "Run ai provider setup now?" and never reach listening. `runOnboard` takes `interactive` (default true); auto-launch passes `false`, which creates the central database, stamps the marker, and points at the dashboard without asking anything. Explicit `fn onboard` keeps every step.

View File

@@ -185,6 +185,32 @@ describe("maybeAutoLaunchOnboarding", () => {
});
expect(runOnboard).toHaveBeenCalledTimes(1);
// FNXC:Onboarding 2026-08-19-03:38: auto-launch never interrogates the operator — it fires while
// they are starting something else, and a dev server stopped on a prompt never listens.
expect(runOnboard).toHaveBeenCalledWith({ interactive: false });
});
/*
FNXC:Onboarding 2026-08-19-03:38:
A Postgres install has no `fusion-central.db`; SQLite central was removed. Probing only that path
left centralDbExists permanently false, so onboarding auto-launched on every interactive start of
a fully working Fusion. The embedded Postgres data directory counts as an initialized install.
*/
it("treats an embedded-postgres install as initialized", async () => {
const runOnboard = vi.fn();
await maybeAutoLaunchOnboarding({
command: "task",
args: ["task", "list"],
centralDbPath: "/virtual/fusion-central.db",
isTTY: true,
// No SQLite file, but the Postgres data directory is present.
pathExists: (candidate: string) => candidate === "/virtual/embedded-postgres",
cliOnboardingCompleted: false,
runOnboard,
});
expect(runOnboard).not.toHaveBeenCalled();
});
it("does not invoke runOnboard when gate fails", async () => {

View File

@@ -279,6 +279,31 @@ describe("onboard", () => {
acceptSession.close();
});
/*
FNXC:Onboarding 2026-08-19-03:38:
Auto-launch fires while the operator is starting something ELSE — a dashboard, a `pnpm dev
--tunnel`. Its questions interrupt work nobody asked to interrupt, and a dev server stopped on
"Run ai provider setup now?" never listens, so nothing is served and no tunnel can point at it.
The non-interactive path prepares the install and asks nothing.
*/
it("asks nothing when not interactive, but still prepares the install", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
// No input at all: any prompt would hang or cancel rather than pass.
await runOnboard({ interactive: false });
expect(centralInitMock).toHaveBeenCalled();
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
// The interactive-only steps must not have run.
expect(mockRunInit).not.toHaveBeenCalled();
expect(providerAuth.setApiKey).not.toHaveBeenCalled();
const printed = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(printed).not.toMatch(/Run ai provider setup now/i);
expect(printed).toMatch(/dashboard|fn onboard/i);
});
it("allows fully skipping onboarding steps while still persisting completion marker", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);

View File

@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { getDefaultCentralDbPath, GlobalSettingsStore } from "@fusion/core";
import { isTTYAvailable } from "./dashboard-tui/index.js";
@@ -132,7 +132,14 @@ export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Prom
const cwd = deps.cwd ?? process.cwd();
const projectMarkerPath = join(cwd, ".fusion", "project.json");
const legacyProjectDbPath = join(cwd, ".fusion", "fusion.db");
centralDbExists = pathExists(centralDbPath);
/*
FNXC:Onboarding 2026-08-19-03:38:
The install is initialized if EITHER the legacy SQLite central file exists or the embedded
Postgres data directory does. Probing only the SQLite path made this permanently false on every
Postgres install — SQLite central was removed — so onboarding auto-launched on each interactive
start of a fully working Fusion until something happened to stamp the completion marker.
*/
centralDbExists = pathExists(centralDbPath) || pathExists(join(dirname(centralDbPath), "embedded-postgres"));
// FNXC:ProjectIdentityMarker 2026-07-14-17:20: Onboarding probes the new
// marker first and recognizes fusion.db only as a pre-cutover project signal.
projectInitialized = deps.projectInitialized
@@ -171,7 +178,9 @@ export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Prom
try {
const runOnboard = deps.runOnboard ?? (await import("./onboard.js")).runOnboard;
await runOnboard();
// FNXC:Onboarding 2026-08-19-03:38: auto-launch never interrogates the operator; see
// OnboardOptions.interactive.
await runOnboard({ interactive: false });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[onboard-autolaunch] non-fatal onboard launch failure: ${message}`);

View File

@@ -12,6 +12,15 @@ import { getModelRegistryModelsPath } from "./auth-paths.js";
export interface OnboardOptions {
force?: boolean;
input?: NodeJS.ReadableStream;
/*
FNXC:Onboarding 2026-08-19-03:38:
False for the AUTO-LAUNCHED path (see onboard-autolaunch): prepare the install and stamp the
marker, but ask nothing. Auto-launch fires while the operator is starting something else — a
dashboard, a `pnpm dev --tunnel` — so its questions (AI provider setup, project setup, core
settings) interrupt work nobody asked to interrupt, and a dev server stopped on a prompt never
listens at all. `fn onboard` is the command for the interactive flow and keeps every step.
*/
interactive?: boolean;
}
const PROMPT_CANCELLED_ERROR = "Interactive prompt cancelled";
@@ -274,6 +283,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
return;
}
const interactive = options.interactive !== false;
const prompts = createPromptSession(options.input);
try {
@@ -297,6 +307,14 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
console.log("✓ Central DB initialized");
}
if (!interactive) {
await globalSettingsStore.updateSettings({
cliOnboardingCompletedAt: new Date().toISOString(),
});
console.log("\nConnect a provider and finish setup in the dashboard, or run `fn onboard` anytime.");
return;
}
const authStorage = createFusionAuthStorage();
const modelRegistry = await createFusionModelRegistry(authStorage);
const providerAuth = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);