Merge fix/npx-bin-collision: restore correct npx behavior; robust global PATH collision handling\n\n- Runtime: always run bundled binary; show npx path; never delegate to VS Code\n- Postinstall: detect any PATH collisions, remove our 'code' shim, keep 'coder'\n- Logs: clear guidance for both npx and global installs

This commit is contained in:
James Peter
2025-08-29 16:10:54 +10:00
4 changed files with 74 additions and 87 deletions
+31 -51
View File
@@ -3,8 +3,6 @@
import path from "path";
import { fileURLToPath } from "url";
import { realpathSync, accessSync, constants } from "fs";
import { spawnSync } from "child_process";
// __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url);
@@ -12,55 +10,9 @@ const __dirname = path.dirname(__filename);
const { platform, arch } = process;
// If invoked as "code" and another "code" exists in PATH that is not
// this package's launcher, delegate to it (e.g., VS Code's CLI).
// This avoids hijacking users who already have VS Code installed.
const maybeDelegateToOtherCode = () => {
try {
const invoked = process.env._ || "";
const invokedBase = path.basename(invoked);
if (invokedBase !== "code") return false;
const ourScriptReal = realpathSync(process.argv[1]);
const which = spawnSync("bash", ["-lc", "which -a code 2>/dev/null"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (which.status !== 0 || !which.stdout) return false;
const candidates = which.stdout
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
for (const c of candidates) {
// Skip the path that was used to invoke us
if (c === invoked) continue;
try {
const real = realpathSync(c);
// Skip if it resolves to our own script (same file inside our package)
if (real === ourScriptReal) continue;
// Ensure it's executable; if so, exec and mirror exit code
accessSync(c, constants.X_OK);
const run = spawnSync(c, process.argv.slice(2), {
stdio: "inherit",
});
// If it executed (even with non-zero), we consider delegation done.
const code = run.status == null ? 1 : run.status;
process.exit(code);
} catch {
// Try next candidate
}
}
} catch {
// Fall through to our own binary
}
return false;
};
maybeDelegateToOtherCode();
// Important: Never delegate to another system's `code` binary (e.g., VS Code).
// When users run via `npx @just-every/code`, we must always execute our
// packaged native binary by absolute path to avoid PATH collisions.
const isWSL = () => {
if (platform !== "linux") return false;
@@ -130,6 +82,7 @@ if (!existsSync(binaryPath)) {
// Check if binary exists and try to fix permissions if needed
import { existsSync, chmodSync, statSync, openSync, readSync, closeSync } from "fs";
import { spawnSync } from "child_process";
if (existsSync(binaryPath)) {
try {
// Ensure binary is executable on Unix-like systems
@@ -200,6 +153,33 @@ if (!validation.ok) {
process.exit(1);
}
// If running under npx/npm, emit a concise notice about which binary path is used
try {
const ua = process.env.npm_config_user_agent || "";
const isNpx = ua.includes("npx");
if (isNpx && process.stderr && process.stderr.isTTY) {
// Best-effort discovery of another 'code' on PATH for user clarity
let otherCode = "";
try {
const cmd = process.platform === "win32" ? "where code" : "command -v code || which code || true";
const out = spawnSync(process.platform === "win32" ? "cmd" : "bash", [
process.platform === "win32" ? "/c" : "-lc",
cmd,
], { encoding: "utf8" });
const line = (out.stdout || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
if (line && !line.includes("@just-every/code")) {
otherCode = line;
}
} catch {}
if (otherCode) {
console.error(`@just-every/code: running bundled binary -> ${binaryPath}`);
console.error(`Note: a different 'code' exists at ${otherCode}; not delegating.`);
} else {
console.error(`@just-every/code: running bundled binary -> ${binaryPath}`);
}
}
} catch {}
// Use an asynchronous spawn instead of spawnSync so that Node is able to
// respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is
// executing. This allows us to forward those signals to the child process
+32 -25
View File
@@ -364,20 +364,32 @@ async function main() {
const ourShim = join(globalBin || '', isWindows ? 'code.cmd' : 'code');
// Resolve which 'code' is currently on PATH
const resolveOnPath = (cmd) => {
// Resolve all 'code' candidates on PATH (so we detect collisions even if
// our npm global bin currently appears first).
const resolveAllOnPath = () => {
try {
if (isWindows) {
const out = execSync(`where ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().split(/\r?\n/)[0]?.trim();
return out || '';
} else {
return execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
const out = execSync('where code', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
}
} catch { return ''; }
// Prefer 'which -a' if available; fall back to 'command -v'
let out = '';
try {
out = execSync('bash -lc "which -a code 2>/dev/null"', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
} catch {
try {
out = execSync('command -v code || true', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
} catch { out = ''; }
}
return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
} catch {
return [];
}
};
const codeResolved = resolveOnPath('code');
const collision = codeResolved && ourShim && codeResolved !== ourShim;
const candidates = resolveAllOnPath();
const others = candidates.filter(p => p && ourShim && p !== ourShim);
const collision = others.length > 0;
const ensureWrapper = (name, args) => {
if (!globalBin) return;
@@ -402,26 +414,22 @@ async function main() {
ensureWrapper('code-exec', 'exec');
if (collision) {
console.log('⚠ Detected an existing `code` command on your PATH (likely VS Code).');
console.log('⚠ Detected existing `code` on PATH:');
for (const p of others) console.log(` - ${p}`);
if (globalBin) {
// Create a 'coder' shim that forwards to our installed 'code' in the same dir
// Remove our global `code` shim to avoid shadowing editors like VS Code
try {
const coderShim = join(globalBin, isWindows ? 'coder.cmd' : 'coder');
if (isWindows) {
const content = `@echo off\r\n"%~dp0code" %*\r\n`;
writeFileSync(coderShim, content);
} else {
const content = `#!/bin/sh\nexec "$(dirname \"$0\")/code" "$@"\n`;
writeFileSync(coderShim, content);
chmodSync(coderShim, 0o755);
if (existsSync(ourShim)) {
unlinkSync(ourShim);
console.log(`✓ Skipped global 'code' shim (removed ${ourShim})`);
}
console.log(`✓ Created fallback command \`coder\` -> our \`code\``);
} catch (e) {
console.log(`⚠ Failed to create 'coder' fallback: ${e.message}`);
console.log(`⚠ Could not remove npm shim '${ourShim}': ${e.message}`);
}
// Offer to create a 'vscode' alias that points to the existing system VS Code
if (isTTY && codeResolved) {
const primaryOther = others[0];
if (isTTY && primaryOther) {
const prompt = (msg) => {
process.stdout.write(msg);
try {
@@ -436,10 +444,10 @@ async function main() {
try {
const vscodeShim = join(globalBin, isWindows ? 'vscode.cmd' : 'vscode');
if (isWindows) {
const content = `@echo off\r\n"${codeResolved}" %*\r\n`;
const content = `@echo off\r\n"${primaryOther}" %*\r\n`;
writeFileSync(vscodeShim, content);
} else {
const content = `#!/bin/sh\nexec "${codeResolved}" "$@"\n`;
const content = `#!/bin/sh\nexec "${primaryOther}" "$@"\n`;
writeFileSync(vscodeShim, content);
chmodSync(vscodeShim, 0o755);
}
@@ -451,7 +459,6 @@ async function main() {
console.log('Skipping creation of `vscode` alias.');
}
}
console.log('→ Use `coder` to run this tool, and `vscode` (if created) for your editor.');
} else {
console.log('Note: could not determine npm global bin; skipping alias creation.');
+6 -6
View File
@@ -5,14 +5,14 @@
"scripts": {
"format": "prettier --check *.json *.md .github/workflows/*.yml **/*.js",
"format:fix": "prettier --write *.json *.md .github/workflows/*.yml **/*.js",
"build": "cd codex-rs && cargo build --release --bin code --bin code-tui --bin code-exec",
"build:dev": "cd codex-rs && cargo build --bin code --bin code-tui --bin code-exec",
"build:quick": "cd codex-rs && cargo build --release --bin code",
"test:local": "cd codex-cli && npm link && code --version",
"fix": "cargo fmt && cargo fix --allow-dirty && cargo clippy --fix --allow-dirty"
"build": "cd codex-rs && cargo build --release --bin code --bin code-tui --bin code-exec",
"build:dev": "cd codex-rs && cargo build --bin code --bin code-tui --bin code-exec",
"build:quick": "cd codex-rs && cargo build --release --bin code",
"test:local": "cd codex-cli && npm link && code --version",
"fix": "cargo fmt && cargo fix --allow-dirty && cargo clippy --fix --allow-dirty"
},
"devDependencies": {
"prettier": "^3.5.3"
"prettier": "^3.6.2"
},
"resolutions": {
"braces": "^3.0.3",
+5 -5
View File
@@ -14,16 +14,16 @@ importers:
.:
devDependencies:
prettier:
specifier: ^3.5.3
version: 3.5.3
specifier: ^3.6.2
version: 3.6.2
packages:
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
snapshots:
prettier@3.5.3: {}
prettier@3.6.2: {}