diff --git a/README.md b/README.md index ae4cbe5d..6ce40dd3 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ gemini extensions install https://github.com/ix-infrastructure/ix-gemini-plugin ## Requirements - macOS, Linux, or Windows +- Node.js 20 or newer - Git installed - Docker (for full functionality) diff --git a/ix-cli/package.json b/ix-cli/package.json index 35b3bdf3..b4de1cab 100644 --- a/ix-cli/package.json +++ b/ix-cli/package.json @@ -12,6 +12,9 @@ "bin": { "ix": "dist/cli/main.js" }, + "engines": { + "node": ">=20.0.0" + }, "scripts": { "build": "node scripts/build-core-ingestion.mjs && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc", "test": "node scripts/build-core-ingestion.mjs && vitest run --exclude test/parser.test.ts && node test/parser.smoke.mjs", diff --git a/ix-cli/src/cli/commands/map.ts b/ix-cli/src/cli/commands/map.ts index bc2f648b..6dff0703 100644 --- a/ix-cli/src/cli/commands/map.ts +++ b/ix-cli/src/cli/commands/map.ts @@ -5,6 +5,7 @@ import { IxClient } from "../../client/api.js"; import { getEndpoint } from "../config.js"; import { roundFloat } from "../format.js"; import { bootstrap } from "../bootstrap.js"; +import { formatFetchError } from "../errors.js"; import { ingestFiles } from "./ingest.js"; export interface MapRegion { @@ -159,7 +160,7 @@ Examples: mapMode: true, }); } catch (err: any) { - console.error(chalk.red("Error:"), err.message ?? err); + console.error(chalk.red("Error:"), formatFetchError(err)); process.exitCode = 1; return; } @@ -183,7 +184,7 @@ Examples: result = await client.map({ full: opts.full }) as MapResult; } catch (err: any) { if (mapInterval) { clearInterval(mapInterval); process.stderr.write('\r' + ' '.repeat(60) + '\r'); } - console.error(chalk.red("Error:"), err.message ?? err); + console.error(chalk.red("Error:"), formatFetchError(err)); process.exitCode = 1; return; } diff --git a/ix-cli/src/cli/errors.ts b/ix-cli/src/cli/errors.ts index 5d73eddc..9889ce16 100644 --- a/ix-cli/src/cli/errors.ts +++ b/ix-cli/src/cli/errors.ts @@ -70,6 +70,39 @@ export class CliResolutionError extends Error { } } +/** + * Format an error for display, unwrapping fetch `TypeError: fetch failed` + * to expose the underlying transport cause (e.g. ECONNRESET, UND_ERR_SOCKET). + * + * Node's built-in fetch (undici) throws `TypeError('fetch failed')` on any + * transport-level failure and stashes the real error in `err.cause`. Without + * this unwrap, users see a bare "fetch failed" with no actionable detail — + * see the Node 18 EOL / undici 5.x transport-drop bug report. + */ +export function formatFetchError(err: unknown): string { + if (err === null || err === undefined) return String(err); + const e = err as { message?: unknown; cause?: unknown }; + const base = typeof e.message === "string" && e.message.length > 0 + ? e.message + : String(err); + + if (base.toLowerCase().includes("fetch failed") && e.cause) { + const cause = e.cause as { code?: unknown; message?: unknown }; + const parts: string[] = []; + if (typeof cause.code === "string" && cause.code.length > 0) { + parts.push(cause.code); + } + if (typeof cause.message === "string" && cause.message.length > 0) { + parts.push(cause.message); + } else if (parts.length === 0) { + parts.push(String(e.cause)); + } + return `${base} (${parts.join(": ")})`; + } + + return base; +} + export function renderCliError(err: unknown, debug = false): void { if (err instanceof CliUsageError || err instanceof CliResolutionError) { process.stderr.write(chalk.red(`Error: ${err.message}\n`)); diff --git a/ix-cli/src/cli/main.ts b/ix-cli/src/cli/main.ts index b8415758..56481f57 100644 --- a/ix-cli/src/cli/main.ts +++ b/ix-cli/src/cli/main.ts @@ -9,6 +9,22 @@ import { readFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; +// Runtime Node version guard. Runs before any command work so users on an +// unsupported Node get an actionable message instead of a cryptic "fetch +// failed" deep inside undici. +const MIN_NODE_MAJOR = 20; +{ + const current = process.versions.node; + const major = parseInt(current.split(".")[0] ?? "0", 10); + if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) { + process.stderr.write( + `Ix requires Node.js ${MIN_NODE_MAJOR} or newer. You are running v${current}.\n` + + `Install a supported version from https://nodejs.org/ and re-run.\n` + ); + process.exit(1); + } +} + let cliVersion = "0.0.0"; try { const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/requirements.txt b/requirements.txt index 5e5d8513..a341cd2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,11 +29,11 @@ git>=2 # STEP 1: Node.js (auto-installed/upgraded if missing or too old) # ============================================================================ -# Node.js >= 18 (installer targets Node 22 LTS) +# Node.js >= 20 (installer targets Node 22 LTS) # macOS: brew install node OR official .pkg installer # Linux: NodeSource apt/dnf/yum repo (setup_22.x) OR apk # Windows: manual from https://nodejs.org/ -node>=18 +node>=20 npm>=8 # bundled with Node.js # ============================================================================ diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh index e1a46adb..a1ab8f5c 100755 --- a/scripts/build-cli.sh +++ b/scripts/build-cli.sh @@ -20,13 +20,13 @@ CLI_DIR="$IX_DIR/ix-cli" check_prerequisites() { if ! command -v node &> /dev/null; then echo "Error: Node.js is not installed." - echo " Install: https://nodejs.org/ (v18+ required)" + echo " Install: https://nodejs.org/ (v20+ required)" exit 1 fi NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) - if [ "$NODE_VERSION" -lt 18 ]; then - echo "Error: Node.js 18+ required (found $(node -v))" + if [ "$NODE_VERSION" -lt 20 ]; then + echo "Error: Node.js 20+ required (found $(node -v))" exit 1 fi diff --git a/scripts/dev/setup.sh b/scripts/dev/setup.sh index 92d85113..4a9796d6 100755 --- a/scripts/dev/setup.sh +++ b/scripts/dev/setup.sh @@ -129,7 +129,7 @@ if [ "${#MISSING_DEPS[@]}" -gt 0 ]; then for dep in "${MISSING_DEPS[@]}"; do case "$dep" in docker) echo " docker: https://docs.docker.com/get-docker/" ;; - node) echo " node: https://nodejs.org (v18+ required)" ;; + node) echo " node: https://nodejs.org (v20+ required)" ;; esac done echo "" diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index bd1172e6..c964dab7 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -27,7 +27,7 @@ $IxBin = "$IxHome\bin" $ComposeDir = "$IxHome\backend" $HealthUrl = "http://localhost:8090/v1/health" $ArangoUrl = "http://localhost:8529/_api/version" -$NodeMinMajor = 18 +$NodeMinMajor = 20 # ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 1d90f2e1..f632e1f3 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -28,7 +28,7 @@ COMPOSE_DIR="$IX_HOME/backend" HEALTH_URL="http://localhost:8090/v1/health" ARANGO_URL="http://localhost:8529/_api/version" -NODE_MIN_MAJOR=18 +NODE_MIN_MAJOR=20 # -- Windows / POSIX docker compose wrapper --