FN-5805: add fn onboard command with sequential runOnboard flow

Add a new interactive onboarding CLI flow that guides first-time setup end to end.

- add new `fn onboard` command wiring in CLI entrypoint and usage help
- implement `runOnboard()` with sequential prompts for central DB, provider auth, init, test mode, and project maxConcurrent
- persist `cliOnboardingCompletedAt` marker in global settings with `--force` rerun support
- add onboarding command tests and global settings regression coverage
- document `fn onboard` usage and options in CLI reference
- add a minor changeset for published `@runfusion/fusion`

Files changed:
 .changeset/fn-5805-onboard-command.md              |   5 +
 docs/cli-reference.md                              |  20 ++
 packages/cli/src/bin.ts                            |  10 +
 packages/cli/src/commands/__tests__/onboard.test.ts| 193 ++++++++++++++++
 packages/cli/src/commands/onboard.ts               | 249 +++++++++++++++++++++
 packages/core/src/__tests__/global-settings.test.ts|  11 +
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   4 +
 9 files changed, 494 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-5805

Fusion-Task-Lineage: b1bcaf27-9bd7-4569-b685-225a97fa1200
This commit is contained in:
gsxdsm
2026-06-01 01:42:19 -07:00
parent 9ba3a8e453
commit e854d33375
9 changed files with 494 additions and 1 deletions

View File

@@ -131,6 +131,7 @@ async function loadCommandHandlers() {
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
const { runInit } = await import("./commands/init.js");
const { runOnboard } = await import("./commands/onboard.js");
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
@@ -212,6 +213,7 @@ async function loadCommandHandlers() {
runNodeHealth,
runMeshStatus,
runInit,
runOnboard,
runAgentStop,
runAgentStart,
runAgentImport,
@@ -253,6 +255,7 @@ fn — AI-orchestrated task board
Usage:
fn Launch the dashboard (same as fn dashboard)
fn init [opts] Initialize a new fn project (--name, --path, --git)
fn onboard [--force] Run the interactive onboarding wizard
fn dashboard Start the board web UI
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
@@ -623,6 +626,7 @@ async function main() {
runNodeHealth,
runMeshStatus,
runInit,
runOnboard,
runAgentStop,
runAgentStart,
runAgentImport,
@@ -671,6 +675,12 @@ async function main() {
break;
}
case "onboard": {
const force = args.includes("--force");
await runOnboard({ force });
break;
}
case "dashboard": {
// Initialize native module resolution for Bun binary before starting dashboard
// This sets up the paths so node-pty can find its native assets

View File

@@ -0,0 +1,193 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockRunInit = vi.fn(async () => {});
const mockResolveProject = vi.fn();
const mockProviderAuthFactory = vi.fn();
const mockGetDefaultCentralDbPath = vi.fn();
const globalSettingsState: Record<string, any> = {};
class MockGlobalSettingsStore {
async init() {}
async getSettings() {
return { ...globalSettingsState };
}
async updateSettings(update: Record<string, any>) {
Object.assign(globalSettingsState, update);
}
}
const centralInitMock = vi.fn(async () => {});
const centralCloseMock = vi.fn(async () => {});
class MockCentralCore {
async init() {
await centralInitMock();
}
async close() {
await centralCloseMock();
}
}
vi.mock("../init.js", () => ({ runInit: mockRunInit }));
vi.mock("../project-context.js", () => ({ resolveProject: mockResolveProject }));
vi.mock("../provider-auth.js", () => ({
createReadOnlyAuthFileStorage: vi.fn(() => ({})),
mergeAuthStorageReads: vi.fn((primary) => primary),
wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()),
}));
vi.mock("../auth-paths.js", () => ({
getFusionAuthPath: vi.fn(() => "/tmp/auth.json"),
getLegacyAuthPaths: vi.fn(() => []),
getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"),
}));
vi.mock("@earendil-works/pi-coding-agent", () => ({
AuthStorage: { create: vi.fn(() => ({})) },
ModelRegistry: { create: vi.fn(() => ({})) },
}));
vi.mock("@fusion/core", () => ({
CentralCore: MockCentralCore,
GlobalSettingsStore: MockGlobalSettingsStore,
getDefaultCentralDbPath: mockGetDefaultCentralDbPath,
}));
const { __testUtils, runOnboard } = await import("../onboard.js");
function inputFrom(lines: string[]): PassThrough {
const input = new PassThrough();
let index = 0;
const pump = () => {
if (index >= lines.length) {
input.end();
return;
}
input.write(`${lines[index++]}\n`);
setTimeout(pump, 1);
};
setTimeout(pump, 0);
return input;
}
function makeProviderAuth() {
return {
getApiKeyProviders: vi.fn(() => [
{ id: "openrouter", name: "OpenRouter" },
{ id: "openai-codex", name: "Codex" },
]),
getOAuthProviders: vi.fn(() => [{ id: "openai-codex", name: "Codex" }]),
hasApiKey: vi.fn(() => false),
hasAuth: vi.fn(() => false),
setApiKey: vi.fn(),
};
}
describe("onboard", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(globalSettingsState)) delete globalSettingsState[key];
mockGetDefaultCentralDbPath.mockReturnValue(join(mkdtempSync(join(tmpdir(), "fn-onboard-db-")), "fusion-central.db"));
mockResolveProject.mockRejectedValue(new Error("no project"));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("prompt helper supports defaults, explicit input, yes/no parsing, and choice/skip", async () => {
const defaultSession = __testUtils.createPromptSession(inputFrom([""]));
await expect(defaultSession.prompt("Name", "default")).resolves.toBe("default");
defaultSession.close();
const explicitSession = __testUtils.createPromptSession(inputFrom(["value", "yes"]));
await expect(explicitSession.prompt("Name")).resolves.toBe("value");
await expect(explicitSession.promptYesNo("Proceed", false)).resolves.toBe(true);
explicitSession.close();
const choiceSession = __testUtils.createPromptSession(inputFrom(["2", "2"]));
await expect(
choiceSession.promptChoice(
"Provider",
[
{ id: "a", label: "A" },
{ id: "b", label: "B" },
],
{ allowSkip: true },
),
).resolves.toBe("b");
await expect(
choiceSession.promptChoice("Provider", [{ id: "a", label: "A" }], { allowSkip: true }),
).resolves.toBeUndefined();
choiceSession.close();
});
it("runOnboard initializes central db when missing", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runOnboard({
input: inputFrom(["3", "", "n", "n"]),
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Creating central DB"));
});
it("runOnboard reports central db already exists", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
const existingPath = mockGetDefaultCentralDbPath();
mkdirSync(dirname(existingPath), { recursive: true });
writeFileSync(existingPath, "db");
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runOnboard({ input: inputFrom(["3", "", "n", "n"]) });
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"]) });
expect(providerAuth.setApiKey).toHaveBeenCalledWith("openrouter", "test-key");
expect(mockRunInit).toHaveBeenCalledTimes(1);
expect(globalSettingsState.testMode).toBe(true);
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
expect(globalSettingsState.setupComplete).toBeUndefined();
});
it("re-runs only with force when marker already exists", async () => {
const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth);
globalSettingsState.cliOnboardingCompletedAt = "2026-06-01T00:00:00.000Z";
await runOnboard({ input: inputFrom(["3", "", "n", "n"]) });
expect(providerAuth.setApiKey).not.toHaveBeenCalled();
await runOnboard({ force: true, input: inputFrom(["3", "", "n", "n"]) });
expect(globalSettingsState.cliOnboardingCompletedAt).not.toBe("2026-06-01T00:00:00.000Z");
});
it("validates bad maxConcurrent locally without process exit", () => {
expect(() => __testUtils.validateMaxConcurrent("99")).toThrow(
"maxConcurrent must be an integer between 1 and 10.",
);
});
it("cleans up SIGINT listeners when prompt is cancelled", async () => {
const input = new PassThrough();
const before = process.listenerCount("SIGINT");
const session = __testUtils.createPromptSession(input);
const during = process.listenerCount("SIGINT");
expect(during).toBeGreaterThanOrEqual(before);
const pending = session.prompt("Name");
process.emit("SIGINT");
await expect(pending).rejects.toThrow(__testUtils.PROMPT_CANCELLED_ERROR);
session.close();
expect(process.listenerCount("SIGINT")).toBeLessThanOrEqual(before);
});
});

View File

@@ -0,0 +1,249 @@
import { existsSync } from "node:fs";
import { createInterface } from "node:readline";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core";
import { resolveProject } from "../project-context.js";
import { runInit } from "./init.js";
import {
createReadOnlyAuthFileStorage,
mergeAuthStorageReads,
wrapAuthStorageWithApiKeyProviders,
} from "./provider-auth.js";
import {
getFusionAuthPath,
getLegacyAuthPaths,
getModelRegistryModelsPath,
} from "./auth-paths.js";
export interface OnboardOptions {
force?: boolean;
input?: NodeJS.ReadableStream;
}
const PROMPT_CANCELLED_ERROR = "Interactive prompt cancelled";
interface PromptChoiceOption {
id: string;
label: string;
}
interface PromptChoiceOptions {
allowSkip?: boolean;
}
interface PromptSession {
prompt(question: string, defaultValue?: string): Promise<string>;
promptYesNo(question: string, defaultValue: boolean): Promise<boolean>;
promptChoice(
question: string,
choices: PromptChoiceOption[],
options?: PromptChoiceOptions,
): Promise<string | undefined>;
close(): void;
}
function createPromptSession(input: NodeJS.ReadableStream = process.stdin): PromptSession {
const rl = createInterface({ input, output: process.stdout });
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
process.removeListener("SIGINT", sigintHandler);
rl.close();
};
const cancel = () => {
cleanup();
console.log("\n");
};
const sigintHandler = () => cancel();
process.on("SIGINT", sigintHandler);
const ask = (question: string): Promise<string> =>
new Promise((resolve, reject) => {
const onClose = () => reject(new Error(PROMPT_CANCELLED_ERROR));
rl.once("close", onClose);
rl.question(question, (answer) => {
rl.removeListener("close", onClose);
resolve(answer.trim());
});
});
const prompt = async (question: string, defaultValue?: string): Promise<string> => {
while (true) {
const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : "";
const answer = await ask(`${question}${suffix}: `);
if (answer === "" && defaultValue !== undefined) {
return defaultValue;
}
if (answer !== "") {
return answer;
}
}
};
const promptYesNo = async (question: string, defaultValue: boolean): Promise<boolean> => {
const hint = defaultValue ? "Y/n" : "y/N";
while (true) {
const answer = (await ask(`${question} (${hint}): `)).toLowerCase();
if (!answer) return defaultValue;
if (answer === "y" || answer === "yes") return true;
if (answer === "n" || answer === "no") return false;
console.log("Please answer yes or no.");
}
};
const promptChoice = async (
question: string,
choices: PromptChoiceOption[],
options: PromptChoiceOptions = {},
): Promise<string | undefined> => {
if (choices.length === 0) return undefined;
const rendered = choices.map((choice, index) => ` ${index + 1}) ${choice.label}`);
rendered.forEach((line) => console.log(line));
if (options.allowSkip) {
console.log(` ${choices.length + 1}) Skip`);
}
while (true) {
const answer = await ask(`${question}: `);
const selected = parseInt(answer, 10);
const upperBound = choices.length + (options.allowSkip ? 1 : 0);
if (!Number.isNaN(selected) && selected >= 1 && selected <= upperBound) {
if (options.allowSkip && selected === choices.length + 1) return undefined;
return choices[selected - 1]?.id;
}
console.log(`Please select a number between 1 and ${upperBound}.`);
}
};
return {
prompt,
promptYesNo,
promptChoice,
close: cleanup,
};
}
function validateMaxConcurrent(input: string): number {
const value = parseInt(input, 10);
if (Number.isNaN(value) || value < 1 || value > 10) {
throw new Error("maxConcurrent must be an integer between 1 and 10.");
}
return value;
}
export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
const globalSettingsStore = new GlobalSettingsStore();
await globalSettingsStore.init();
const settings = await globalSettingsStore.getSettings();
if (settings.cliOnboardingCompletedAt && !options.force) {
console.log("Onboarding already completed. Re-run with --force to run it again.");
return;
}
const prompts = createPromptSession(options.input);
try {
const centralDbPath = getDefaultCentralDbPath();
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 authStorage = AuthStorage.create(getFusionAuthPath());
const supplementalAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
const apiProviders = providerAuth.getApiKeyProviders();
if (apiProviders.length > 0) {
console.log("\nAI provider setup:");
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);
const oauthHint = oauthProviders.has(provider.id) ? " (OAuth via fn dashboard)" : "";
const configuredHint = configured ? " (already configured)" : "";
return {
id: provider.id,
label: `${provider.name}${configuredHint}${oauthHint}`,
};
});
const selectedProvider = await prompts.promptChoice("Select provider", providerChoices, {
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}`);
}
}
}
console.log("\nProject setup:");
const shouldInit = await prompts.promptYesNo("Run fn init for this directory now?", true);
if (shouldInit) {
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 });
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).");
}
console.log("\nNext steps:");
console.log(" fn dashboard # launch dashboard");
console.log(" fn task create # create your first task");
await globalSettingsStore.updateSettings({
cliOnboardingCompletedAt: new Date().toISOString(),
});
console.log("\n✓ Onboarding complete");
} catch (error) {
if (error instanceof Error && error.message === PROMPT_CANCELLED_ERROR) {
throw new Error("Onboarding cancelled.");
}
throw error;
} finally {
prompts.close();
}
}
export const __testUtils = {
createPromptSession,
validateMaxConcurrent,
PROMPT_CANCELLED_ERROR,
};