Fix Feynman onboarding and local install reliability
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
src/cli.ts
124
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<void> {
|
||||
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<void> {
|
||||
@@ -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<void> {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = resolve(here, "..");
|
||||
@@ -498,7 +536,13 @@ export async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<OAuthProviderInfo | "cancel">(
|
||||
`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<ApiKeyProviderInfo | undefined> {
|
||||
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<ApiKeyProviderInfo | "cancel">[] = 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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
if (!input.isTTY || !output.isTTY) {
|
||||
export class SetupCancelledError extends Error {
|
||||
constructor(message = "setup cancelled") {
|
||||
super(message);
|
||||
this.name = "SetupCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptSelectOption<T = string> = {
|
||||
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<T>(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<void> {
|
||||
ensureInteractiveTerminal();
|
||||
clackIntro(title);
|
||||
}
|
||||
|
||||
export async function promptOutro(message: string): Promise<void> {
|
||||
ensureInteractiveTerminal();
|
||||
clackOutro(message);
|
||||
}
|
||||
|
||||
export async function promptText(question: string, defaultValue = "", placeholder?: string): Promise<string> {
|
||||
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<T>(
|
||||
question: string,
|
||||
options: PromptSelectOption<T>[],
|
||||
initialValue?: T,
|
||||
): Promise<T> {
|
||||
ensureInteractiveTerminal();
|
||||
|
||||
const selection = guardCancelled(
|
||||
await clackSelect({
|
||||
message: question,
|
||||
options: options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.hint,
|
||||
})) as Option<T>[],
|
||||
initialValue,
|
||||
}),
|
||||
);
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
export async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise<number> {
|
||||
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<boolean> {
|
||||
ensureInteractiveTerminal();
|
||||
|
||||
return guardCancelled(
|
||||
await clackConfirm({
|
||||
message: question,
|
||||
initialValue,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function promptMultiSelect<T>(
|
||||
question: string,
|
||||
options: PromptSelectOption<T>[],
|
||||
initialValues: T[] = [],
|
||||
): Promise<T[]> {
|
||||
ensureInteractiveTerminal();
|
||||
|
||||
const selection = guardCancelled(
|
||||
await clackMultiselect({
|
||||
message: question,
|
||||
options: options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.hint,
|
||||
})) as Option<T>[],
|
||||
initialValues,
|
||||
required: false,
|
||||
}),
|
||||
);
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user