FN-5806: make onboarding steps individually skippable

Allow each fn onboard stage to be skipped without exiting the overall onboarding flow.

- add a reusable skippable-step helper that prompts before each onboarding section
- gate central DB setup, provider setup, project init, core settings, and next-steps output behind per-step skip prompts
- preserve onboarding completion persistence while keeping cancellation semantics intact
- expand onboarding command tests for full skip, selective skip, and cancellation coverage
- document per-step skip behavior in the CLI reference
- add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5806-onboard-step-skip.md            |   5 +
 docs/cli-reference.md                              |   3 +-
 packages/cli/src/commands/__tests__/onboard.test.ts     |  72 ++++++++++++-
 packages/cli/src/commands/onboard.ts               | 112 ++++++++++++---------
 4 files changed, 141 insertions(+), 51 deletions(-)

Fusion-Task-Id: FN-5806

Fusion-Task-Lineage: d122d67a-2a13-4b2f-a1aa-459dc548d370
This commit is contained in:
gsxdsm
2026-06-01 13:20:55 -07:00
parent 641b932631
commit 40b491958f
4 changed files with 141 additions and 51 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
`fn onboard` now allows each onboarding step to be skipped individually without aborting the overall wizard. Skipping steps still marks onboarding as completed, while interactive cancellation behavior remains unchanged.

View File

@@ -52,7 +52,8 @@ During fresh initialization, Fusion also installs the bundled `fusion` skill int
Run the interactive CLI onboarding wizard. It walks through central DB setup, Run the interactive CLI onboarding wizard. It walks through central DB setup,
API-key provider setup, optional first-project init, core settings defaults, and API-key provider setup, optional first-project init, core settings defaults, and
a short next-steps tour. a short next-steps tour. Each step can be individually skipped while staying in
the onboarding flow.
```bash ```bash
fn onboard fn onboard

View File

@@ -129,9 +129,10 @@ describe("onboard", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runOnboard({ await runOnboard({
input: inputFrom(["3", "", "n", "n"]), input: inputFrom(["y", "y", "3", "y", "y", "n", "y"]),
}); });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Creating central DB")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Creating central DB"));
expect(centralInitMock).toHaveBeenCalled();
}); });
it("runOnboard reports central db already exists", async () => { it("runOnboard reports central db already exists", async () => {
@@ -142,20 +143,23 @@ describe("onboard", () => {
writeFileSync(existingPath, "db"); writeFileSync(existingPath, "db");
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runOnboard({ input: inputFrom(["3", "", "n", "n"]) }); await runOnboard({ input: inputFrom(["y", "3", "y", "y", "n", "y"]) });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Central DB already exists")); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Central DB already exists"));
}); });
it("stores API key, runs init, persists global testMode and completion marker", async () => { it("stores API key, runs init, persists global testMode and completion marker", async () => {
const providerAuth = makeProviderAuth(); const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth); mockProviderAuthFactory.mockReturnValue(providerAuth);
await runOnboard({ input: inputFrom(["1", "test-key", "y", "y"]) }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runOnboard({ input: inputFrom(["y", "y", "1", "test-key", "y", "y", "y", "y"]) });
expect(providerAuth.setApiKey).toHaveBeenCalledWith("openrouter", "test-key"); expect(providerAuth.setApiKey).toHaveBeenCalledWith("openrouter", "test-key");
expect(mockRunInit).toHaveBeenCalledTimes(1); expect(mockRunInit).toHaveBeenCalledTimes(1);
expect(globalSettingsState.testMode).toBe(true); expect(globalSettingsState.testMode).toBe(true);
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string"); expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
expect(logSpy).toHaveBeenCalledWith(" fn dashboard # launch dashboard");
expect(logSpy).toHaveBeenCalledWith(" fn task create # create your first task");
expect(globalSettingsState.setupComplete).toBeUndefined(); expect(globalSettingsState.setupComplete).toBeUndefined();
}); });
@@ -164,10 +168,10 @@ describe("onboard", () => {
mockProviderAuthFactory.mockReturnValue(providerAuth); mockProviderAuthFactory.mockReturnValue(providerAuth);
globalSettingsState.cliOnboardingCompletedAt = "2026-06-01T00:00:00.000Z"; globalSettingsState.cliOnboardingCompletedAt = "2026-06-01T00:00:00.000Z";
await runOnboard({ input: inputFrom(["3", "", "n", "n"]) }); await runOnboard({ input: inputFrom(["y", "3", "y", "y", "n", "y"]) });
expect(providerAuth.setApiKey).not.toHaveBeenCalled(); expect(providerAuth.setApiKey).not.toHaveBeenCalled();
await runOnboard({ force: true, input: inputFrom(["3", "", "n", "n"]) }); await runOnboard({ force: true, input: inputFrom(["y", "n", "n", "n", "n"]) });
expect(globalSettingsState.cliOnboardingCompletedAt).not.toBe("2026-06-01T00:00:00.000Z"); expect(globalSettingsState.cliOnboardingCompletedAt).not.toBe("2026-06-01T00:00:00.000Z");
}); });
@@ -190,4 +194,62 @@ describe("onboard", () => {
session.close(); session.close();
expect(process.listenerCount("SIGINT")).toBeLessThanOrEqual(before); expect(process.listenerCount("SIGINT")).toBeLessThanOrEqual(before);
}); });
it("runSkippableStep declines without running body and accepts once", async () => {
const declineSession = __testUtils.createPromptSession(inputFrom(["n"]));
const declineBody = vi.fn(async () => {});
await expect(__testUtils.runSkippableStep(declineSession, "Sample", declineBody)).resolves.toBe(false);
expect(declineBody).not.toHaveBeenCalled();
declineSession.close();
const acceptSession = __testUtils.createPromptSession(inputFrom(["y"]));
const acceptBody = vi.fn(async () => {});
await expect(__testUtils.runSkippableStep(acceptSession, "Sample", acceptBody)).resolves.toBe(true);
expect(acceptBody).toHaveBeenCalledTimes(1);
acceptSession.close();
});
it("allows fully skipping onboarding steps while still persisting completion marker", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
await runOnboard({ input: inputFrom(["n", "n", "n", "n", "n"]) });
expect(centralInitMock).not.toHaveBeenCalled();
expect(mockRunInit).not.toHaveBeenCalled();
expect(globalSettingsState.testMode).toBeUndefined();
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
expect(providerAuth.setApiKey).not.toHaveBeenCalled();
});
it("supports selective skip for provider while running init and settings", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
await runOnboard({ input: inputFrom(["y", "n", "y", "y", "n", "y"]) });
expect(providerAuth.setApiKey).not.toHaveBeenCalled();
expect(mockRunInit).toHaveBeenCalledTimes(1);
expect(globalSettingsState.testMode).toBe(false);
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
});
it("supports selective skip for project setup while other steps run", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
await runOnboard({ input: inputFrom(["y", "y", "3", "n", "y", "n", "y"]) });
expect(mockRunInit).not.toHaveBeenCalled();
expect(globalSettingsState.testMode).toBe(false);
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
});
it("treats cancellation distinctly from skip and does not persist completion", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
const cancelledInput = new PassThrough();
cancelledInput.end();
await expect(runOnboard({ input: cancelledInput })).rejects.toThrow("Onboarding cancelled.");
expect(globalSettingsState.cliOnboardingCompletedAt).toBeUndefined();
});
}); });

View File

@@ -135,6 +135,21 @@ function validateMaxConcurrent(input: string): number {
return value; return value;
} }
async function runSkippableStep(
prompts: PromptSession,
label: string,
body: () => Promise<void>,
): Promise<boolean> {
console.log(`\n${label}:`);
const shouldRun = await prompts.promptYesNo(`Run ${label.toLowerCase()} now?`, true);
if (!shouldRun) {
console.log(`⤳ Skipped ${label}`);
return false;
}
await body();
return true;
}
export async function runOnboard(options: OnboardOptions = {}): Promise<void> { export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
const globalSettingsStore = new GlobalSettingsStore(); const globalSettingsStore = new GlobalSettingsStore();
await globalSettingsStore.init(); await globalSettingsStore.init();
@@ -152,11 +167,16 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
if (existsSync(centralDbPath)) { if (existsSync(centralDbPath)) {
console.log(`✓ Central DB already exists: ${centralDbPath}`); console.log(`✓ Central DB already exists: ${centralDbPath}`);
} else { } else {
const ranCentralDb = await runSkippableStep(prompts, "Central DB", async () => {
console.log(`Creating central DB: ${centralDbPath}`); console.log(`Creating central DB: ${centralDbPath}`);
const central = new CentralCore(); const central = new CentralCore();
await central.init(); await central.init();
await central.close(); await central.close();
console.log("✓ Central DB initialized"); console.log("✓ Central DB initialized");
});
if (!ranCentralDb) {
console.log("Central DB setup skipped; database was not created or initialized.");
}
} }
const authStorage = AuthStorage.create(getFusionAuthPath()); const authStorage = AuthStorage.create(getFusionAuthPath());
@@ -165,9 +185,10 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
await runSkippableStep(prompts, "AI provider setup", async () => {
const apiProviders = providerAuth.getApiKeyProviders(); const apiProviders = providerAuth.getApiKeyProviders();
if (apiProviders.length > 0) { if (apiProviders.length === 0) return;
console.log("\nAI provider setup:");
const oauthProviders = new Set(providerAuth.getOAuthProviders().map((provider) => provider.id)); const oauthProviders = new Set(providerAuth.getOAuthProviders().map((provider) => provider.id));
const providerChoices = apiProviders.map((provider) => { const providerChoices = apiProviders.map((provider) => {
const configured = providerAuth.hasApiKey(provider.id) || providerAuth.hasAuth(provider.id); const configured = providerAuth.hasApiKey(provider.id) || providerAuth.hasAuth(provider.id);
@@ -183,24 +204,22 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
allowSkip: true, allowSkip: true,
}); });
if (selectedProvider) { if (!selectedProvider) return;
if (oauthProviders.has(selectedProvider)) { if (oauthProviders.has(selectedProvider)) {
console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`); console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`);
} else { return;
}
const apiKey = await prompts.prompt("Enter API key"); const apiKey = await prompts.prompt("Enter API key");
providerAuth.setApiKey(selectedProvider, apiKey); providerAuth.setApiKey(selectedProvider, apiKey);
console.log(`✓ Stored API key for ${selectedProvider}`); console.log(`✓ Stored API key for ${selectedProvider}`);
} });
}
}
console.log("\nProject setup:"); await runSkippableStep(prompts, "Project setup", async () => {
const shouldInit = await prompts.promptYesNo("Run fn init for this directory now?", true);
if (shouldInit) {
await runInit({}); await runInit({});
} });
console.log("\nCore settings:"); await runSkippableStep(prompts, "Core settings", async () => {
const testMode = await prompts.promptYesNo("Enable test mode globally?", false); const testMode = await prompts.promptYesNo("Enable test mode globally?", false);
// Project testMode overrides global testMode when set. // Project testMode overrides global testMode when set.
await globalSettingsStore.updateSettings({ testMode }); await globalSettingsStore.updateSettings({ testMode });
@@ -223,10 +242,12 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
} else { } else {
console.log("Skipping maxConcurrent (no active project found)."); console.log("Skipping maxConcurrent (no active project found).");
} }
});
console.log("\nNext steps:"); await runSkippableStep(prompts, "Next steps", async () => {
console.log(" fn dashboard # launch dashboard"); console.log(" fn dashboard # launch dashboard");
console.log(" fn task create # create your first task"); console.log(" fn task create # create your first task");
});
await globalSettingsStore.updateSettings({ await globalSettingsStore.updateSettings({
cliOnboardingCompletedAt: new Date().toISOString(), cliOnboardingCompletedAt: new Date().toISOString(),
@@ -245,5 +266,6 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
export const __testUtils = { export const __testUtils = {
createPromptSession, createPromptSession,
validateMaxConcurrent, validateMaxConcurrent,
runSkippableStep,
PROMPT_CANCELLED_ERROR, PROMPT_CANCELLED_ERROR,
}; };