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,
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
fn onboard

View File

@@ -129,9 +129,10 @@ describe("onboard", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
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(centralInitMock).toHaveBeenCalled();
});
it("runOnboard reports central db already exists", async () => {
@@ -142,20 +143,23 @@ describe("onboard", () => {
writeFileSync(existingPath, "db");
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"));
});
it("stores API key, runs init, persists global testMode and completion marker", async () => {
const providerAuth = makeProviderAuth();
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(mockRunInit).toHaveBeenCalledTimes(1);
expect(globalSettingsState.testMode).toBe(true);
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();
});
@@ -164,10 +168,10 @@ describe("onboard", () => {
mockProviderAuthFactory.mockReturnValue(providerAuth);
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();
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");
});
@@ -190,4 +194,62 @@ describe("onboard", () => {
session.close();
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;
}
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> {
const globalSettingsStore = new GlobalSettingsStore();
await globalSettingsStore.init();
@@ -152,11 +167,16 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
if (existsSync(centralDbPath)) {
console.log(`✓ Central DB already exists: ${centralDbPath}`);
} else {
console.log(`Creating central DB: ${centralDbPath}`);
const central = new CentralCore();
await central.init();
await central.close();
console.log("✓ Central DB initialized");
const ranCentralDb = await runSkippableStep(prompts, "Central DB", async () => {
console.log(`Creating central DB: ${centralDbPath}`);
const central = new CentralCore();
await central.init();
await central.close();
console.log("✓ Central DB initialized");
});
if (!ranCentralDb) {
console.log("Central DB setup skipped; database was not created or initialized.");
}
}
const authStorage = AuthStorage.create(getFusionAuthPath());
@@ -165,9 +185,10 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
const apiProviders = providerAuth.getApiKeyProviders();
if (apiProviders.length > 0) {
console.log("\nAI provider setup:");
await runSkippableStep(prompts, "AI provider setup", async () => {
const apiProviders = providerAuth.getApiKeyProviders();
if (apiProviders.length === 0) return;
const oauthProviders = new Set(providerAuth.getOAuthProviders().map((provider) => provider.id));
const providerChoices = apiProviders.map((provider) => {
const configured = providerAuth.hasApiKey(provider.id) || providerAuth.hasAuth(provider.id);
@@ -183,50 +204,50 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
allowSkip: true,
});
if (selectedProvider) {
if (oauthProviders.has(selectedProvider)) {
console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`);
} else {
const apiKey = await prompts.prompt("Enter API key");
providerAuth.setApiKey(selectedProvider, apiKey);
console.log(`✓ Stored API key for ${selectedProvider}`);
}
if (!selectedProvider) return;
if (oauthProviders.has(selectedProvider)) {
console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`);
return;
}
}
console.log("\nProject setup:");
const shouldInit = await prompts.promptYesNo("Run fn init for this directory now?", true);
if (shouldInit) {
const apiKey = await prompts.prompt("Enter API key");
providerAuth.setApiKey(selectedProvider, apiKey);
console.log(`✓ Stored API key for ${selectedProvider}`);
});
await runSkippableStep(prompts, "Project setup", async () => {
await runInit({});
}
});
console.log("\nCore settings:");
const testMode = await prompts.promptYesNo("Enable test mode globally?", false);
// Project testMode overrides global testMode when set.
await globalSettingsStore.updateSettings({ testMode });
await runSkippableStep(prompts, "Core settings", async () => {
const testMode = await prompts.promptYesNo("Enable test mode globally?", false);
// Project testMode overrides global testMode when set.
await globalSettingsStore.updateSettings({ testMode });
let projectContext: Awaited<ReturnType<typeof resolveProject>> | undefined;
try {
projectContext = await resolveProject(undefined);
} catch {
projectContext = undefined;
}
let projectContext: Awaited<ReturnType<typeof resolveProject>> | undefined;
try {
projectContext = await resolveProject(undefined);
} catch {
projectContext = undefined;
}
if (projectContext) {
const rawMaxConcurrent = await prompts.prompt(
"Set maxConcurrent for this project",
String((await projectContext.store.getSettings()).maxConcurrent ?? 2),
);
const maxConcurrent = validateMaxConcurrent(rawMaxConcurrent);
await projectContext.store.updateSettings({ maxConcurrent });
console.log(`✓ Project maxConcurrent set to ${maxConcurrent}`);
} else {
console.log("Skipping maxConcurrent (no active project found).");
}
if (projectContext) {
const rawMaxConcurrent = await prompts.prompt(
"Set maxConcurrent for this project",
String((await projectContext.store.getSettings()).maxConcurrent ?? 2),
);
const maxConcurrent = validateMaxConcurrent(rawMaxConcurrent);
await projectContext.store.updateSettings({ maxConcurrent });
console.log(`✓ Project maxConcurrent set to ${maxConcurrent}`);
} else {
console.log("Skipping maxConcurrent (no active project found).");
}
});
console.log("\nNext steps:");
console.log(" fn dashboard # launch dashboard");
console.log(" fn task create # create your first task");
await runSkippableStep(prompts, "Next steps", async () => {
console.log(" fn dashboard # launch dashboard");
console.log(" fn task create # create your first task");
});
await globalSettingsStore.updateSettings({
cliOnboardingCompletedAt: new Date().toISOString(),
@@ -245,5 +266,6 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
export const __testUtils = {
createPromptSession,
validateMaxConcurrent,
runSkippableStep,
PROMPT_CANCELLED_ERROR,
};