Skip to content

Commit a28840c

Browse files
NathanFlurryclaude
andcommitted
feat: bare command PATH resolution via node_modules/.bin
Node runtime's tryResolve() now resolves bare commands (e.g. 'pi') by checking node_modules/.bin on the host filesystem. Supports both pnpm shell wrappers and npm/yarn direct Node.js scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf3ac85 commit a28840c

2 files changed

Lines changed: 168 additions & 1 deletion

File tree

packages/nodejs/src/kernel-runtime.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* or other mounted runtimes.
99
*/
1010

11-
import { existsSync, readFileSync } from 'node:fs';
11+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
1212
import * as fsPromises from 'node:fs/promises';
1313
import { dirname, join, resolve } from 'node:path';
1414
import type {
@@ -404,9 +404,56 @@ class NodeRuntimeDriver implements RuntimeDriver {
404404
tryResolve(command: string): boolean {
405405
// Handle .js/.mjs/.cjs file paths as node scripts
406406
if (/\.[cm]?js$/.test(command)) return true;
407+
// Handle bare commands resolvable via node_modules/.bin
408+
if (this._resolveBinCommand(command) !== null) return true;
407409
return false;
408410
}
409411

412+
/**
413+
* Resolve a bare command name (e.g. 'pi') to a JS entry point via
414+
* node_modules/.bin on the host filesystem. Returns the VFS path
415+
* (e.g. '/root/node_modules/@pkg/dist/cli.js') or null if not found.
416+
*
417+
* Handles two formats:
418+
* 1. pnpm shell wrappers: parse `"$basedir/<relative-path>.js"` from the script
419+
* 2. npm/yarn symlinks or direct JS files: follow to the .js target
420+
*/
421+
private _resolveBinCommand(command: string): string | null {
422+
if (!this._moduleAccessCwd) return null;
423+
const binPath = join(this._moduleAccessCwd, 'node_modules', '.bin', command);
424+
try {
425+
const content = readFileSync(binPath, 'utf-8');
426+
// Direct Node.js script (#!/usr/bin/env node or #!/path/to/node)
427+
if (/^#!.*\bnode\b/.test(content)) {
428+
// The .bin file itself is a JS entry — resolve its real path
429+
// in case it's a symlink (npm/yarn), then map to VFS path
430+
const realPath = realpathSync(binPath);
431+
const nmDir = join(this._moduleAccessCwd, 'node_modules');
432+
if (realPath.startsWith(nmDir)) {
433+
return '/root/node_modules/' + realPath.slice(nmDir.length + 1);
434+
}
435+
// Fallback: use the .bin path itself
436+
return `/root/node_modules/.bin/${command}`;
437+
}
438+
// pnpm/yarn shell wrapper — extract JS path from: "$basedir/<path>.{js,mjs,cjs}"
439+
const match = content.match(/"\$basedir\/([^"]+\.[cm]?js)"/);
440+
if (match) {
441+
// Resolve relative to node_modules/.bin/ on host
442+
const resolved = resolve(
443+
join(this._moduleAccessCwd, 'node_modules', '.bin'),
444+
match[1],
445+
);
446+
const nmDir = join(this._moduleAccessCwd, 'node_modules');
447+
if (resolved.startsWith(nmDir)) {
448+
return '/root/node_modules/' + resolved.slice(nmDir.length + 1);
449+
}
450+
}
451+
} catch {
452+
// File doesn't exist or isn't readable
453+
}
454+
return null;
455+
}
456+
410457
spawn(command: string, args: string[], ctx: ProcessContext): DriverProcess {
411458
const kernel = this._kernel;
412459
if (!kernel) throw new Error('Node driver not initialized');
@@ -763,6 +810,12 @@ class NodeRuntimeDriver implements RuntimeDriver {
763810
return this._resolveNodeArgs([command, ...args], kernel);
764811
}
765812

813+
// Bare command — resolve from node_modules/.bin (e.g. 'pi' → '/root/node_modules/.../cli.js')
814+
const binEntry = this._resolveBinCommand(command);
815+
if (binEntry) {
816+
return this._resolveNodeArgs([binEntry, ...args], kernel);
817+
}
818+
766819
// 'node' command — parse args to find code/script
767820
return this._resolveNodeArgs(args, kernel);
768821
}

packages/nodejs/test/kernel-runtime.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
*/
88

99
import { describe, it, expect, afterEach } from 'vitest';
10+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
11+
import { join } from 'node:path';
12+
import { tmpdir } from 'node:os';
1013
import { createNodeRuntime } from '../src/kernel-runtime.ts';
1114
import type { NodeRuntimeOptions } from '../src/kernel-runtime.ts';
1215
import { createKernel } from '@secure-exec/core';
@@ -766,4 +769,115 @@ describe('Node RuntimeDriver', () => {
766769
expect(output).toContain('STEP:3');
767770
});
768771
});
772+
773+
describe('bare command resolution from node_modules/.bin', () => {
774+
let kernel: Kernel;
775+
let tmpDir: string;
776+
777+
function createMockBinDir() {
778+
tmpDir = join(tmpdir(), `se-bin-test-${Date.now()}`);
779+
const binDir = join(tmpDir, 'node_modules', '.bin');
780+
const pkgDir = join(tmpDir, 'node_modules', 'my-tool', 'dist');
781+
mkdirSync(binDir, { recursive: true });
782+
mkdirSync(pkgDir, { recursive: true });
783+
// Create a real JS entry file
784+
writeFileSync(
785+
join(pkgDir, 'cli.js'),
786+
'console.log("hello from bare command");',
787+
);
788+
// Create a pnpm-style shell wrapper in .bin
789+
writeFileSync(
790+
join(binDir, 'my-tool'),
791+
[
792+
'#!/bin/sh',
793+
'basedir=$(dirname "$(echo "$0" | sed -e \'s,\\\\,/,g\')")',
794+
'exec node "$basedir/../my-tool/dist/cli.js" "$@"',
795+
].join('\n'),
796+
{ mode: 0o755 },
797+
);
798+
return tmpDir;
799+
}
800+
801+
afterEach(async () => {
802+
await kernel?.dispose();
803+
if (tmpDir) {
804+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
805+
}
806+
});
807+
808+
it('tryResolve returns true for bare command in node_modules/.bin', () => {
809+
createMockBinDir();
810+
const driver = createNodeRuntime({ moduleAccessCwd: tmpDir });
811+
expect(driver.tryResolve!('my-tool')).toBe(true);
812+
});
813+
814+
it('tryResolve returns false for unknown bare command', () => {
815+
createMockBinDir();
816+
const driver = createNodeRuntime({ moduleAccessCwd: tmpDir });
817+
expect(driver.tryResolve!('nonexistent-tool')).toBe(false);
818+
});
819+
820+
it('tryResolve returns false when moduleAccessCwd is not set', () => {
821+
const driver = createNodeRuntime();
822+
expect(driver.tryResolve!('my-tool')).toBe(false);
823+
});
824+
825+
it('bare command executes the resolved JS entry point', async () => {
826+
createMockBinDir();
827+
const vfs = new SimpleVFS();
828+
kernel = createKernel({ filesystem: vfs as any });
829+
await kernel.mount(createNodeRuntime({ moduleAccessCwd: tmpDir }));
830+
831+
const chunks: Uint8Array[] = [];
832+
const proc = kernel.spawn('my-tool', [], {
833+
onStdout: (data) => chunks.push(data),
834+
});
835+
const code = await proc.wait();
836+
expect(code).toBe(0);
837+
838+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
839+
expect(output).toContain('hello from bare command');
840+
});
841+
842+
it('bare command runs successfully even when args are passed', async () => {
843+
createMockBinDir();
844+
const vfs = new SimpleVFS();
845+
kernel = createKernel({ filesystem: vfs as any });
846+
await kernel.mount(createNodeRuntime({ moduleAccessCwd: tmpDir }));
847+
848+
// Spawn with extra args — should not crash
849+
const chunks: Uint8Array[] = [];
850+
const proc = kernel.spawn('my-tool', ['--flag', 'value'], {
851+
onStdout: (data) => chunks.push(data),
852+
});
853+
const code = await proc.wait();
854+
expect(code).toBe(0);
855+
856+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
857+
expect(output).toContain('hello from bare command');
858+
});
859+
860+
it('handles direct node shebang scripts (npm/yarn symlink style)', async () => {
861+
createMockBinDir();
862+
// Replace the shell wrapper with a direct node script
863+
writeFileSync(
864+
join(tmpDir, 'node_modules', '.bin', 'my-tool'),
865+
'#!/usr/bin/env node\nconsole.log("direct node script");',
866+
{ mode: 0o755 },
867+
);
868+
const vfs = new SimpleVFS();
869+
kernel = createKernel({ filesystem: vfs as any });
870+
await kernel.mount(createNodeRuntime({ moduleAccessCwd: tmpDir }));
871+
872+
const chunks: Uint8Array[] = [];
873+
const proc = kernel.spawn('my-tool', [], {
874+
onStdout: (data) => chunks.push(data),
875+
});
876+
const code = await proc.wait();
877+
expect(code).toBe(0);
878+
879+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
880+
expect(output).toContain('direct node script');
881+
});
882+
});
769883
});

0 commit comments

Comments
 (0)