fix(codex-cli/install): harden Windows/WSL install paths and locks

- Avoid placing EXE inside node_modules on Windows and WSL-on-NTFS
- Add preinstall cleanup (global-only), limited to our EXE; age-guard stale .code-* dirs
- Atomic cache writes with retries; mirror to node_modules only on Unix filesystems
- Robust Windows unzip fallback chain (powershell.exe→powershell→pwsh→tar)
- Detect WSL drvfs/cifs with realpath mount check; skip taskkill on WSL
- Fallback to per-user cache if tmp staging dir is unavailable
- Package windows-cleanup.ps1 and preinstall script
This commit is contained in:
James Peter
2025-09-11 19:11:59 +10:00
parent 1829bd2987
commit 9faf876c6c
4 changed files with 233 additions and 33 deletions
+3
View File
@@ -14,9 +14,12 @@
"bin/coder.js",
"bin/codex.js",
"postinstall.js",
"scripts/preinstall.js",
"scripts/windows-cleanup.ps1",
"dist"
],
"scripts": {
"preinstall": "node scripts/preinstall.js",
"postinstall": "node postinstall.js",
"prepublishOnly": "node -e \"const fs=require('fs'),path=require('path'); const repoGit=path.join(__dirname,'..','.git'); const inCi=process.env.GITHUB_ACTIONS==='true'||process.env.CI==='true'; if(fs.existsSync(repoGit) && !inCi){ console.error('Refusing to publish from codex-cli. Use codex-cli/scripts/stage_release.sh to stage a release.'); process.exit(1);} else { console.log(inCi ? 'CI publish detected.' : 'Publishing staged package...'); }\""
},
+129 -33
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node
// Non-functional change to trigger release workflow
import { existsSync, mkdirSync, createWriteStream, chmodSync, readFileSync, readSync, writeFileSync, unlinkSync, statSync, openSync, closeSync, copyFileSync } from 'fs';
import { existsSync, mkdirSync, createWriteStream, chmodSync, readFileSync, readSync, writeFileSync, unlinkSync, statSync, openSync, closeSync, copyFileSync, fsyncSync, renameSync, realpathSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { get } from 'https';
import { platform, arch } from 'os';
import { platform, arch, tmpdir } from 'os';
import { execSync } from 'child_process';
import { createRequire } from 'module';
@@ -54,6 +54,56 @@ function getCachedBinaryPath(version, targetTriple, isWindows) {
return join(cacheDir, `code-${targetTriple}${ext}`);
}
function isWSL() {
if (platform() !== 'linux') return false;
try {
const ver = readFileSync('/proc/version', 'utf8').toLowerCase();
return ver.includes('microsoft') || !!process.env.WSL_DISTRO_NAME;
} catch { return false; }
}
function isPathOnWindowsFs(p) {
try {
const mounts = readFileSync('/proc/mounts', 'utf8').split(/\n/).filter(Boolean);
let best = { mount: '/', type: 'unknown', len: 1 };
for (const line of mounts) {
const parts = line.split(' ');
if (parts.length < 3) continue;
const mnt = parts[1];
const typ = parts[2];
if (p.startsWith(mnt) && mnt.length > best.len) best = { mount: mnt, type: typ, len: mnt.length };
}
return best.type === 'drvfs' || best.type === 'cifs';
} catch { return false; }
}
async function writeCacheAtomic(srcPath, cachePath) {
try {
if (existsSync(cachePath)) {
const ok = validateDownloadedBinary(cachePath).ok;
if (ok) return;
}
} catch {}
const dir = dirname(cachePath);
if (!existsSync(dir)) { try { mkdirSync(dir, { recursive: true }); } catch {} }
const tmp = cachePath + '.tmp-' + Math.random().toString(36).slice(2, 8);
copyFileSync(srcPath, tmp);
try { const fd = openSync(tmp, 'r'); try { fsyncSync(fd); } finally { closeSync(fd); } } catch {}
// Retry with exponential backoff up to ~1.6s total
const delays = [100, 200, 400, 800, 1200, 1600];
for (let i = 0; i < delays.length; i++) {
try {
if (existsSync(cachePath)) { try { unlinkSync(cachePath); } catch {} }
renameSync(tmp, cachePath);
return;
} catch {
await new Promise(r => setTimeout(r, delays[i]));
}
}
if (existsSync(cachePath)) { try { unlinkSync(cachePath); } catch {} }
renameSync(tmp, cachePath);
}
async function downloadBinary(url, dest, maxRedirects = 5, maxRetries = 3) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
@@ -238,8 +288,11 @@ async function main() {
if (existsSync(cachePath)) {
const valid = validateDownloadedBinary(cachePath);
if (valid.ok) {
if (!isWindows) {
// Mirror into local bin for Unix-like platforms
// Avoid mirroring into node_modules on Windows or WSL-on-NTFS.
const wsl = isWSL();
const binDirReal = (() => { try { return realpathSync(binDir); } catch { return binDir; } })();
const mirrorToLocal = !(isWindows || (wsl && isPathOnWindowsFs(binDirReal)));
if (mirrorToLocal) {
copyFileSync(cachePath, localPath);
try { chmodSync(localPath, 0o755); } catch {}
}
@@ -281,10 +334,13 @@ async function main() {
if (!existsSync(src)) {
throw new Error(`platform package missing binary: ${platformPkg.name}`);
}
// Populate cache first (canonical location)
copyFileSync(src, cachePath);
if (!isWindows) {
// Mirror into local bin for Unix-like platforms only
// Populate cache first (canonical location) atomically
await writeCacheAtomic(src, cachePath);
// Mirror into local bin only on Unix-like filesystems (not Windows/WSL-on-NTFS)
const wsl = isWSL();
const binDirReal = (() => { try { return realpathSync(binDir); } catch { return binDir; } })();
const mirrorToLocal = !(isWindows || (wsl && isPathOnWindowsFs(binDirReal)));
if (mirrorToLocal) {
copyFileSync(cachePath, localPath);
try { chmodSync(localPath, 0o755); } catch {}
}
@@ -299,6 +355,15 @@ async function main() {
// - Windows: .zip
// - macOS/Linux: prefer .zst if `zstd` CLI is available; otherwise use .tar.gz
const isWin = isWindows;
const isWSL = (() => {
if (platform() !== 'linux') return false;
try {
const ver = readFileSync('/proc/version', 'utf8').toLowerCase();
return ver.includes('microsoft') || !!process.env.WSL_DISTRO_NAME;
} catch { return false; }
})();
const binDirReal = (() => { try { return realpathSync(binDir); } catch { return binDir; } })();
const mirrorToLocal = !(isWin || (isWSL && isPathOnWindowsFs(binDirReal)));
let useZst = false;
if (!isWin) {
try {
@@ -313,24 +378,57 @@ async function main() {
console.log(`Downloading ${archiveName}...`);
try {
const tmpPath = join(binDir, `.${archiveName}.part`);
const needsIsolation = isWin || (!isWin && !mirrorToLocal); // Windows or WSL-on-NTFS
let safeTempDir = needsIsolation ? join(tmpdir(), 'just-every', 'code', version) : binDir;
// Ensure staging dir exists; if tmp fails (permissions/space), fall back to user cache.
if (needsIsolation) {
try {
if (!existsSync(safeTempDir)) mkdirSync(safeTempDir, { recursive: true });
} catch {
try {
safeTempDir = getCacheDir(version);
if (!existsSync(safeTempDir)) mkdirSync(safeTempDir, { recursive: true });
} catch {}
}
}
const tmpPath = join(needsIsolation ? safeTempDir : binDir, `.${archiveName}.part`);
await downloadBinary(downloadUrl, tmpPath);
if (isWin) {
// Unzip the single-file archive using PowerShell (built-in)
// Unzip to a temp directory, then move into the per-user cache.
const unzipDest = safeTempDir;
try {
const psCmd = `powershell -NoProfile -NonInteractive -Command "Expand-Archive -Path '${tmpPath}' -DestinationPath '${binDir}' -Force"`;
execSync(psCmd, { stdio: 'ignore' });
const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
const psFull = join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
const psCmd = `Expand-Archive -Path '${tmpPath}' -DestinationPath '${unzipDest}' -Force`;
let ok = false;
// Attempt full-path powershell.exe
try { execSync(`"${psFull}" -NoProfile -NonInteractive -Command "${psCmd}"`, { stdio: 'ignore' }); ok = true; } catch {}
// Fallback to powershell in PATH
if (!ok) { try { execSync(`powershell -NoProfile -NonInteractive -Command "${psCmd}"`, { stdio: 'ignore' }); ok = true; } catch {} }
// Fallback to pwsh (PowerShell 7)
if (!ok) { try { execSync(`pwsh -NoProfile -NonInteractive -Command "${psCmd}"`, { stdio: 'ignore' }); ok = true; } catch {} }
// Final fallback: bsdtar can extract .zip
if (!ok) { execSync(`tar -xf "${tmpPath}" -C "${unzipDest}"`, { stdio: 'ignore', shell: true }); }
} catch (e) {
throw new Error(`failed to unzip archive: ${e.message}`);
} finally {
try { unlinkSync(tmpPath); } catch {}
}
// Move the extracted file from temp to cache; do not leave a copy in node_modules
try {
const extractedPath = join(unzipDest, binaryName);
await writeCacheAtomic(extractedPath, cachePath);
try { unlinkSync(extractedPath); } catch {}
} catch (e) {
throw new Error(`failed to move binary to cache: ${e.message}`);
}
} else {
if (useZst) {
// Decompress .zst via system zstd
try {
execSync(`zstd -d '${tmpPath}' -o '${localPath}'`, { stdio: 'ignore', shell: true });
const outPath = mirrorToLocal ? localPath : join(safeTempDir, binaryName);
execSync(`zstd -d '${tmpPath}' -o '${outPath}'`, { stdio: 'ignore', shell: true });
} catch (e) {
try { unlinkSync(tmpPath); } catch {}
throw new Error(`failed to decompress .zst (need zstd CLI): ${e.message}`);
@@ -339,44 +437,42 @@ async function main() {
} else {
// Extract .tar.gz using system tar
try {
execSync(`tar -xzf '${tmpPath}' -C '${binDir}'`, { stdio: 'ignore', shell: true });
const dest = mirrorToLocal ? binDir : safeTempDir;
execSync(`tar -xzf '${tmpPath}' -C '${dest}'`, { stdio: 'ignore', shell: true });
} catch (e) {
try { unlinkSync(tmpPath); } catch {}
throw new Error(`failed to extract .tar.gz: ${e.message}`);
}
try { unlinkSync(tmpPath); } catch {}
}
}
// Validate header to avoid corrupt binaries causing spawn EFTYPE/ENOEXEC
// On Windows, archive extraction writes to binDir; move the result to cache
// and remove the copy from node_modules to avoid future locks. On Unix,
// we keep a copy in binDir and also ensure cache is populated.
if (isWindows) {
try {
// Ensure the extracted file is at localPath; then move it to cachePath
copyFileSync(localPath, cachePath);
try { unlinkSync(localPath); } catch {}
} catch (e) {
throw new Error(`failed to move binary to cache: ${e.message}`);
if (!mirrorToLocal) {
try {
const extractedPath = join(safeTempDir, binaryName);
await writeCacheAtomic(extractedPath, cachePath);
try { unlinkSync(extractedPath); } catch {}
} catch (e) {
throw new Error(`failed to move binary to cache: ${e.message}`);
}
}
}
const valid = validateDownloadedBinary(isWindows ? cachePath : localPath);
// Validate header to avoid corrupt binaries causing spawn EFTYPE/ENOEXEC
const valid = validateDownloadedBinary(isWin ? cachePath : (mirrorToLocal ? localPath : cachePath));
if (!valid.ok) {
try { isWindows ? unlinkSync(cachePath) : unlinkSync(localPath); } catch {}
try { (isWin || !mirrorToLocal) ? unlinkSync(cachePath) : unlinkSync(localPath); } catch {}
throw new Error(`invalid binary (${valid.reason})`);
}
// Make executable on Unix-like systems
if (!isWindows) {
if (!isWin && mirrorToLocal) {
chmodSync(localPath, 0o755);
}
console.log(`✓ Installed ${binaryName}${isWindows ? ' (cached)' : ''}`);
console.log(`✓ Installed ${binaryName}${(isWin || !mirrorToLocal) ? ' (cached)' : ''}`);
// Ensure persistent cache holds the binary (already true for Windows path)
if (!isWindows) {
try { copyFileSync(localPath, cachePath); } catch {}
if (!isWin && mirrorToLocal) {
try { await writeCacheAtomic(localPath, cachePath); } catch {}
}
} catch (error) {
console.error(`✗ Failed to install ${binaryName}: ${error.message}`);
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
// Windows-friendly preinstall: proactively free file locks from prior installs
// so npm/yarn/pnpm can stage the new package. No-ops on non-Windows.
import { platform } from 'os';
import { execSync } from 'child_process';
import { existsSync, readdirSync, rmSync, readFileSync, statSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
function isWSL() {
if (platform() !== 'linux') return false;
try {
const rel = readFileSync('/proc/version', 'utf8').toLowerCase();
return rel.includes('microsoft') || !!process.env.WSL_DISTRO_NAME;
} catch { return false; }
}
const isWin = platform() === 'win32';
const wsl = isWSL();
const isWinLike = isWin || wsl;
// Scope: only run for global installs, unless explicitly forced. Allow opt-out.
const isGlobal = process.env.npm_config_global === 'true';
const force = process.env.CODE_FORCE_PREINSTALL === '1';
const skip = process.env.CODE_SKIP_PREINSTALL === '1';
if (!isWinLike || skip || (!isGlobal && !force)) process.exit(0);
function tryExec(cmd, opts = {}) {
try { execSync(cmd, { stdio: ['ignore', 'ignore', 'ignore'], shell: true, ...opts }); } catch { /* ignore */ }
}
// 1) Stop our native binary if it is holding locks. Avoid killing unrelated tools.
// Only available on native Windows; skip entirely on WSL to avoid noise.
if (isWin) {
tryExec('taskkill /IM code-x86_64-pc-windows-msvc.exe /F');
}
// 2) Remove stale staging dirs from previous failed installs under the global
// @just-every scope, which npm will reuse (e.g., .code-XXXXX). Remove only
// old entries and never the current staging or live package.
try {
let scopeDir = '';
try {
const root = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'], shell: true }).toString().trim();
scopeDir = path.join(root, '@just-every');
} catch {
// Fall back to guessing from this script location: <staging>\..\..\
const here = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
scopeDir = path.resolve(here, '..');
}
if (existsSync(scopeDir)) {
const now = Date.now();
const maxAgeMs = 2 * 60 * 60 * 1000; // 2 hours
const currentDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
for (const name of readdirSync(scopeDir)) {
if (!name.startsWith('.code-')) continue;
const p = path.join(scopeDir, name);
if (path.resolve(p) === currentDir) continue; // never remove our current dir
try {
const st = statSync(p);
const age = now - st.mtimeMs;
if (age > maxAgeMs) rmSync(p, { recursive: true, force: true });
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
process.exit(0);
+32
View File
@@ -0,0 +1,32 @@
<#
Helper to recover from EBUSY/EPERM during global npm upgrades on Windows.
Closes running processes and removes stale package folders.
Usage (PowerShell):
Set-ExecutionPolicy -Scope Process Bypass -Force
./codex-cli/scripts/windows-cleanup.ps1
#>
$ErrorActionPreference = 'SilentlyContinue'
Write-Host "Stopping running Code/Coder processes..."
taskkill /IM code-x86_64-pc-windows-msvc.exe /F 2>$null | Out-Null
taskkill /IM code.exe /F 2>$null | Out-Null
taskkill /IM coder.exe /F 2>$null | Out-Null
Write-Host "Removing old global package (if present)..."
$npmRoot = (& npm root -g).Trim()
$pkgPath = Join-Path $npmRoot "@just-every\code"
if (Test-Path $pkgPath) {
try { Remove-Item -LiteralPath $pkgPath -Recurse -Force -ErrorAction Stop } catch {}
}
Write-Host "Removing temp staging directories (if present)..."
Get-ChildItem -LiteralPath (Join-Path $npmRoot "@just-every") -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '.code-*' } |
ForEach-Object {
try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop } catch {}
}
Write-Host "Cleanup complete. You can now run: npm install -g @just-every/code@latest"