diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..ec6c126 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,42 @@ +# The CLI is developed on macOS; this is the only place its Windows path runs. +# The first Windows user hit seven "Failed to install" lines because nothing +# here ever executed on Windows. +name: windows +on: + push: + pull_request: + workflow_dispatch: +jobs: + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - uses: actions/setup-python@v5 + with: { python-version: '3.12' } + - run: pip install pyyaml + - run: bun install + - name: Platform unit tests + run: bun test tests/windows.test.ts + - name: Real Windows checks (cmd.exe, where, npm shims) + shell: pwsh + run: | + Remove-Item Env:HOME -ErrorAction SilentlyContinue + bun scripts/windows-smoke.ts + - name: Built CLI detects installed tools (HOME unset, as on a user's machine) + shell: pwsh + run: | + bun run build + npm pack + npm install -g (Get-ChildItem datacore-one-cli-*.tgz).FullName + Remove-Item Env:HOME -ErrorAction SilentlyContinue + $r = datacore doctor --format json | ConvertFrom-Json + $r.dependencies | Format-Table name, installed, version + "home: $($r.home)" + foreach ($n in 'git','node','python') { + $d = $r.dependencies | Where-Object name -eq $n + if (-not $d.installed) { throw "$n reported missing on a runner that has it" } + } + if ($r.home -notmatch '^[A-Za-z]:\\') { throw "home is not an absolute Windows path: $($r.home)" } diff --git a/package.json b/package.json index 54d41d2..a9c6fe9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datacore-one/cli", - "version": "2.5.0", + "version": "2.5.1", "description": "CLI for setting up and managing Datacore installations", "type": "module", "main": "dist/index.js", diff --git a/scripts/windows-smoke.ts b/scripts/windows-smoke.ts new file mode 100644 index 0000000..7a01d45 --- /dev/null +++ b/scripts/windows-smoke.ts @@ -0,0 +1,49 @@ +/** + * Runs on a real Windows machine (CI: .github/workflows/windows.yml). + * + * tests/windows.test.ts proves the platform decisions; this proves they work + * against real cmd.exe, real `where`, and real npm shims — the three things + * that made every install fail on the first Windows machine. + */ +import { execFileSync, homeDir, runShell, which } from '../src/lib/exec' +import { resolveBinary } from '../src/lib/upgrade' +import { findPython } from '../src/lib/python' + +let failed = 0 +function check(name: string, ok: boolean, detail: unknown) { + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}: ${String(detail)}`) + if (!ok) failed++ +} + +const node = which('node') +check('which(node) finds node.exe', !!node && /node\.exe$/i.test(node), node) +const npm = which('npm') +check('which(npm) picks the .cmd shim, not the bash script', !!npm && /npm\.cmd$/i.test(npm), npm) +check('which(git) finds git', which('git') !== null, which('git')) +check('which() is null', which('definitely-not-a-command-xyz') === null, which('definitely-not-a-command-xyz')) + +let npmVersion = '' +try { npmVersion = String(execFileSync('npm', ['--version'], { encoding: 'utf-8', stdio: 'pipe' })).trim() } catch (e) { npmVersion = (e as Error).message } +check('execFileSync(npm) runs the .cmd shim', /^\d+\.\d+/.test(npmVersion), npmVersion) + +let shellOut = '' +try { shellOut = String(runShell('echo first && echo second', { encoding: 'utf-8', stdio: 'pipe' })) } catch (e) { shellOut = (e as Error).message } +check('runShell handles && through cmd.exe', shellOut.includes('first') && shellOut.includes('second'), JSON.stringify(shellOut)) + +check('resolveBinary(npm) resolves', resolveBinary('npm') !== null, resolveBinary('npm')) +check('homeDir() is absolute', /^[A-Za-z]:\\/.test(homeDir()), homeDir()) +check('findPython() finds an interpreter', findPython() !== null, findPython()) + +// The exact step that failed for the first Windows user: an install command +// run through the shell, then the installed binary found. CI only — it +// installs a global package. +if (process.env.CI) { + let installErr = '' + try { runShell('npm install -g @plur-ai/mcp', { stdio: 'inherit', timeout: 300000 }) } catch (e) { installErr = (e as Error).message } + check('runShell(npm install -g ...) succeeds', installErr === '', installErr || 'installed') + const plur = resolveBinary('plur-mcp') + check('the installed binary is found afterwards', plur !== null, plur) +} + +if (failed) { console.log(`\n${failed} check(s) failed`); process.exit(1) } +console.log('\nall Windows checks passed') diff --git a/src/index.ts b/src/index.ts index 19f1bab..81a4deb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { listModules, installModule, updateModules, removeModule } from './lib/m import { createSnapshot, saveSnapshot, loadSnapshot, diffSnapshot, restoreFromSnapshot, lockFileExists } from './lib/snapshot' import * as app from './lib/app' import { VERSION } from './version' +import { homeDir } from './lib/exec' @@ -717,7 +718,7 @@ async function handleResource( // Add task to inbox.org with :AI: tag const { existsSync, appendFileSync } = await import('fs') const { join } = await import('path') - const inboxPath = join(process.env.HOME || '~', 'Data', '0-personal', 'org', 'inbox.org') + const inboxPath = join(homeDir(), 'Data', '0-personal', 'org', 'inbox.org') if (!existsSync(inboxPath)) { throw new CLIError('ERR_NOT_FOUND', 'inbox.org not found. Run datacore init first.') diff --git a/src/lib/agent.ts b/src/lib/agent.ts index 579a8dd..c468ffb 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -5,7 +5,7 @@ * which spawns subagents with specific system prompts. */ -import { spawn } from 'child_process' +import { spawn, which } from './exec' import { existsSync } from 'fs' import { join } from 'path' import type { AgentInvocation, AgentResult } from '../types' @@ -14,13 +14,7 @@ import { dataDir } from './paths' const DATA_DIR = () => dataDir() function commandExists(cmd: string): boolean { - try { - const { execSync } = require('child_process') - execSync(`which ${cmd}`, { stdio: 'pipe' }) - return true - } catch { - return false - } + return which(cmd) !== null } export interface InvokeOptions { diff --git a/src/lib/background.ts b/src/lib/background.ts index 0dd6cb2..0cc4414 100644 --- a/src/lib/background.ts +++ b/src/lib/background.ts @@ -3,11 +3,11 @@ * Used by init (ingest) and potentially nightshift. */ -import { spawn } from 'child_process' +import { spawn, homeDir } from './exec' import { openSync, closeSync, mkdirSync } from 'fs' import { join } from 'path' -const DATA_DIR = join(process.env.HOME || '', 'Data') +const DATA_DIR = join(homeDir(), 'Data') const STATE_DIR = join(DATA_DIR, '.datacore', 'state') export interface BackgroundJob { diff --git a/src/lib/dependency.ts b/src/lib/dependency.ts index 783ce52..3a519ab 100644 --- a/src/lib/dependency.ts +++ b/src/lib/dependency.ts @@ -2,7 +2,9 @@ * Dependency checking utilities. */ -import { execSync, execFileSync } from 'child_process' +import { execSync } from 'child_process' +import { execFileSync, homeDir } from './exec' +import { resolveBinary } from './upgrade' import { existsSync, readFileSync } from 'fs' import { join, basename } from 'path' import type { DependencyCheck, DoctorResult, LedgerCheck } from '../types' @@ -12,12 +14,9 @@ import { classifyVerifyFailure, findPython, PYTHON_MIN_VERSION } from './python' import { listSpaces } from './space' function commandExists(cmd: string): boolean { - try { - execSync(`which ${cmd}`, { stdio: 'pipe' }) - return true - } catch { - return false - } + // Same lookup init uses, so doctor and the installer cannot disagree about + // whether a tool is there. + return resolveBinary(cmd) !== null } function getVersion(cmd: string, versionFlag = '--version'): string | undefined { @@ -223,7 +222,7 @@ function checkPlurMcp(platform: Platform): DependencyCheck { } export function checkCodePermissions(): { enableAll: boolean; mcpAllowed: boolean } { - const home = process.env.HOME || '' + const home = homeDir() const settingsPaths = [ join(home, 'Data', '.claude', 'settings.local.json'), join(home, 'Data', '.claude', 'settings.json'), @@ -252,7 +251,7 @@ export function checkCodePermissions(): { enableAll: boolean; mcpAllowed: boolea export function checkMcpConfig(): { claudeDesktop: boolean; claudeCode: boolean } { // Check Claude Desktop config - const home = process.env.HOME || '' + const home = homeDir() const desktopPaths = [ join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), // macOS join(home, '.config', 'claude', 'claude_desktop_config.json'), // Linux @@ -454,7 +453,7 @@ export function runDoctor(): DoctorResult { return { platform: `${platform} (${release})`, arch, - home: process.env.HOME || '~', + home: homeDir(), dataDir: resolveDataDir(), python: { path: py }, datacoreExists: datacore.exists, diff --git a/src/lib/exec.ts b/src/lib/exec.ts new file mode 100644 index 0000000..abaf32d --- /dev/null +++ b/src/lib/exec.ts @@ -0,0 +1,135 @@ +/** + * Process spawning and command lookup that work on native Windows. + * + * Every other module spawns through here. The first Windows install failed + * on all seven tools because each install ran via `/bin/bash`, which Windows + * does not have; lookups used `which`, also absent, so every tool looked + * missing; and npm's `.cmd` shims cannot be spawned without a shell (Node + * refuses them outright since CVE-2024-27980). On macOS and Linux everything + * here is a passthrough, so behaviour there is unchanged. + * + * The decisions are pure functions of the platform so they can be tested on + * the Mac this is developed on: tests/windows.test.ts. + */ + +import * as cp from 'child_process' +import type { ExecFileSyncOptions, SpawnOptions, ChildProcess } from 'child_process' +import { homedir } from 'os' +import { join, extname } from 'path' + +const IS_WINDOWS = process.platform === 'win32' + +/** + * The user's home directory. HOME is unset on native Windows, and + * `join(process.env.HOME || '', 'Data')` is then the RELATIVE path `Data` — + * the second brain would be created in whatever directory init ran from. + */ +export function homeDir(): string { + return process.env.HOME || process.env.USERPROFILE || homedir() +} + +/** How to run a command string that may contain pipes and `&&`. */ +export function shellInvocation(command: string, platform: NodeJS.Platform = process.platform): { file: string; args: string[] } { + if (platform === 'win32') { + // /d: skip AutoRun, /s: take the rest literally, /c: run and exit. + return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', command] } + } + return { file: '/bin/bash', args: ['-c', command] } +} + +export function whichInvocation(cmd: string, platform: NodeJS.Platform = process.platform): { file: string; args: string[] } { + return platform === 'win32' ? { file: 'where', args: [cmd] } : { file: 'which', args: [cmd] } +} + +/** + * `where npm` prints the extensionless bash script before npm.cmd; only the + * latter is runnable from cmd or Node. Prefer anything with an executable + * extension, and fall back to the first line. + */ +export function pickWhereResult(output: string): string | null { + const lines = output.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) + if (lines.length === 0) return null + const runnable = ['.exe', '.cmd', '.bat', '.com'] + return lines.find((l) => runnable.includes(extname(l).toLowerCase())) ?? lines[0]! +} + +/** Where `npm install -g` puts a binary under a given prefix. */ +export function npmBinCandidates(prefix: string, cmd: string, platform: NodeJS.Platform = process.platform): string[] { + return platform === 'win32' ? [join(prefix, `${cmd}.cmd`)] : [join(prefix, 'bin', cmd)] +} + +export function needsShell(file: string): boolean { + return /\.(cmd|bat)$/i.test(file) +} + +/** Quote one argument for a cmd.exe command line. */ +export function quoteWindowsArg(arg: string): string { + if (arg === '') return '""' + if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg + return `"${arg.replace(/"/g, '""')}"` +} + +/** Absolute path of `cmd` on PATH, or null. */ +export function which(cmd: string): string | null { + const inv = whichInvocation(cmd) + try { + const out = cp.execFileSync(inv.file, inv.args, { stdio: 'pipe', encoding: 'utf-8' }) + return IS_WINDOWS ? pickWhereResult(out) : (out.trim() || null) + } catch { + return null + } +} + +/** + * On Windows, turn a bare command name into something spawnable: `.exe` runs + * directly with args untouched; a `.cmd`/`.bat` shim runs through cmd.exe + * with its args quoted. Anything unresolvable is passed through unchanged, so + * the caller sees the same ENOENT it would have. + */ +function windowsTarget(file: string, args: readonly string[]): { file: string; args: string[]; shell: boolean } { + const resolved = /[\\/]/.test(file) ? file : which(file) + if (resolved && needsShell(resolved)) { + return { file: quoteWindowsArg(resolved), args: args.map(quoteWindowsArg), shell: true } + } + return { file: resolved ?? file, args: [...args], shell: false } +} + +/** Drop-in for child_process.execFileSync that can run npm shims on Windows. */ +export function execFileSync(file: string, args: readonly string[] = [], options?: ExecFileSyncOptions): any { + if (!IS_WINDOWS) return cp.execFileSync(file, args, options) + const t = windowsTarget(file, args) + return cp.execFileSync(t.file, t.args, t.shell ? { ...options, shell: true } : options) +} + +/** Drop-in for child_process.spawn with the same Windows handling. */ +export function spawn(file: string, args: readonly string[] = [], options: SpawnOptions = {}): ChildProcess { + if (!IS_WINDOWS) return cp.spawn(file, args, options) + const t = windowsTarget(file, args) + return cp.spawn(t.file, t.args, t.shell ? { ...options, shell: true } : options) +} + +/** Run a command string (pipes, &&) through the platform's shell. */ +export function runShell(command: string, options?: ExecFileSyncOptions): any { + const inv = shellInvocation(command) + return cp.execFileSync(inv.file, inv.args, IS_WINDOWS ? ({ ...options, windowsVerbatimArguments: true } as ExecFileSyncOptions) : options) +} + +/** + * winget updates the registry PATH, not this process's. Without re-reading + * it, git installed a second ago is "not found", and npm — needed for the + * next four installs — does not exist yet. No-op outside Windows. + */ +export function refreshWindowsPath(): void { + if (!IS_WINDOWS) return + try { + const out = cp.execFileSync('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-Command', + "[Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')", + ], { stdio: 'pipe', encoding: 'utf-8', timeout: 15000 }).trim() + if (!out) return + const current = (process.env.Path ?? process.env.PATH ?? '').split(';') + const merged = [...new Set([...out.split(';'), ...current].filter(Boolean))].join(';') + process.env.Path = merged + process.env.PATH = merged + } catch { /* keep the PATH we have */ } +} diff --git a/src/lib/init.ts b/src/lib/init.ts index 6e3ea04..cc87306 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -19,7 +19,7 @@ import { existsSync, mkdirSync, writeFileSync, symlinkSync, copyFileSync, readdirSync, readFileSync, statSync, cpSync } from 'fs' import { join, basename } from 'path' -import { execFileSync } from 'child_process' +import { execFileSync, homeDir, npmBinCandidates, refreshWindowsPath, runShell } from './exec' import { createInterface } from 'readline' import { detectPlatform, getInstallCommand, type Platform } from './platform' import { AVAILABLE_MODULES, installModule, listModules } from './module' @@ -41,7 +41,7 @@ import { spawnBackground, type BackgroundJob } from './background' // the caller sets before starting the process (see the init smoke test). import { ensureDatacoreVenv, ensureModuleDeps, initSucceeded, pipInstallInto, venvPython } from './python-env' -const DATA_DIR = process.env.DATACORE_ROOT || join(process.env.HOME || '', 'Data') +const DATA_DIR = process.env.DATACORE_ROOT || join(homeDir(), 'Data') const DATACORE_DIR = join(DATA_DIR, '.datacore') const UPSTREAM_REPO = 'datacore-one/datacore' const TOTAL_STEPS = 10 @@ -335,13 +335,7 @@ function commandExists(cmd: string): boolean { // resolveBinary also looks in npm's configured prefix and our fallback // prefix. `which` alone reported binaries as missing that were installed // and working, purely because the prefix bin was not on PATH. - if (resolveBinary(cmd)) return true - try { - execFileSync('which', [cmd], { stdio: 'pipe' }) - return true - } catch { - return false - } + return resolveBinary(cmd) !== null } function getVersionString(cmd: string, flag = '--version'): string | undefined { @@ -413,7 +407,7 @@ function cosPersonaName(): string { * working memory server, whatever the Code config says. */ function mcpConfigured(name: string): boolean { - const home = process.env.HOME || '' + const home = homeDir() const candidates = [ join(DATA_DIR, '.mcp.json'), join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), @@ -454,7 +448,7 @@ function localiseHookPaths(isTTY: boolean | undefined, result: InitResult): void const settingsPath = join(DATACORE_DIR, 'settings.json') if (!existsSync(settingsPath)) return - const home = process.env.HOME || '' + const home = homeDir() const defaultRoot = join(home, 'Data') if (DATA_DIR === defaultRoot) return // nothing to rewrite @@ -592,10 +586,12 @@ async function ensureDependency( }, 1000) : null try { - // Install commands may contain shell operators (pipes, &&), so use bash. - // `inherit` also connects stdin, so a prompt can actually be answered. - execFileSync('/bin/bash', ['-c', installCmd], - { stdio: isTTY ? 'inherit' : 'pipe', timeout: 900000 }) + // Install commands may contain shell operators (pipes, &&), so they run + // through a shell: bash, or cmd.exe on Windows. `inherit` also connects + // stdin, so a prompt can actually be answered. + runShell(installCmd, { stdio: isTTY ? 'inherit' : 'pipe', timeout: 900000 }) + // winget writes the new PATH to the registry, not to this process. + refreshWindowsPath() if (ticker) clearInterval(ticker) // Verify it's now available @@ -623,7 +619,7 @@ async function ensureDependency( // touching PATH — which is the half of the usual remedy people skip, // and the reason an install could look complete and launch nothing. const pkg = installCmd.replace('npm install -g ', '').trim() - const fallbackBin = join(FALLBACK_NPM_PREFIX, 'bin', checkCmd) + const fallbackBin = npmBinCandidates(FALLBACK_NPM_PREFIX, checkCmd)[0]! try { mkdirSync(FALLBACK_NPM_PREFIX, { recursive: true }) execFileSync('npm', ['install', '--prefix', FALLBACK_NPM_PREFIX, '-g', pkg], @@ -685,7 +681,7 @@ async function ensureHomebrew(isTTY: boolean): Promise { spinner?.start() try { - execFileSync('/bin/bash', ['-c', 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], { + runShell('NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', { stdio: 'pipe', timeout: 600000, }) @@ -1447,7 +1443,7 @@ export async function initDatacore(options: InitOptions = {}): Promise> = { git: { macos: 'brew install git', linux: 'sudo apt-get install git', wsl: 'sudo apt-get install git', - windows: 'winget install Git.Git', + windows: winget('Git.Git'), unknown: null, }, node: { macos: 'brew install node', linux: 'curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs', wsl: 'curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs', - windows: 'winget install OpenJS.NodeJS', + windows: winget('OpenJS.NodeJS.LTS'), unknown: null, }, python: { macos: 'brew install python@3.11', linux: 'sudo apt-get install python3', wsl: 'sudo apt-get install python3', - windows: 'winget install Python.Python.3.11', + windows: winget('Python.Python.3.11'), unknown: null, }, gh: { macos: 'brew install gh', linux: 'sudo apt-get install gh', wsl: 'sudo apt-get install gh', - windows: 'winget install GitHub.cli', + windows: winget('GitHub.cli'), unknown: null, }, claude: { diff --git a/src/lib/python-env.ts b/src/lib/python-env.ts index b2bf334..9eaff3c 100644 --- a/src/lib/python-env.ts +++ b/src/lib/python-env.ts @@ -11,7 +11,7 @@ * install every module's tools fail to load and the server quietly offers 12 * tools instead of 45+. */ -import { execFileSync } from 'child_process' +import { execFileSync } from './exec' import { existsSync, rmSync } from 'fs' import { join } from 'path' @@ -26,8 +26,11 @@ const defaultRun: Runner = (cmd, args, opts) => { } } -export function venvPython(dataDir: string): string { - return join(dataDir, '.datacore', 'venv', 'bin', 'python') +export function venvPython(dataDir: string, platform: NodeJS.Platform = process.platform): string { + // Windows venvs put the interpreter in Scripts\, not bin/. + return platform === 'win32' + ? join(dataDir, '.datacore', 'venv', 'Scripts', 'python.exe') + : join(dataDir, '.datacore', 'venv', 'bin', 'python') } export function pipInstallInto(python: string, requirements: string, run: Runner = defaultRun) { diff --git a/src/lib/python.ts b/src/lib/python.ts index 1aea5f9..34d640b 100644 --- a/src/lib/python.ts +++ b/src/lib/python.ts @@ -18,7 +18,8 @@ * take the first that clears the floor. */ -import { execFileSync } from 'child_process' +import { execFileSync } from './exec' +import { venvPython } from './python-env' import { existsSync } from 'fs' import { join } from 'path' @@ -40,7 +41,7 @@ function candidates(root?: string): string[] { const explicit = process.env.DATACORE_PYTHON if (explicit) list.push(explicit) // The installation's own venv holds the dependencies `datacore init` installed. - const venv = root ? join(root, '.datacore', 'venv', 'bin', 'python') : null + const venv = root ? venvPython(root) : null if (venv && existsSync(venv)) list.push(venv) // Version-qualified names first: they cannot be the 3.9 system binary. list.push('python3.13', 'python3.12', 'python3.11', 'python3.10') @@ -48,6 +49,9 @@ function candidates(root?: string): string[] { // for non-interactive shells — which is how a launchd job ends up on 3.9. list.push('/opt/homebrew/bin/python3', '/usr/local/bin/python3') list.push('python3') + // Windows installs name it python.exe; python3 there is often only the + // Microsoft Store stub, which exits without running anything. + if (process.platform === 'win32') list.push('python') return list } diff --git a/src/lib/space.ts b/src/lib/space.ts index aa15b27..be9a314 100644 --- a/src/lib/space.ts +++ b/src/lib/space.ts @@ -6,7 +6,7 @@ import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, rmSync } from 'fs' import { join, basename } from 'path' -import { execFileSync } from 'child_process' +import { execFileSync, which } from './exec' import type { SpaceInfo } from '../types' import { dataDir } from './paths' @@ -592,7 +592,7 @@ function run(bin: string, args: string[], opts: { cwd?: string; timeout?: number } function binExists(bin: string): boolean { - return run('which', [bin]).ok + return which(bin) !== null } export type ForgeId = 'github' | 'gitlab' diff --git a/src/lib/sync.ts b/src/lib/sync.ts index de1a839..0735d15 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -4,7 +4,8 @@ * Handles git pull/push across all spaces. */ -import { execSync, spawn, execFileSync } from 'child_process' +import { execSync } from 'child_process' +import { execFileSync, spawn } from './exec' import { existsSync } from 'fs' import { join, basename } from 'path' import { listSpaces } from './space' diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 14b4ae3..4a53b68 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' import { join } from 'path' -import { execFileSync } from 'child_process' +import { execFileSync, homeDir, npmBinCandidates, runShell, which } from './exec' import { createInterface } from 'readline' import { detectPlatform, getInstallCommand, type Platform } from './platform' import { updateModules, listModules } from './module' @@ -69,7 +69,7 @@ const c = { /** Where we install when npm's global prefix is not writable (see init.ts). */ -export const FALLBACK_NPM_PREFIX = join(process.env.HOME || '', '.datacore', 'npm') +export const FALLBACK_NPM_PREFIX = join(homeDir(), '.datacore', 'npm') /** * Absolute path of a CLI binary, or null. @@ -81,18 +81,17 @@ export const FALLBACK_NPM_PREFIX = join(process.env.HOME || '', '.datacore', 'np * is extremely common. */ export function resolveBinary(cmd: string): string | null { - try { - const p = execFileSync('which', [cmd], { stdio: 'pipe' }).toString().trim() - if (p) return p - } catch { /* not on PATH — keep looking */ } + const onPath = which(cmd) + if (onPath) return onPath const candidates: string[] = [] try { const prefix = execFileSync('npm', ['config', 'get', 'prefix'], { stdio: 'pipe' }) .toString().trim() - if (prefix && prefix !== 'undefined') candidates.push(join(prefix, 'bin', cmd)) + if (prefix && prefix !== 'undefined') candidates.push(...npmBinCandidates(prefix, cmd)) } catch { /* npm not answering — fall through */ } - candidates.push(join(FALLBACK_NPM_PREFIX, 'bin', cmd)) + // The fallback is installed with `-g --prefix`, so it has the same layout. + candidates.push(...npmBinCandidates(FALLBACK_NPM_PREFIX, cmd)) for (const c of candidates) { if (existsSync(c)) return c @@ -101,12 +100,7 @@ export function resolveBinary(cmd: string): string | null { } function commandExists(cmd: string): boolean { - try { - execFileSync('which', [cmd], { stdio: 'pipe' }) - return true - } catch { - return false - } + return which(cmd) !== null } function getVersionString(cmd: string, flag = '--version'): string | undefined { @@ -255,7 +249,7 @@ function upgradeDependencies( if (installCmd) { if (isTTY) process.stdout.write(` Installing datacore-mcp...`) try { - execFileSync('/bin/bash', ['-c', installCmd], { stdio: 'pipe', timeout: 300000 }) + runShell(installCmd, { stdio: 'pipe', timeout: 300000 }) if (commandExists('datacore-mcp')) { const version = getVersionString('datacore-mcp', '--version') if (isTTY) console.log(`\r ${c.green}✓${c.reset} datacore-mcp installed ${c.dim}(${version})${c.reset}`) @@ -341,7 +335,7 @@ export function unresolvableMcpServers(): string[] { type McpTarget = 'code' | 'desktop' | 'both' function detectClaudeDesktopConfigDir(): string | null { - const home = process.env.HOME || '' + const home = homeDir() const paths = [ join(home, 'Library', 'Application Support', 'Claude'), // macOS join(home, '.config', 'claude'), // Linux diff --git a/src/state.ts b/src/state.ts index 11f3028..ddc59dd 100644 --- a/src/state.ts +++ b/src/state.ts @@ -6,8 +6,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import type { OperationState, OperationStep } from './types' +import { homeDir } from './lib/exec' -const STATE_DIR = join(process.env.HOME || '~', 'Data', '.datacore', 'state') +const STATE_DIR = join(homeDir(), 'Data', '.datacore', 'state') const STATE_FILE = join(STATE_DIR, 'operations.json') interface StateStore { diff --git a/tests/windows.test.ts b/tests/windows.test.ts new file mode 100644 index 0000000..3e318fa --- /dev/null +++ b/tests/windows.test.ts @@ -0,0 +1,151 @@ +/** + * Native Windows install. + * + * The first Windows user got "✗ Failed to install" for all seven tools with + * nothing streamed, because every install ran through `/bin/bash` — which + * does not exist there, so the spawn itself failed before winget or npm ran. + * Behind it sat three more: `which` (also absent) made every tool look + * missing, so it tried to install node from inside a running node; npm's + * `.cmd` shims cannot be spawned without a shell; and with HOME unset, + * `join(process.env.HOME || '', 'Data')` is the relative path `Data`, so the + * second brain would have landed in whatever directory init was run from. + * + * None of this can be observed on the Mac the CLI is developed on, so every + * platform decision is a pure function of the platform, tested here for both. + */ +import { describe, it, expect, afterEach } from 'bun:test' +import { readdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { + homeDir, shellInvocation, whichInvocation, pickWhereResult, + npmBinCandidates, needsShell, quoteWindowsArg, +} from '../src/lib/exec' +import { getInstallCommand } from '../src/lib/platform' +import { venvPython } from '../src/lib/python-env' + +const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } +afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } +}) + +describe('home directory', () => { + it('falls back to USERPROFILE when HOME is unset (native Windows)', () => { + delete process.env.HOME + process.env.USERPROFILE = 'C:\\Users\\ana' + expect(homeDir()).toBe('C:\\Users\\ana') + }) + it('is never empty, so ~/Data can never become the relative path "Data"', () => { + delete process.env.HOME + delete process.env.USERPROFILE + expect(homeDir().length).toBeGreaterThan(0) + }) +}) + +describe('running an install command', () => { + it('uses cmd.exe on Windows, never /bin/bash', () => { + const inv = shellInvocation('winget install --id Git.Git -e', 'win32') + expect(inv.file.toLowerCase()).toContain('cmd') + expect(inv.file).not.toContain('bash') + expect(inv.args.join(' ')).toContain('winget install --id Git.Git -e') + }) + it('keeps bash on macOS and Linux (install commands use pipes and &&)', () => { + expect(shellInvocation('a && b', 'darwin')).toEqual({ file: '/bin/bash', args: ['-c', 'a && b'] }) + expect(shellInvocation('a && b', 'linux')).toEqual({ file: '/bin/bash', args: ['-c', 'a && b'] }) + }) +}) + +describe('finding a command', () => { + it('uses `where` on Windows and `which` elsewhere', () => { + expect(whichInvocation('git', 'win32')).toEqual({ file: 'where', args: ['git'] }) + expect(whichInvocation('git', 'darwin')).toEqual({ file: 'which', args: ['git'] }) + }) + it('prefers the runnable shim: `where npm` lists the extensionless bash script first', () => { + const out = 'C:\\Program Files\\nodejs\\npm\r\nC:\\Program Files\\nodejs\\npm.cmd\r\n' + expect(pickWhereResult(out)).toBe('C:\\Program Files\\nodejs\\npm.cmd') + }) + it('returns an .exe as-is', () => { + expect(pickWhereResult('C:\\Program Files\\Git\\cmd\\git.exe\r\n')).toBe('C:\\Program Files\\Git\\cmd\\git.exe') + }) + it('returns null for empty output', () => { + expect(pickWhereResult('')).toBeNull() + }) +}) + +describe('npm global binaries', () => { + it('live in the prefix itself as .cmd on Windows, not prefix/bin', () => { + expect(npmBinCandidates('C:\\Users\\ana\\AppData\\Roaming\\npm', 'plur-mcp', 'win32')) + .toEqual([join('C:\\Users\\ana\\AppData\\Roaming\\npm', 'plur-mcp.cmd')]) + }) + it('live in prefix/bin elsewhere', () => { + expect(npmBinCandidates('/usr/local', 'plur-mcp', 'darwin')).toEqual([join('/usr/local', 'bin', 'plur-mcp')]) + }) + it('.cmd and .bat shims need a shell to spawn (Node refuses them since CVE-2024-27980)', () => { + expect(needsShell('C:\\x\\npm.cmd')).toBe(true) + expect(needsShell('C:\\x\\run.BAT')).toBe(true) + expect(needsShell('C:\\x\\git.exe')).toBe(false) + }) + it('quotes arguments cmd.exe would split or interpret', () => { + expect(quoteWindowsArg('install')).toBe('install') + expect(quoteWindowsArg('C:\\Program Files\\x')).toBe('"C:\\Program Files\\x"') + expect(quoteWindowsArg('a&b')).toBe('"a&b"') + expect(quoteWindowsArg('say "hi"')).toBe('"say ""hi"""') + expect(quoteWindowsArg('')).toBe('""') + }) +}) + +describe('winget', () => { + it('installs by exact id and pre-accepts agreements, so an agent-driven run cannot stall on a prompt', () => { + for (const pkg of ['git', 'node', 'python', 'gh']) { + const cmd = getInstallCommand(pkg, 'windows')! + expect(cmd).toContain('winget install') + expect(cmd).toContain('--exact') + expect(cmd).toContain('--accept-package-agreements') + expect(cmd).toContain('--accept-source-agreements') + } + }) +}) + +describe('python venv', () => { + it('uses Scripts\\python.exe on Windows', () => { + expect(venvPython('C:\\Users\\ana\\Data', 'win32')) + .toBe(join('C:\\Users\\ana\\Data', '.datacore', 'venv', 'Scripts', 'python.exe')) + }) + it('uses bin/python elsewhere', () => { + expect(venvPython('/home/ana/Data', 'linux')).toBe(join('/home/ana/Data', '.datacore', 'venv', 'bin', 'python')) + }) +}) + +describe('no Windows-hostile calls outside exec.ts', () => { + // The guard that stops this recurring: the bug was not one call site but a + // habit, repeated in init, upgrade, dependency and agent. A new `which` or + // `/bin/bash` anywhere else reintroduces it silently on the platform nobody + // here runs. + const srcDir = join(import.meta.dir, '..', 'src') + const files: string[] = [] + const walk = (d: string) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + if (e.isDirectory()) walk(join(d, e.name)) + else if (e.name.endsWith('.ts') && e.name !== 'exec.ts') files.push(join(d, e.name)) + } + } + walk(srcDir) + + const offenders = (re: RegExp) => + files.filter((f) => re.test(readFileSync(f, 'utf-8'))).map((f) => f.slice(srcDir.length + 1)) + + it('does not spawn /bin/bash', () => { + expect(offenders(/['"]\/bin\/bash['"]/)).toEqual([]) + }) + it('does not call `which`', () => { + expect(offenders(/['"]which['"]\s*[,)]|`which \$\{/)).toEqual([]) + }) + it('does not build paths from process.env.HOME || ...', () => { + expect(offenders(/process\.env\.HOME\s*\|\|/)).toEqual([]) + }) + it('does not import execFileSync or spawn straight from child_process', () => { + expect(offenders(/import\s*\{[^}]*\b(execFileSync|spawn)\b[^}]*\}\s*from\s*'child_process'/)).toEqual([]) + }) +})