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:
5
.changeset/fn-5805-onboard-command.md
Normal file
5
.changeset/fn-5805-onboard-command.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add `fn onboard` command: a sequential, prompt-based onboarding wizard covering central DB creation, AI provider setup (API key), first project init, core settings defaults, and a next-steps tour. Persists a `cliOnboardingCompletedAt` completion marker in global settings (distinct from the dashboard `setupComplete` first-run flag).
|
||||||
@@ -48,6 +48,26 @@ During fresh initialization, Fusion also installs the bundled `fusion` skill int
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `fn onboard`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn onboard
|
||||||
|
fn onboard --force
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|---|---|
|
||||||
|
| `--force` | Re-run onboarding even when `cliOnboardingCompletedAt` is already set. |
|
||||||
|
|
||||||
|
The command is safe to re-run and only updates the settings you confirm during
|
||||||
|
prompts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `fn update`
|
## `fn update`
|
||||||
|
|
||||||
Check for and install the latest `@runfusion/fusion` CLI release from npm.
|
Check for and install the latest `@runfusion/fusion` CLI release from npm.
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ async function loadCommandHandlers() {
|
|||||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
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 { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
|
||||||
const { runInit } = await import("./commands/init.js");
|
const { runInit } = await import("./commands/init.js");
|
||||||
|
const { runOnboard } = await import("./commands/onboard.js");
|
||||||
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
||||||
const { runAgentImport } = await import("./commands/agent-import.js");
|
const { runAgentImport } = await import("./commands/agent-import.js");
|
||||||
const { runAgentExport } = await import("./commands/agent-export.js");
|
const { runAgentExport } = await import("./commands/agent-export.js");
|
||||||
@@ -212,6 +213,7 @@ async function loadCommandHandlers() {
|
|||||||
runNodeHealth,
|
runNodeHealth,
|
||||||
runMeshStatus,
|
runMeshStatus,
|
||||||
runInit,
|
runInit,
|
||||||
|
runOnboard,
|
||||||
runAgentStop,
|
runAgentStop,
|
||||||
runAgentStart,
|
runAgentStart,
|
||||||
runAgentImport,
|
runAgentImport,
|
||||||
@@ -253,6 +255,7 @@ fn — AI-orchestrated task board
|
|||||||
Usage:
|
Usage:
|
||||||
fn Launch the dashboard (same as fn dashboard)
|
fn Launch the dashboard (same as fn dashboard)
|
||||||
fn init [opts] Initialize a new fn project (--name, --path, --git)
|
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 Start the board web UI
|
||||||
fn dashboard --paused Start with automation paused
|
fn dashboard --paused Start with automation paused
|
||||||
fn dashboard --dev Start web UI only (no AI engine)
|
fn dashboard --dev Start web UI only (no AI engine)
|
||||||
@@ -623,6 +626,7 @@ async function main() {
|
|||||||
runNodeHealth,
|
runNodeHealth,
|
||||||
runMeshStatus,
|
runMeshStatus,
|
||||||
runInit,
|
runInit,
|
||||||
|
runOnboard,
|
||||||
runAgentStop,
|
runAgentStop,
|
||||||
runAgentStart,
|
runAgentStart,
|
||||||
runAgentImport,
|
runAgentImport,
|
||||||
@@ -671,6 +675,12 @@ async function main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "onboard": {
|
||||||
|
const force = args.includes("--force");
|
||||||
|
await runOnboard({ force });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "dashboard": {
|
case "dashboard": {
|
||||||
// Initialize native module resolution for Bun binary before starting dashboard
|
// Initialize native module resolution for Bun binary before starting dashboard
|
||||||
// This sets up the paths so node-pty can find its native assets
|
// 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,
|
||||||
|
};
|
||||||
@@ -219,6 +219,17 @@ describe("GlobalSettingsStore", () => {
|
|||||||
expect(settings.themeMode).toBe("dark"); // preserved default
|
expect(settings.themeMode).toBe("dark"); // preserved default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips cliOnboardingCompletedAt without changing setupComplete", async () => {
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const marker = "2026-05-31T00:00:00.000Z";
|
||||||
|
await store.updateSettings({ cliOnboardingCompletedAt: marker });
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.cliOnboardingCompletedAt).toBe(marker);
|
||||||
|
expect(settings.setupComplete).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips testMode in global settings", async () => {
|
it("round-trips testMode in global settings", async () => {
|
||||||
await store.init();
|
await store.init();
|
||||||
|
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ export {
|
|||||||
|
|
||||||
export { CentralCore } from "./central-core.js";
|
export { CentralCore } from "./central-core.js";
|
||||||
export type { CentralCoreEvents } from "./central-core.js";
|
export type { CentralCoreEvents } from "./central-core.js";
|
||||||
export { CentralDatabase, createCentralDatabase } from "./central-db.js";
|
export { CentralDatabase, createCentralDatabase, getDefaultCentralDbPath } from "./central-db.js";
|
||||||
export { NodeConnection } from "./node-connection.js";
|
export { NodeConnection } from "./node-connection.js";
|
||||||
export { NodeDiscovery } from "./node-discovery.js";
|
export { NodeDiscovery } from "./node-discovery.js";
|
||||||
export { collectSystemMetrics } from "./system-metrics.js";
|
export { collectSystemMetrics } from "./system-metrics.js";
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
customProviders: [],
|
customProviders: [],
|
||||||
defaultProjectId: undefined,
|
defaultProjectId: undefined,
|
||||||
setupComplete: undefined,
|
setupComplete: undefined,
|
||||||
|
cliOnboardingCompletedAt: undefined,
|
||||||
favoriteProviders: undefined,
|
favoriteProviders: undefined,
|
||||||
favoriteModels: undefined,
|
favoriteModels: undefined,
|
||||||
openrouterModelSync: true,
|
openrouterModelSync: true,
|
||||||
|
|||||||
@@ -2542,6 +2542,10 @@ export interface GlobalSettings {
|
|||||||
* Set to true when the user completes the multi-project setup process.
|
* Set to true when the user completes the multi-project setup process.
|
||||||
* Default: false (undefined until setup is completed). */
|
* Default: false (undefined until setup is completed). */
|
||||||
setupComplete?: boolean;
|
setupComplete?: boolean;
|
||||||
|
/** ISO timestamp for completion of the `fn onboard` CLI wizard.
|
||||||
|
* Distinct from dashboard `setupComplete` first-run flow state.
|
||||||
|
* Undefined means CLI onboarding has not completed yet. */
|
||||||
|
cliOnboardingCompletedAt?: string;
|
||||||
/** List of favorite provider names. Favorite providers appear at the top of
|
/** List of favorite provider names. Favorite providers appear at the top of
|
||||||
* model selection dropdowns. Order is preserved - earlier entries appear higher. */
|
* model selection dropdowns. Order is preserved - earlier entries appear higher. */
|
||||||
favoriteProviders?: string[];
|
favoriteProviders?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user