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:
@@ -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
|
||||
|
||||
193
packages/cli/src/commands/__tests__/onboard.test.ts
Normal file
193
packages/cli/src/commands/__tests__/onboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
249
packages/cli/src/commands/onboard.ts
Normal file
249
packages/cli/src/commands/onboard.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user