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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user