From dd3c07633ba1574b58cb972e994513b06069c1ca Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Wed, 15 Apr 2026 13:46:12 -0700 Subject: [PATCH] Fix Feynman onboarding and local install reliability --- .nvmrc | 2 +- CONTRIBUTING.md | 2 +- bin/feynman.js | 15 ++- scripts/check-node-version.mjs | 16 ++- scripts/patch-embedded-pi.mjs | 177 ++++++++++++++++++++++++++++++--- src/cli.ts | 124 +++++++++++++++-------- src/model/commands.ts | 67 ++++++++----- src/setup/prompts.ts | 144 +++++++++++++++++++++++---- src/system/node-version.ts | 17 +++- tests/model-harness.test.ts | 57 ++++++++++- tests/node-version.test.ts | 14 ++- 11 files changed, 512 insertions(+), 123 deletions(-) diff --git a/.nvmrc b/.nvmrc index 5bd6811..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.19.0 +22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ddbea9..547f5ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ If you need to change how bundled subagents behave, edit `.feynman/agents/*.md`. ## Before You Open a PR 1. Start from the latest `main`. -2. Use Node.js `20.19.0` or newer. The repo expects `.nvmrc`, `package.json` engines, `website/package.json` engines, and the runtime version guard to stay aligned. +2. Use Node.js `22.x` for local development. The supported runtime range is Node.js `20.19.0` through `24.x`; `.nvmrc` pins the preferred local version while `package.json`, `website/package.json`, and the runtime version guard define the broader supported range. 3. Install dependencies from the repo root: ```bash diff --git a/bin/feynman.js b/bin/feynman.js index 085449b..e8d82ac 100755 --- a/bin/feynman.js +++ b/bin/feynman.js @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; const MIN_NODE_VERSION = "20.19.0"; +const MAX_NODE_MAJOR = 24; +const PREFERRED_NODE_MAJOR = 22; function parseNodeVersion(version) { const [major = "0", minor = "0", patch = "0"] = version.replace(/^v/, "").split("."); @@ -19,12 +21,15 @@ function compareNodeVersions(left, right) { return left.patch - right.patch; } -if (compareNodeVersions(parseNodeVersion(process.versions.node), parseNodeVersion(MIN_NODE_VERSION)) < 0) { +const parsedNodeVersion = parseNodeVersion(process.versions.node); +if (compareNodeVersions(parsedNodeVersion, parseNodeVersion(MIN_NODE_VERSION)) < 0 || parsedNodeVersion.major > MAX_NODE_MAJOR) { const isWindows = process.platform === "win32"; - console.error(`feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${process.versions.node}).`); - console.error(isWindows - ? "Install a newer Node.js from https://nodejs.org, or use the standalone installer:" - : "Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:"); + console.error(`feynman supports Node.js ${MIN_NODE_VERSION} through ${MAX_NODE_MAJOR}.x (detected ${process.versions.node}).`); + console.error(parsedNodeVersion.major > MAX_NODE_MAJOR + ? "This newer Node release is not supported yet because native Pi packages may fail to build." + : isWindows + ? "Install a supported Node.js release from https://nodejs.org, or use the standalone installer:" + : `Switch to a supported Node release with \`nvm install ${PREFERRED_NODE_MAJOR} && nvm use ${PREFERRED_NODE_MAJOR}\`, or use the standalone installer:`); console.error(isWindows ? "irm https://feynman.is/install.ps1 | iex" : "curl -fsSL https://feynman.is/install | bash"); diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs index 771836d..4b060b9 100644 --- a/scripts/check-node-version.mjs +++ b/scripts/check-node-version.mjs @@ -1,4 +1,6 @@ const MIN_NODE_VERSION = "20.19.0"; +const MAX_NODE_MAJOR = 24; +const PREFERRED_NODE_MAJOR = 22; function parseNodeVersion(version) { const [major = "0", minor = "0", patch = "0"] = version.replace(/^v/, "").split("."); @@ -16,16 +18,20 @@ function compareNodeVersions(left, right) { } function isSupportedNodeVersion(version = process.versions.node) { - return compareNodeVersions(parseNodeVersion(version), parseNodeVersion(MIN_NODE_VERSION)) >= 0; + const parsed = parseNodeVersion(version); + return compareNodeVersions(parsed, parseNodeVersion(MIN_NODE_VERSION)) >= 0 && parsed.major <= MAX_NODE_MAJOR; } function getUnsupportedNodeVersionLines(version = process.versions.node) { const isWindows = process.platform === "win32"; + const parsed = parseNodeVersion(version); return [ - `feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${version}).`, - isWindows - ? "Install a newer Node.js from https://nodejs.org, or use the standalone installer:" - : "Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:", + `feynman supports Node.js ${MIN_NODE_VERSION} through ${MAX_NODE_MAJOR}.x (detected ${version}).`, + parsed.major > MAX_NODE_MAJOR + ? "This newer Node release is not supported yet because native Pi packages may fail to build." + : isWindows + ? "Install a supported Node.js release from https://nodejs.org, or use the standalone installer:" + : `Switch to a supported Node release with \`nvm install ${PREFERRED_NODE_MAJOR} && nvm use ${PREFERRED_NODE_MAJOR}\`, or use the standalone installer:`, isWindows ? "irm https://feynman.is/install.ps1 | iex" : "curl -fsSL https://feynman.is/install | bash", diff --git a/scripts/patch-embedded-pi.mjs b/scripts/patch-embedded-pi.mjs index 8d1b892..6e263ce 100644 --- a/scripts/patch-embedded-pi.mjs +++ b/scripts/patch-embedded-pi.mjs @@ -1,8 +1,8 @@ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; +import { delimiter, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { FEYNMAN_LOGO_HTML } from "../logo.mjs"; import { patchAlphaHubAuthSource } from "./lib/alpha-hub-auth-patch.mjs"; @@ -88,7 +88,30 @@ const piMemoryPath = resolve(workspaceRoot, "@samfp", "pi-memory", "src", "index const settingsPath = resolve(appRoot, ".feynman", "settings.json"); const workspaceDir = resolve(appRoot, ".feynman", "npm"); const workspacePackageJsonPath = resolve(workspaceDir, "package.json"); +const workspaceManifestPath = resolve(workspaceDir, ".runtime-manifest.json"); const workspaceArchivePath = resolve(appRoot, ".feynman", "runtime-workspace.tgz"); +const globalNodeModulesRoot = resolve(feynmanNpmPrefix, "lib", "node_modules"); +const PRUNE_VERSION = 3; +const NATIVE_PACKAGE_SPECS = new Set([ + "@kaiserlich-dev/pi-session-search", + "@samfp/pi-memory", +]); +const FILTERED_INSTALL_OUTPUT_PATTERNS = [ + /npm warn deprecated node-domexception@1\.0\.0/i, + /npm notice/i, + /^(added|removed|changed) \d+ packages?( in .+)?$/i, + /^\d+ packages are looking for funding$/i, + /^run `npm fund` for details$/i, +]; + +function arraysMatch(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function supportsNativePackageSources(version = process.versions.node) { + const [major = "0"] = version.replace(/^v/, "").split("."); + return (Number.parseInt(major, 10) || 0) <= 24; +} function createInstallCommand(packageManager, packageSpecs) { switch (packageManager) { @@ -100,6 +123,7 @@ function createInstallCommand(packageManager, packageSpecs) { "--prefer-offline", "--no-audit", "--no-fund", + "--legacy-peer-deps", "--loglevel", "error", ...packageSpecs, @@ -142,12 +166,24 @@ function installWorkspacePackages(packageSpecs) { const result = spawnSync(packageManager, createInstallCommand(packageManager, packageSpecs), { cwd: workspaceDir, - stdio: ["ignore", "ignore", "pipe"], + stdio: ["ignore", "pipe", "pipe"], timeout: 300000, + env: { + ...process.env, + PATH: getPathWithCurrentNode(process.env.PATH), + }, }); + for (const stream of [result.stdout, result.stderr]) { + if (!stream?.length) continue; + for (const line of stream.toString().split(/\r?\n/)) { + if (!line.trim()) continue; + if (FILTERED_INSTALL_OUTPUT_PATTERNS.some((pattern) => pattern.test(line.trim()))) continue; + process.stderr.write(`${line}\n`); + } + } + if (result.status !== 0) { - if (result.stderr?.length) process.stderr.write(result.stderr); process.stderr.write(`[feynman] ${packageManager} failed while setting up bundled packages.\n`); return false; } @@ -160,6 +196,102 @@ function parsePackageName(spec) { return match?.[1] ?? spec; } +function filterUnsupportedPackageSpecs(packageSpecs) { + if (supportsNativePackageSources()) return packageSpecs; + return packageSpecs.filter((spec) => !NATIVE_PACKAGE_SPECS.has(parsePackageName(spec))); +} + +function workspaceContainsPackages(packageSpecs) { + return packageSpecs.every((spec) => existsSync(resolve(workspaceRoot, parsePackageName(spec)))); +} + +function workspaceMatchesRuntime(packageSpecs) { + if (!existsSync(workspaceManifestPath)) return false; + + try { + const manifest = JSON.parse(readFileSync(workspaceManifestPath, "utf8")); + if (!Array.isArray(manifest.packageSpecs)) { + return false; + } + if (!arraysMatch(manifest.packageSpecs, packageSpecs)) { + if (!(workspaceContainsPackages(packageSpecs) && packageSpecs.every((spec) => manifest.packageSpecs.includes(spec)))) { + return false; + } + } + if (!supportsNativePackageSources() && workspaceContainsPackages(packageSpecs)) { + return true; + } + if ( + manifest.nodeAbi !== process.versions.modules || + manifest.platform !== process.platform || + manifest.arch !== process.arch || + manifest.pruneVersion !== PRUNE_VERSION + ) { + return false; + } + + return packageSpecs.every((spec) => existsSync(resolve(workspaceRoot, parsePackageName(spec)))); + } catch { + return false; + } +} + +function writeWorkspaceManifest(packageSpecs) { + writeFileSync( + workspaceManifestPath, + JSON.stringify( + { + packageSpecs, + generatedAt: new Date().toISOString(), + nodeAbi: process.versions.modules, + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + pruneVersion: PRUNE_VERSION, + }, + null, + 2, + ) + "\n", + "utf8", + ); +} + +function ensureParentDir(path) { + mkdirSync(dirname(path), { recursive: true }); +} + +function linkPointsTo(linkPath, targetPath) { + try { + if (!lstatSync(linkPath).isSymbolicLink()) return false; + return resolve(dirname(linkPath), readlinkSync(linkPath)) === targetPath; + } catch { + return false; + } +} + +function ensureBundledPackageLinks(packageSpecs) { + if (!workspaceMatchesRuntime(packageSpecs)) return; + + for (const spec of packageSpecs) { + const packageName = parsePackageName(spec); + const sourcePath = resolve(workspaceRoot, packageName); + const targetPath = resolve(globalNodeModulesRoot, packageName); + if (!existsSync(sourcePath)) continue; + if (linkPointsTo(targetPath, sourcePath)) continue; + try { + if (lstatSync(targetPath).isSymbolicLink()) { + rmSync(targetPath, { force: true }); + } + } catch {} + if (existsSync(targetPath)) continue; + + ensureParentDir(targetPath); + try { + symlinkSync(sourcePath, targetPath, process.platform === "win32" ? "junction" : "dir"); + } catch {} + } +} + function restorePackagedWorkspace(packageSpecs) { if (!existsSync(workspaceArchivePath)) return false; @@ -185,24 +317,26 @@ function restorePackagedWorkspace(packageSpecs) { return false; } -function refreshPackagedWorkspace(packageSpecs) { - return installWorkspacePackages(packageSpecs); -} - function resolveExecutable(name, fallbackPaths = []) { for (const candidate of fallbackPaths) { if (existsSync(candidate)) return candidate; } const isWindows = process.platform === "win32"; + const env = { + ...process.env, + PATH: process.env.PATH ?? "", + }; const result = isWindows ? spawnSync("cmd", ["/c", `where ${name}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + env, }) - : spawnSync("sh", ["-lc", `command -v ${name}`], { + : spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + env, }); if (result.status === 0) { const resolved = result.stdout.trim().split(/\r?\n/)[0]; @@ -211,6 +345,12 @@ function resolveExecutable(name, fallbackPaths = []) { return null; } +function getPathWithCurrentNode(pathValue = process.env.PATH ?? "") { + const nodeDir = dirname(process.execPath); + const parts = pathValue.split(delimiter).filter(Boolean); + return parts.includes(nodeDir) ? pathValue : `${nodeDir}${delimiter}${pathValue}`; +} + function ensurePackageWorkspace() { if (!existsSync(settingsPath)) return; @@ -220,10 +360,17 @@ function ensurePackageWorkspace() { .filter((v) => typeof v === "string" && v.startsWith("npm:")) .map((v) => v.slice(4)) : []; + const supportedPackageSpecs = filterUnsupportedPackageSpecs(packageSpecs); - if (packageSpecs.length === 0) return; - if (existsSync(resolve(workspaceRoot, parsePackageName(packageSpecs[0])))) return; - if (restorePackagedWorkspace(packageSpecs) && refreshPackagedWorkspace(packageSpecs)) return; + if (supportedPackageSpecs.length === 0) return; + if (workspaceMatchesRuntime(supportedPackageSpecs)) { + ensureBundledPackageLinks(supportedPackageSpecs); + return; + } + if (restorePackagedWorkspace(packageSpecs) && workspaceMatchesRuntime(supportedPackageSpecs)) { + ensureBundledPackageLinks(supportedPackageSpecs); + return; + } mkdirSync(workspaceDir, { recursive: true }); writeFileSync( @@ -240,7 +387,7 @@ function ensurePackageWorkspace() { process.stderr.write(`\r${frames[frame++ % frames.length]} setting up feynman... ${elapsed}s`); }, 80); - const result = installWorkspacePackages(packageSpecs); + const result = installWorkspacePackages(supportedPackageSpecs); clearInterval(spinner); const elapsed = Math.round((Date.now() - start) / 1000); @@ -248,7 +395,9 @@ function ensurePackageWorkspace() { if (!result) { process.stderr.write(`\r✗ setup failed (${elapsed}s)\n`); } else { - process.stderr.write(`\r✓ feynman ready (${elapsed}s)\n`); + process.stderr.write("\r\x1b[2K"); + writeWorkspaceManifest(supportedPackageSpecs); + ensureBundledPackageLinks(supportedPackageSpecs); } } diff --git a/src/cli.ts b/src/cli.ts index edbfc74..bbe3917 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import "dotenv/config"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { parseArgs } from "node:util"; import { fileURLToPath } from "node:url"; @@ -11,11 +11,13 @@ import { login as loginAlpha, logout as logoutAlpha, } from "@companion-ai/alpha-hub/lib"; -import { DefaultPackageManager, SettingsManager } from "@mariozechner/pi-coding-agent"; +import { SettingsManager } from "@mariozechner/pi-coding-agent"; import { syncBundledAssets } from "./bootstrap/sync.js"; import { ensureFeynmanHome, getDefaultSessionDir, getFeynmanAgentDir, getFeynmanHome } from "./config/paths.js"; import { launchPiChat } from "./pi/launch.js"; +import { installPackageSources, updateConfiguredPackages } from "./pi/package-ops.js"; +import { MAX_NATIVE_PACKAGE_NODE_MAJOR } from "./pi/package-presets.js"; import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js"; import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js"; import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js"; @@ -28,6 +30,7 @@ import { printModelList, setDefaultModelSpec, } from "./model/commands.js"; +import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "./model/catalog.js"; import { clearSearchConfig, printSearchStatus, setSearchProvider } from "./search/commands.js"; import type { PiWebSearchProvider } from "./pi/web-access.js"; import { runDoctor, runStatus } from "./setup/doctor.js"; @@ -180,27 +183,30 @@ async function handleModelCommand(subcommand: string | undefined, args: string[] } async function handleUpdateCommand(workingDir: string, feynmanAgentDir: string, source?: string): Promise { - applyFeynmanPackageManagerEnv(feynmanAgentDir); - const settingsManager = SettingsManager.create(workingDir, feynmanAgentDir); - const packageManager = new DefaultPackageManager({ - cwd: workingDir, - agentDir: feynmanAgentDir, - settingsManager, - }); - - packageManager.setProgressCallback((event) => { - if (event.type === "start") { - console.log(`Updating ${event.source}...`); - } else if (event.type === "complete") { - console.log(`Updated ${event.source}`); - } else if (event.type === "error") { - console.error(`Failed to update ${event.source}: ${event.message ?? "unknown error"}`); + try { + const result = await updateConfiguredPackages(workingDir, feynmanAgentDir, source); + if (result.updated.length === 0) { + console.log("All packages up to date."); + return; } - }); - await packageManager.update(source); - await settingsManager.flush(); - console.log("All packages up to date."); + for (const updatedSource of result.updated) { + console.log(`Updated ${updatedSource}`); + } + for (const skippedSource of result.skipped) { + console.log(`Skipped ${skippedSource} on Node ${process.versions.node} (native packages are only supported through Node ${MAX_NATIVE_PACKAGE_NODE_MAJOR}.x).`); + } + console.log("All packages up to date."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("No supported package manager found")) { + console.log("No package manager is available for live package updates."); + console.log("If you installed the standalone app, rerun the installer to get newer bundled packages."); + return; + } + + throw error; + } } async function handlePackagesCommand(subcommand: string | undefined, args: string[], workingDir: string, feynmanAgentDir: string): Promise { @@ -244,30 +250,44 @@ async function handlePackagesCommand(subcommand: string | undefined, args: strin throw new Error(`Unknown package preset: ${target}`); } - const packageManager = new DefaultPackageManager({ - cwd: workingDir, - agentDir: feynmanAgentDir, - settingsManager, - }); - packageManager.setProgressCallback((event) => { - if (event.type === "start") { - console.log(`Installing ${event.source}...`); - } else if (event.type === "complete") { - console.log(`Installed ${event.source}`); - } else if (event.type === "error") { - console.error(`Failed to install ${event.source}: ${event.message ?? "unknown error"}`); - } - }); + const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const isStandaloneBundle = !existsSync(resolve(appRoot, ".feynman", "runtime-workspace.tgz")) && existsSync(resolve(appRoot, ".feynman", "npm")); + if (target === "generative-ui" && process.platform === "darwin" && isStandaloneBundle) { + console.log("The generative-ui preset is currently unavailable in the standalone macOS bundle."); + console.log("Its native glimpseui dependency fails to compile reliably in that environment."); + console.log("If you need generative-ui, install Feynman through npm instead of the standalone bundle."); + return; + } + const pendingSources = sources.filter((source) => !configuredSources.has(source)); for (const source of sources) { if (configuredSources.has(source)) { console.log(`${source} already installed`); - continue; } - await packageManager.install(source); } - await settingsManager.flush(); - console.log("Optional packages installed."); + + if (pendingSources.length === 0) { + console.log("Optional packages installed."); + return; + } + + try { + const result = await installPackageSources(workingDir, feynmanAgentDir, pendingSources, { persist: true }); + for (const skippedSource of result.skipped) { + console.log(`Skipped ${skippedSource} on Node ${process.versions.node} (native packages are only supported through Node ${MAX_NATIVE_PACKAGE_NODE_MAJOR}.x).`); + } + await settingsManager.flush(); + console.log("Optional packages installed."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("No supported package manager found")) { + console.log("No package manager is available for optional package installs."); + console.log("Install npm, pnpm, or bun, or rerun the standalone installer for bundled package updates."); + return; + } + + throw error; + } } function handleSearchCommand(subcommand: string | undefined, args: string[]): void { @@ -326,6 +346,24 @@ export function resolveInitialPrompt( return undefined; } +export function shouldRunInteractiveSetup( + explicitModelSpec: string | undefined, + currentModelSpec: string | undefined, + isInteractiveTerminal: boolean, + authPath: string, +): boolean { + if (explicitModelSpec || !isInteractiveTerminal) { + return false; + } + + const status = buildModelStatusSnapshotFromRecords( + getSupportedModelRecords(authPath), + getAvailableModelRecords(authPath), + currentModelSpec, + ); + return !status.currentValid; +} + export async function main(): Promise { const here = dirname(fileURLToPath(import.meta.url)); const appRoot = resolve(here, ".."); @@ -498,7 +536,13 @@ export async function main(): Promise { } } - if (!explicitModelSpec && !getCurrentModelSpec(feynmanSettingsPath) && process.stdin.isTTY && process.stdout.isTTY) { + const currentModelSpec = getCurrentModelSpec(feynmanSettingsPath); + if (shouldRunInteractiveSetup( + explicitModelSpec, + currentModelSpec, + Boolean(process.stdin.isTTY && process.stdout.isTTY), + feynmanAuthPath, + )) { await runSetup({ settingsPath: feynmanSettingsPath, bundledSettingsPath, diff --git a/src/model/commands.ts b/src/model/commands.ts index 42b6057..8816a35 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -4,7 +4,7 @@ import { exec as execCallback } from "node:child_process"; import { promisify } from "node:util"; import { readJson } from "../pi/settings.js"; -import { promptChoice, promptText } from "../setup/prompts.js"; +import { promptChoice, promptSelect, promptText, type PromptSelectOption } from "../setup/prompts.js"; import { openUrl } from "../system/open-url.js"; import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js"; import { @@ -55,13 +55,22 @@ async function selectOAuthProvider(authPath: string, action: "login" | "logout") return providers[0]; } - const choices = providers.map((provider) => `${provider.id} — ${provider.name ?? provider.id}`); - choices.push("Cancel"); - const selection = await promptChoice(`Choose an OAuth provider to ${action}:`, choices, 0); - if (selection >= providers.length) { + const selection = await promptSelect( + `Choose an OAuth provider to ${action}:`, + [ + ...providers.map((provider) => ({ + value: provider, + label: provider.name ?? provider.id, + hint: provider.id, + })), + { value: "cancel", label: "Cancel" }, + ], + providers[0], + ); + if (selection === "cancel") { return undefined; } - return providers[selection]; + return selection; } type ApiKeyProviderInfo = { @@ -71,10 +80,10 @@ type ApiKeyProviderInfo = { }; const API_KEY_PROVIDERS: ApiKeyProviderInfo[] = [ - { id: "__custom__", label: "Custom provider (baseUrl + API key)" }, { id: "openai", label: "OpenAI Platform API", envVar: "OPENAI_API_KEY" }, { id: "anthropic", label: "Anthropic API", envVar: "ANTHROPIC_API_KEY" }, { id: "google", label: "Google Gemini API", envVar: "GEMINI_API_KEY" }, + { id: "__custom__", label: "Custom provider (local/self-hosted/proxy)" }, { id: "amazon-bedrock", label: "Amazon Bedrock (AWS credential chain)" }, { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, { id: "zai", label: "Z.AI / GLM", envVar: "ZAI_API_KEY" }, @@ -118,15 +127,21 @@ export function resolveModelProviderForCommand( } async function selectApiKeyProvider(): Promise { - const choices = API_KEY_PROVIDERS.map( - (provider) => `${provider.id} — ${provider.label}${provider.envVar ? ` (${provider.envVar})` : ""}`, - ); - choices.push("Cancel"); - const selection = await promptChoice("Choose an API-key provider:", choices, 0); - if (selection >= API_KEY_PROVIDERS.length) { + const options: PromptSelectOption[] = API_KEY_PROVIDERS.map((provider) => ({ + value: provider, + label: provider.label, + hint: provider.id === "__custom__" + ? "Ollama, vLLM, LM Studio, proxies" + : provider.envVar ?? provider.id, + })); + options.push({ value: "cancel", label: "Cancel" }); + + const defaultProvider = API_KEY_PROVIDERS.find((provider) => provider.id === "openai") ?? API_KEY_PROVIDERS[0]; + const selection = await promptSelect("Choose an API-key provider:", options, defaultProvider); + if (selection === "cancel") { return undefined; } - return API_KEY_PROVIDERS[selection]; + return selection; } type CustomProviderSetup = { @@ -656,13 +671,17 @@ export function printModelList(settingsPath: string, authPath: string): void { export async function authenticateModelProvider(authPath: string, settingsPath?: string): Promise { const choices = [ - "API key (OpenAI, Anthropic, Google, custom provider, ...)", - "OAuth login (ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)", + "OAuth login (recommended: ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)", + "API key or custom provider (OpenAI, Anthropic, Google, local/self-hosted, ...)", "Cancel", ]; const selection = await promptChoice("How do you want to authenticate?", choices, 0); if (selection === 0) { + return loginModelProvider(authPath, undefined, settingsPath); + } + + if (selection === 1) { const configured = await configureApiKeyProvider(authPath); if (configured) { maybeSetRecommendedDefaultModel(settingsPath, authPath); @@ -670,10 +689,6 @@ export async function authenticateModelProvider(authPath: string, settingsPath?: return configured; } - if (selection === 1) { - return loginModelProvider(authPath, undefined, settingsPath); - } - printInfo("Authentication cancelled."); return false; } @@ -788,20 +803,20 @@ export async function runModelSetup(settingsPath: string, authPath: string): Pro while (status.availableModels.length === 0) { const choices = [ - "API key (OpenAI, Anthropic, ZAI, Kimi, MiniMax, ...)", - "OAuth login (ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)", + "OAuth login (recommended: ChatGPT Plus/Pro, Claude Pro/Max, Copilot, ...)", + "API key or custom provider (OpenAI, Anthropic, ZAI, Kimi, MiniMax, ...)", "Cancel", ]; const selection = await promptChoice("Choose how to configure model access:", choices, 0); if (selection === 0) { - const configured = await configureApiKeyProvider(authPath); - if (!configured) { + const loggedIn = await loginModelProvider(authPath, undefined, settingsPath); + if (!loggedIn) { status = collectModelStatus(settingsPath, authPath); continue; } } else if (selection === 1) { - const loggedIn = await loginModelProvider(authPath, undefined, settingsPath); - if (!loggedIn) { + const configured = await configureApiKeyProvider(authPath); + if (!configured) { status = collectModelStatus(settingsPath, authPath); continue; } diff --git a/src/setup/prompts.ts b/src/setup/prompts.ts index 46fdcc0..6c217bf 100644 --- a/src/setup/prompts.ts +++ b/src/setup/prompts.ts @@ -1,30 +1,130 @@ -import { stdin as input, stdout as output } from "node:process"; -import { createInterface } from "node:readline/promises"; +import { + confirm as clackConfirm, + intro as clackIntro, + isCancel, + multiselect as clackMultiselect, + outro as clackOutro, + select as clackSelect, + text as clackText, + type Option, +} from "@clack/prompts"; -export async function promptText(question: string, defaultValue = ""): Promise { - if (!input.isTTY || !output.isTTY) { +export class SetupCancelledError extends Error { + constructor(message = "setup cancelled") { + super(message); + this.name = "SetupCancelledError"; + } +} + +export type PromptSelectOption = { + value: T; + label: string; + hint?: string; +}; + +function ensureInteractiveTerminal(): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error("feynman setup requires an interactive terminal."); } - const rl = createInterface({ input, output }); - try { - const suffix = defaultValue ? ` [${defaultValue}]` : ""; - const value = (await rl.question(`${question}${suffix}: `)).trim(); - return value || defaultValue; - } finally { - rl.close(); +} + +function guardCancelled(value: T | symbol): T { + if (isCancel(value)) { + throw new SetupCancelledError(); } + + return value; +} + +export function isInteractiveTerminal(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +export async function promptIntro(title: string): Promise { + ensureInteractiveTerminal(); + clackIntro(title); +} + +export async function promptOutro(message: string): Promise { + ensureInteractiveTerminal(); + clackOutro(message); +} + +export async function promptText(question: string, defaultValue = "", placeholder?: string): Promise { + ensureInteractiveTerminal(); + + const value = guardCancelled( + await clackText({ + message: question, + initialValue: defaultValue || undefined, + placeholder: placeholder ?? (defaultValue || undefined), + }), + ); + + const normalized = String(value ?? "").trim(); + return normalized || defaultValue; +} + +export async function promptSelect( + question: string, + options: PromptSelectOption[], + initialValue?: T, +): Promise { + ensureInteractiveTerminal(); + + const selection = guardCancelled( + await clackSelect({ + message: question, + options: options.map((option) => ({ + value: option.value, + label: option.label, + hint: option.hint, + })) as Option[], + initialValue, + }), + ); + + return selection; } export async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise { - console.log(question); - for (const [index, choice] of choices.entries()) { - const marker = index === defaultIndex ? "*" : " "; - console.log(` ${marker} ${index + 1}. ${choice}`); - } - const answer = await promptText("Select", String(defaultIndex + 1)); - const parsed = Number(answer); - if (!Number.isFinite(parsed) || parsed < 1 || parsed > choices.length) { - return defaultIndex; - } - return parsed - 1; + const options = choices.map((choice, index) => ({ + value: index, + label: choice, + })); + return promptSelect(question, options, Math.max(0, Math.min(defaultIndex, choices.length - 1))); +} + +export async function promptConfirm(question: string, initialValue = true): Promise { + ensureInteractiveTerminal(); + + return guardCancelled( + await clackConfirm({ + message: question, + initialValue, + }), + ); +} + +export async function promptMultiSelect( + question: string, + options: PromptSelectOption[], + initialValues: T[] = [], +): Promise { + ensureInteractiveTerminal(); + + const selection = guardCancelled( + await clackMultiselect({ + message: question, + options: options.map((option) => ({ + value: option.value, + label: option.label, + hint: option.hint, + })) as Option[], + initialValues, + required: false, + }), + ); + + return selection; } diff --git a/src/system/node-version.ts b/src/system/node-version.ts index bf349af..22de2d7 100644 --- a/src/system/node-version.ts +++ b/src/system/node-version.ts @@ -1,4 +1,6 @@ export const MIN_NODE_VERSION = "20.19.0"; +export const MAX_NODE_MAJOR = 24; +export const PREFERRED_NODE_MAJOR = 22; type ParsedNodeVersion = { major: number; @@ -22,16 +24,21 @@ function compareNodeVersions(left: ParsedNodeVersion, right: ParsedNodeVersion): } export function isSupportedNodeVersion(version = process.versions.node): boolean { - return compareNodeVersions(parseNodeVersion(version), parseNodeVersion(MIN_NODE_VERSION)) >= 0; + const parsed = parseNodeVersion(version); + return compareNodeVersions(parsed, parseNodeVersion(MIN_NODE_VERSION)) >= 0 && parsed.major <= MAX_NODE_MAJOR; } export function getUnsupportedNodeVersionLines(version = process.versions.node): string[] { const isWindows = process.platform === "win32"; + const parsed = parseNodeVersion(version); + const rangeText = `Node.js ${MIN_NODE_VERSION} through ${MAX_NODE_MAJOR}.x`; return [ - `feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${version}).`, - isWindows - ? "Install a newer Node.js from https://nodejs.org, or use the standalone installer:" - : "Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:", + `feynman supports ${rangeText} (detected ${version}).`, + parsed.major > MAX_NODE_MAJOR + ? "This newer Node release is not supported yet because native Pi packages may fail to build." + : isWindows + ? "Install a supported Node.js release from https://nodejs.org, or use the standalone installer:" + : `Switch to a supported Node release with \`nvm install ${PREFERRED_NODE_MAJOR} && nvm use ${PREFERRED_NODE_MAJOR}\`, or use the standalone installer:`, isWindows ? "irm https://feynman.is/install.ps1 | iex" : "curl -fsSL https://feynman.is/install | bash", diff --git a/tests/model-harness.test.ts b/tests/model-harness.test.ts index 87050e3..4319185 100644 --- a/tests/model-harness.test.ts +++ b/tests/model-harness.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveInitialPrompt } from "../src/cli.js"; +import { resolveInitialPrompt, shouldRunInteractiveSetup } from "../src/cli.js"; import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel } from "../src/model/catalog.js"; import { resolveModelProviderForCommand, setDefaultModelSpec } from "../src/model/commands.js"; @@ -118,10 +118,63 @@ test("chooseRecommendedModel prefers MiniMax M2.7 over highspeed when that is th }); test("resolveInitialPrompt maps top-level research commands to Pi slash workflows", () => { - const workflows = new Set(["lit", "watch", "jobs", "deepresearch"]); + const workflows = new Set([ + "lit", + "watch", + "jobs", + "deepresearch", + "review", + "audit", + "replicate", + "compare", + "draft", + "autoresearch", + "summarize", + "log", + ]); assert.equal(resolveInitialPrompt("lit", ["tool-using", "agents"], undefined, workflows), "/lit tool-using agents"); assert.equal(resolveInitialPrompt("watch", ["openai"], undefined, workflows), "/watch openai"); assert.equal(resolveInitialPrompt("jobs", [], undefined, workflows), "/jobs"); + assert.equal(resolveInitialPrompt("deepresearch", ["scaling", "laws"], undefined, workflows), "/deepresearch scaling laws"); + assert.equal(resolveInitialPrompt("review", ["paper.md"], undefined, workflows), "/review paper.md"); + assert.equal(resolveInitialPrompt("audit", ["2401.12345"], undefined, workflows), "/audit 2401.12345"); + assert.equal(resolveInitialPrompt("replicate", ["chain-of-thought"], undefined, workflows), "/replicate chain-of-thought"); + assert.equal(resolveInitialPrompt("compare", ["tool", "use"], undefined, workflows), "/compare tool use"); + assert.equal(resolveInitialPrompt("draft", ["mechanistic", "interp"], undefined, workflows), "/draft mechanistic interp"); + assert.equal(resolveInitialPrompt("autoresearch", ["gsm8k"], undefined, workflows), "/autoresearch gsm8k"); + assert.equal(resolveInitialPrompt("summarize", ["README.md"], undefined, workflows), "/summarize README.md"); + assert.equal(resolveInitialPrompt("log", [], undefined, workflows), "/log"); assert.equal(resolveInitialPrompt("chat", ["hello"], undefined, workflows), "hello"); assert.equal(resolveInitialPrompt("unknown", ["topic"], undefined, workflows), "unknown topic"); }); + +test("shouldRunInteractiveSetup triggers on first run when no default model is configured", () => { + const authPath = createAuthPath({}); + + assert.equal(shouldRunInteractiveSetup(undefined, undefined, true, authPath), true); +}); + +test("shouldRunInteractiveSetup triggers when the configured default model is unavailable", () => { + const authPath = createAuthPath({ + openai: { type: "api_key", key: "openai-test-key" }, + }); + + assert.equal(shouldRunInteractiveSetup(undefined, "anthropic/claude-opus-4-6", true, authPath), true); +}); + +test("shouldRunInteractiveSetup skips onboarding when the configured default model is available", () => { + const authPath = createAuthPath({ + openai: { type: "api_key", key: "openai-test-key" }, + }); + + assert.equal(shouldRunInteractiveSetup(undefined, "openai/gpt-5.4", true, authPath), false); +}); + +test("shouldRunInteractiveSetup skips onboarding for explicit model overrides or non-interactive terminals", () => { + const authPath = createAuthPath({ + openai: { type: "api_key", key: "openai-test-key" }, + }); + + assert.equal(shouldRunInteractiveSetup("openai/gpt-5.4", undefined, true, authPath), false); + assert.equal(shouldRunInteractiveSetup(undefined, undefined, false, authPath), false); +}); diff --git a/tests/node-version.test.ts b/tests/node-version.test.ts index fca23eb..0538c2e 100644 --- a/tests/node-version.test.ts +++ b/tests/node-version.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + MAX_NODE_MAJOR, MIN_NODE_VERSION, ensureSupportedNodeVersion, getUnsupportedNodeVersionLines, @@ -12,6 +13,8 @@ test("isSupportedNodeVersion enforces the exact minimum floor", () => { assert.equal(isSupportedNodeVersion("20.19.0"), true); assert.equal(isSupportedNodeVersion("20.19.0"), true); assert.equal(isSupportedNodeVersion("21.0.0"), true); + assert.equal(isSupportedNodeVersion(`${MAX_NODE_MAJOR}.9.9`), true); + assert.equal(isSupportedNodeVersion(`${MAX_NODE_MAJOR + 1}.0.0`), false); assert.equal(isSupportedNodeVersion("20.18.1"), false); assert.equal(isSupportedNodeVersion("18.17.0"), false); }); @@ -22,7 +25,7 @@ test("ensureSupportedNodeVersion throws a guided upgrade message", () => { (error: unknown) => error instanceof Error && error.message.includes(`Node.js ${MIN_NODE_VERSION}`) && - error.message.includes("nvm install 20 && nvm use 20") && + error.message.includes("nvm install 22 && nvm use 22") && error.message.includes("https://feynman.is/install"), ); }); @@ -30,6 +33,13 @@ test("ensureSupportedNodeVersion throws a guided upgrade message", () => { test("unsupported version guidance reports the detected version", () => { const lines = getUnsupportedNodeVersionLines("18.17.0"); - assert.equal(lines[0], "feynman requires Node.js 20.19.0 or later (detected 18.17.0)."); + assert.equal(lines[0], `feynman supports Node.js ${MIN_NODE_VERSION} through ${MAX_NODE_MAJOR}.x (detected 18.17.0).`); assert.ok(lines.some((line) => line.includes("curl -fsSL https://feynman.is/install | bash"))); }); + +test("unsupported version guidance explains upper-bound failures", () => { + const lines = getUnsupportedNodeVersionLines("25.1.0"); + + assert.equal(lines[0], `feynman supports Node.js ${MIN_NODE_VERSION} through ${MAX_NODE_MAJOR}.x (detected 25.1.0).`); + assert.ok(lines.some((line) => line.includes("native Pi packages may fail to build"))); +});