From dbd89d8e3dbf80d249aee2d710e98eb834d5521e Mon Sep 17 00:00:00 2001 From: Jeremy <125112776+Frostbite1536@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:08:14 -0500 Subject: [PATCH] Claude/windows install compatibility tr di s (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Windows PowerShell 5.1 compatibility in installer Use $env:PROCESSOR_ARCHITECTURE for arch detection instead of RuntimeInformation::OSArchitecture which may not be loaded in every Windows PowerShell 5.1 session. Also fix null-reference when user PATH environment variable is empty. https://claude.ai/code/session_01VFiRDM2ZweyacXN5JneVoP * Fix executable resolution and tar extraction on Windows resolveExecutable() used `sh -lc "command -v ..."` which doesn't work on Windows (no sh). Now uses `cmd /c where` on win32. Also make tar workspace restoration tolerate symlink failures on Windows — .bin/ symlinks can't be created without Developer Mode, but the actual package directories are extracted fine. https://claude.ai/code/session_01VFiRDM2ZweyacXN5JneVoP * Broad Windows compatibility fixes across the codebase - runtime.ts: Use path.delimiter instead of hardcoded ":" for PATH construction — was completely broken on Windows - executables.ts: Add Windows fallback paths for Chrome, Edge, Brave, and Pandoc in Program Files; skip macOS-only paths on win32 - node-version.ts, check-node-version.mjs, bin/feynman.js: Show Windows-appropriate install instructions (irm | iex, nodejs.org) instead of nvm/curl on win32 - preview.ts: Support winget for pandoc auto-install on Windows, and apt on Linux (was macOS/brew only) - launch.ts: Catch unsupported signal errors on Windows - README.md: Add Windows PowerShell commands alongside macOS/Linux for all install instructions https://claude.ai/code/session_01VFiRDM2ZweyacXN5JneVoP * fix: complete windows bootstrap hardening --------- Co-authored-by: Claude Co-authored-by: Advait Paliwal --- README.md | 24 +++++++++++++ bin/feynman.js | 13 +++++--- scripts/check-node-version.mjs | 9 +++-- scripts/install/install.ps1 | 30 +++++++++++++---- scripts/patch-embedded-pi.mjs | 29 ++++++++++++---- src/pi/launch.ts | 6 +++- src/pi/runtime.ts | 13 +++++--- src/setup/preview.ts | 34 +++++++++++++++---- src/system/executables.ts | 61 +++++++++++++++++++++------------- src/system/node-version.ts | 9 +++-- tests/pi-runtime.test.ts | 40 ++++++++++++++++------ website/public/install.ps1 | 30 +++++++++++++---- 12 files changed, 226 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 539850a..081ce22 100644 --- a/README.md +++ b/README.md @@ -13,28 +13,52 @@ ### Installation +**macOS / Linux:** + ```bash curl -fsSL https://feynman.is/install | bash ``` +**Windows (PowerShell):** + +```powershell +irm https://feynman.is/install.ps1 | iex +``` + If you install via `pnpm` or `bun` instead of the standalone bundle, Feynman requires Node.js `20.18.1` or newer. ### Skills Only If you want just the research skills without the full terminal app: +**macOS / Linux:** + ```bash curl -fsSL https://feynman.is/install-skills | bash ``` +**Windows (PowerShell):** + +```powershell +irm https://feynman.is/install-skills.ps1 | iex +``` + That installs the skill library into `~/.codex/skills/feynman`. For a repo-local install instead: +**macOS / Linux:** + ```bash curl -fsSL https://feynman.is/install-skills | bash -s -- --repo ``` +**Windows (PowerShell):** + +```powershell +& ([scriptblock]::Create((irm https://feynman.is/install-skills.ps1))) -Scope Repo +``` + That installs into `.agents/skills/feynman` under the current repository. --- diff --git a/bin/feynman.js b/bin/feynman.js index 3d77cca..9c9111f 100755 --- a/bin/feynman.js +++ b/bin/feynman.js @@ -17,10 +17,15 @@ function compareNodeVersions(left, right) { } if (compareNodeVersions(parseNodeVersion(process.versions.node), parseNodeVersion(MIN_NODE_VERSION)) < 0) { + const isWindows = process.platform === "win32"; console.error(`feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${process.versions.node}).`); - console.error("Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:"); - console.error("curl -fsSL https://feynman.is/install | bash"); + 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(isWindows + ? "irm https://feynman.is/install.ps1 | iex" + : "curl -fsSL https://feynman.is/install | bash"); process.exit(1); } -await import("../scripts/patch-embedded-pi.mjs"); -await import("../dist/index.js"); +await import(new URL("../scripts/patch-embedded-pi.mjs", import.meta.url).href); +await import(new URL("../dist/index.js", import.meta.url).href); diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs index 8c8cf57..8b4170c 100644 --- a/scripts/check-node-version.mjs +++ b/scripts/check-node-version.mjs @@ -20,10 +20,15 @@ function isSupportedNodeVersion(version = process.versions.node) { } function getUnsupportedNodeVersionLines(version = process.versions.node) { + const isWindows = process.platform === "win32"; return [ `feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${version}).`, - "Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:", - "curl -fsSL https://feynman.is/install | bash", + 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:", + isWindows + ? "irm https://feynman.is/install.ps1 | iex" + : "curl -fsSL https://feynman.is/install | bash", ]; } diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index cd06e40..c75b672 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -73,12 +73,26 @@ function Resolve-ReleaseMetadata { } function Get-ArchSuffix { - $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - switch ($arch.ToString()) { - "X64" { return "x64" } - "Arm64" { return "arm64" } - default { throw "Unsupported architecture: $arch" } + # Prefer PROCESSOR_ARCHITECTURE which is always available on Windows. + # RuntimeInformation::OSArchitecture requires .NET 4.7.1+ and may not + # be loaded in every Windows PowerShell 5.1 session. + $envArch = $env:PROCESSOR_ARCHITECTURE + if ($envArch) { + switch ($envArch) { + "AMD64" { return "x64" } + "ARM64" { return "arm64" } + } } + + try { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($arch.ToString()) { + "X64" { return "x64" } + "Arm64" { return "arm64" } + } + } catch {} + + throw "Unsupported architecture: $envArch" } $archSuffix = Get-ArchSuffix @@ -134,7 +148,11 @@ Workarounds: "@ | Set-Content -Path $shimPath -Encoding ASCII $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User") - if (-not $currentUserPath.Split(';').Contains($installBinDir)) { + $alreadyOnPath = $false + if ($currentUserPath) { + $alreadyOnPath = $currentUserPath.Split(';') -contains $installBinDir + } + if (-not $alreadyOnPath) { $updatedPath = if ([string]::IsNullOrWhiteSpace($currentUserPath)) { $installBinDir } else { diff --git a/scripts/patch-embedded-pi.mjs b/scripts/patch-embedded-pi.mjs index 74afae5..22eb29a 100644 --- a/scripts/patch-embedded-pi.mjs +++ b/scripts/patch-embedded-pi.mjs @@ -139,12 +139,18 @@ function restorePackagedWorkspace(packageSpecs) { timeout: 300000, }); + // On Windows, tar may exit non-zero due to symlink creation failures in + // .bin/ directories. These are non-fatal — check whether the actual + // package directories were extracted successfully. + const packagesPresent = packageSpecs.every((spec) => existsSync(resolve(workspaceRoot, parsePackageName(spec)))); + if (packagesPresent) return true; + if (result.status !== 0) { if (result.stderr?.length) process.stderr.write(result.stderr); return false; } - return packageSpecs.every((spec) => existsSync(resolve(workspaceRoot, parsePackageName(spec)))); + return false; } function refreshPackagedWorkspace(packageSpecs) { @@ -156,12 +162,18 @@ function resolveExecutable(name, fallbackPaths = []) { if (existsSync(candidate)) return candidate; } - const result = spawnSync("sh", ["-lc", `command -v ${name}`], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); + const isWindows = process.platform === "win32"; + const result = isWindows + ? spawnSync("cmd", ["/c", `where ${name}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + : spawnSync("sh", ["-lc", `command -v ${name}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); if (result.status === 0) { - const resolved = result.stdout.trim(); + const resolved = result.stdout.trim().split(/\r?\n/)[0]; if (resolved) return resolved; } return null; @@ -541,6 +553,11 @@ if (alphaHubAuthPath && existsSync(alphaHubAuthPath)) { if (source.includes(oldError)) { source = source.replace(oldError, newError); } + const brokenWinOpen = "else if (plat === 'win32') execSync(`start \"${url}\"`);"; + const fixedWinOpen = "else if (plat === 'win32') execSync(`cmd /c start \"\" \"${url}\"`);"; + if (source.includes(brokenWinOpen)) { + source = source.replace(brokenWinOpen, fixedWinOpen); + } writeFileSync(alphaHubAuthPath, source, "utf8"); } diff --git a/src/pi/launch.ts b/src/pi/launch.ts index e2c35cf..0d8bdc1 100644 --- a/src/pi/launch.ts +++ b/src/pi/launch.ts @@ -29,7 +29,11 @@ export async function launchPiChat(options: PiRuntimeOptions): Promise { child.on("error", reject); child.on("exit", (code, signal) => { if (signal) { - process.kill(process.pid, signal); + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = 1; + } return; } process.exitCode = code ?? 0; diff --git a/src/pi/runtime.ts b/src/pi/runtime.ts index 9a85aa3..9208854 100644 --- a/src/pi/runtime.ts +++ b/src/pi/runtime.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { delimiter, dirname, resolve } from "node:path"; import { BROWSER_FALLBACK_PATHS, @@ -83,11 +83,11 @@ export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv { const currentPath = process.env.PATH ?? ""; const binEntries = [paths.nodeModulesBinPath, resolve(paths.piWorkspaceNodeModulesPath, ".bin"), feynmanNpmBinPath]; - const binPath = binEntries.join(":"); + const binPath = binEntries.join(delimiter); return { ...process.env, - PATH: `${binPath}:${currentPath}`, + PATH: `${binPath}${delimiter}${currentPath}`, FEYNMAN_VERSION: options.feynmanVersion, FEYNMAN_SESSION_DIR: options.sessionDir, FEYNMAN_MEMORY_DIR: resolve(dirname(options.feynmanAgentDir), "memory"), @@ -100,7 +100,10 @@ export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv { MERMAID_CLI_PATH: process.env.MERMAID_CLI_PATH ?? resolveExecutable("mmdc", MERMAID_FALLBACK_PATHS), PUPPETEER_EXECUTABLE_PATH: process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS), - NPM_CONFIG_PREFIX: process.env.NPM_CONFIG_PREFIX ?? feynmanNpmPrefixPath, - npm_config_prefix: process.env.npm_config_prefix ?? feynmanNpmPrefixPath, + // Always pin npm's global prefix to the Feynman workspace. npm injects + // lowercase config vars into child processes, which would otherwise leak + // the caller's global prefix into Pi. + NPM_CONFIG_PREFIX: feynmanNpmPrefixPath, + npm_config_prefix: feynmanNpmPrefixPath, }; } diff --git a/src/setup/preview.ts b/src/setup/preview.ts index df06bfe..788d9bd 100644 --- a/src/setup/preview.ts +++ b/src/setup/preview.ts @@ -13,13 +13,35 @@ export function setupPreviewDependencies(): PreviewSetupResult { return { status: "ready", message: `pandoc already installed at ${pandocPath}` }; } - const brewPath = resolveExecutable("brew", BREW_FALLBACK_PATHS); - if (process.platform === "darwin" && brewPath) { - const result = spawnSync(brewPath, ["install", "pandoc"], { stdio: "inherit" }); - if (result.status !== 0) { - throw new Error("Failed to install pandoc via Homebrew."); + if (process.platform === "darwin") { + const brewPath = resolveExecutable("brew", BREW_FALLBACK_PATHS); + if (brewPath) { + const result = spawnSync(brewPath, ["install", "pandoc"], { stdio: "inherit" }); + if (result.status !== 0) { + throw new Error("Failed to install pandoc via Homebrew."); + } + return { status: "installed", message: "Preview dependency installed: pandoc" }; + } + } + + if (process.platform === "win32") { + const wingetPath = resolveExecutable("winget"); + if (wingetPath) { + const result = spawnSync(wingetPath, ["install", "--id", "JohnMacFarlane.Pandoc", "-e"], { stdio: "inherit" }); + if (result.status === 0) { + return { status: "installed", message: "Preview dependency installed: pandoc (via winget)" }; + } + } + } + + if (process.platform === "linux") { + const aptPath = resolveExecutable("apt-get"); + if (aptPath) { + const result = spawnSync(aptPath, ["install", "-y", "pandoc"], { stdio: "inherit" }); + if (result.status === 0) { + return { status: "installed", message: "Preview dependency installed: pandoc (via apt)" }; + } } - return { status: "installed", message: "Preview dependency installed: pandoc" }; } return { diff --git a/src/system/executables.ts b/src/system/executables.ts index ad2d5a9..b0c3f7f 100644 --- a/src/system/executables.ts +++ b/src/system/executables.ts @@ -1,27 +1,36 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; -export const PANDOC_FALLBACK_PATHS = [ - "/opt/homebrew/bin/pandoc", - "/usr/local/bin/pandoc", -]; +const isWindows = process.platform === "win32"; +const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; +const localAppData = process.env.LOCALAPPDATA ?? ""; -export const BREW_FALLBACK_PATHS = [ - "/opt/homebrew/bin/brew", - "/usr/local/bin/brew", -]; +export const PANDOC_FALLBACK_PATHS = isWindows + ? [`${programFiles}\\Pandoc\\pandoc.exe`] + : ["/opt/homebrew/bin/pandoc", "/usr/local/bin/pandoc"]; -export const BROWSER_FALLBACK_PATHS = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", -]; +export const BREW_FALLBACK_PATHS = isWindows + ? [] + : ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]; -export const MERMAID_FALLBACK_PATHS = [ - "/opt/homebrew/bin/mmdc", - "/usr/local/bin/mmdc", -]; +export const BROWSER_FALLBACK_PATHS = isWindows + ? [ + `${programFiles}\\Google\\Chrome\\Application\\chrome.exe`, + `${programFiles} (x86)\\Google\\Chrome\\Application\\chrome.exe`, + `${localAppData}\\Google\\Chrome\\Application\\chrome.exe`, + `${programFiles}\\Microsoft\\Edge\\Application\\msedge.exe`, + `${programFiles}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`, + ] + : [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ]; + +export const MERMAID_FALLBACK_PATHS = isWindows + ? [] + : ["/opt/homebrew/bin/mmdc", "/usr/local/bin/mmdc"]; export function resolveExecutable(name: string, fallbackPaths: string[] = []): string | undefined { for (const candidate of fallbackPaths) { @@ -30,13 +39,19 @@ export function resolveExecutable(name: string, fallbackPaths: string[] = []): s } } - const result = spawnSync("sh", ["-lc", `command -v ${name}`], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); + const isWindows = process.platform === "win32"; + const result = isWindows + ? spawnSync("cmd", ["/c", `where ${name}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + : spawnSync("sh", ["-lc", `command -v ${name}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); if (result.status === 0) { - const resolved = result.stdout.trim(); + const resolved = result.stdout.trim().split(/\r?\n/)[0]; if (resolved) { return resolved; } diff --git a/src/system/node-version.ts b/src/system/node-version.ts index 6842aba..4bd7bb8 100644 --- a/src/system/node-version.ts +++ b/src/system/node-version.ts @@ -26,10 +26,15 @@ export function isSupportedNodeVersion(version = process.versions.node): boolean } export function getUnsupportedNodeVersionLines(version = process.versions.node): string[] { + const isWindows = process.platform === "win32"; return [ `feynman requires Node.js ${MIN_NODE_VERSION} or later (detected ${version}).`, - "Switch to Node 20 with `nvm install 20 && nvm use 20`, or use the standalone installer:", - "curl -fsSL https://feynman.is/install | bash", + 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:", + isWindows + ? "irm https://feynman.is/install.ps1 | iex" + : "curl -fsSL https://feynman.is/install | bash", ]; } diff --git a/tests/pi-runtime.test.ts b/tests/pi-runtime.test.ts index b388d98..1bbca63 100644 --- a/tests/pi-runtime.test.ts +++ b/tests/pi-runtime.test.ts @@ -30,6 +30,11 @@ test("buildPiArgs includes configured runtime paths and prompt", () => { }); test("buildPiEnv wires Feynman paths into the Pi environment", () => { + const previousUppercasePrefix = process.env.NPM_CONFIG_PREFIX; + const previousLowercasePrefix = process.env.npm_config_prefix; + process.env.NPM_CONFIG_PREFIX = "/tmp/global-prefix"; + process.env.npm_config_prefix = "/tmp/global-prefix-lower"; + const env = buildPiEnv({ appRoot: "/repo/feynman", workingDir: "/workspace", @@ -38,17 +43,30 @@ test("buildPiEnv wires Feynman paths into the Pi environment", () => { feynmanVersion: "0.1.5", }); - assert.equal(env.FEYNMAN_SESSION_DIR, "/sessions"); - assert.equal(env.FEYNMAN_BIN_PATH, "/repo/feynman/bin/feynman.js"); - assert.equal(env.FEYNMAN_MEMORY_DIR, "/home/.feynman/memory"); - assert.equal(env.FEYNMAN_NPM_PREFIX, "/home/.feynman/npm-global"); - assert.equal(env.NPM_CONFIG_PREFIX, "/home/.feynman/npm-global"); - assert.equal(env.npm_config_prefix, "/home/.feynman/npm-global"); - assert.ok( - env.PATH?.startsWith( - "/repo/feynman/node_modules/.bin:/repo/feynman/.feynman/npm/node_modules/.bin:/home/.feynman/npm-global/bin:", - ), - ); + try { + assert.equal(env.FEYNMAN_SESSION_DIR, "/sessions"); + assert.equal(env.FEYNMAN_BIN_PATH, "/repo/feynman/bin/feynman.js"); + assert.equal(env.FEYNMAN_MEMORY_DIR, "/home/.feynman/memory"); + assert.equal(env.FEYNMAN_NPM_PREFIX, "/home/.feynman/npm-global"); + assert.equal(env.NPM_CONFIG_PREFIX, "/home/.feynman/npm-global"); + assert.equal(env.npm_config_prefix, "/home/.feynman/npm-global"); + assert.ok( + env.PATH?.startsWith( + "/repo/feynman/node_modules/.bin:/repo/feynman/.feynman/npm/node_modules/.bin:/home/.feynman/npm-global/bin:", + ), + ); + } finally { + if (previousUppercasePrefix === undefined) { + delete process.env.NPM_CONFIG_PREFIX; + } else { + process.env.NPM_CONFIG_PREFIX = previousUppercasePrefix; + } + if (previousLowercasePrefix === undefined) { + delete process.env.npm_config_prefix; + } else { + process.env.npm_config_prefix = previousLowercasePrefix; + } + } }); test("resolvePiPaths includes the Promise.withResolvers polyfill path", () => { diff --git a/website/public/install.ps1 b/website/public/install.ps1 index cd06e40..c75b672 100644 --- a/website/public/install.ps1 +++ b/website/public/install.ps1 @@ -73,12 +73,26 @@ function Resolve-ReleaseMetadata { } function Get-ArchSuffix { - $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - switch ($arch.ToString()) { - "X64" { return "x64" } - "Arm64" { return "arm64" } - default { throw "Unsupported architecture: $arch" } + # Prefer PROCESSOR_ARCHITECTURE which is always available on Windows. + # RuntimeInformation::OSArchitecture requires .NET 4.7.1+ and may not + # be loaded in every Windows PowerShell 5.1 session. + $envArch = $env:PROCESSOR_ARCHITECTURE + if ($envArch) { + switch ($envArch) { + "AMD64" { return "x64" } + "ARM64" { return "arm64" } + } } + + try { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($arch.ToString()) { + "X64" { return "x64" } + "Arm64" { return "arm64" } + } + } catch {} + + throw "Unsupported architecture: $envArch" } $archSuffix = Get-ArchSuffix @@ -134,7 +148,11 @@ Workarounds: "@ | Set-Content -Path $shimPath -Encoding ASCII $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User") - if (-not $currentUserPath.Split(';').Contains($installBinDir)) { + $alreadyOnPath = $false + if ($currentUserPath) { + $alreadyOnPath = $currentUserPath.Split(';') -contains $installBinDir + } + if (-not $alreadyOnPath) { $updatedPath = if ([string]::IsNullOrWhiteSpace($currentUserPath)) { $installBinDir } else {