fix(tools): spawn npm.cmd through a shell on Windows (#3923)

* fix(tools): spawn npm.cmd through a shell on Windows

* fix(tools): join the npm command line on Windows to avoid DEP0190

Passing args alongside shell: true warns on Node 24. Test the spawn
tuple as data and run a real npm spawn instead of mocking child_process.

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
Rasad RegmiandDaniel Salazar authored and GitHub committed 2026-09-23 18:54:43 -07:00
1 parent 9f69483593
commit 83de67cca9
5 files changed
+63 -7

No files matched your search

+2
View File
@@ -112,6 +112,8 @@ export default defineConfig(({ mode }) => ({
// The MCP connector's signed-upload tools call the `/fs` HTTP API
// directly, so their tests need a booted backend (`setupPuterTestEnv`).
'src/mcp-connector/**/*.test.{js,ts}',
// Root-level tools/ scripts are exercised through this suite.
'tools/**/*.test.mjs',
],
// Root is the repo root so that the file transformer (which
// applies `lowerDecoratorsPlugin`) sees both src/backend and
+2 -4
View File
@@ -5,8 +5,8 @@
// the previous bash version.
import { existsSync, readdirSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import { spawnNpm } from './npmSpawn.mjs';
const EXT_DIR = './extensions';
@@ -23,13 +23,11 @@ if (dirs.length === 0) {
process.exit(0);
}
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
function install(dir) {
return new Promise((resolve, reject) => {
const args = existsSync(join(dir, 'package-lock.json')) ? ['ci'] : ['install'];
console.log(`[${dir}] starting npm ${args.join(' ')}`);
const child = spawn(npmCmd, args, { cwd: dir });
const child = spawnNpm(args, { cwd: dir });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
+21
View File
@@ -0,0 +1,21 @@
// Shared helper for spawning npm from the repo's tools/ scripts.
//
// On Windows `npm` resolves to the `npm.cmd` batch shim, which Node refuses
// to spawn without a shell (`spawn EINVAL`). Node also warns when args are
// passed alongside `shell: true` (DEP0190), so the command line is joined
// here; args must be plain words that need no quoting.
import { spawn } from 'node:child_process';
/** `[command, args, options]` for `spawn`. */
export const npmSpawnArgs = (
args,
options = {},
platform = process.platform,
) =>
platform === 'win32'
? [['npm.cmd', ...args].join(' '), [], { ...options, shell: true }]
: ['npm', args, options];
export const spawnNpm = (args, options = {}) =>
spawn(...npmSpawnArgs(args, options));
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { npmSpawnArgs, spawnNpm } from './npmSpawn.mjs';
describe('npmSpawnArgs', () => {
it('runs npm.cmd through a shell on Windows, with no separate args', () => {
expect(
npmSpawnArgs(['run', 'build:ts'], { cwd: '.' }, 'win32'),
).toEqual(['npm.cmd run build:ts', [], { cwd: '.', shell: true }]);
});
it('spawns npm directly without a shell elsewhere', () => {
for (const platform of ['linux', 'darwin']) {
expect(npmSpawnArgs(['ci'], { cwd: '.' }, platform)).toEqual([
'npm',
['ci'],
{ cwd: '.' },
]);
}
});
});
describe('spawnNpm', () => {
it('runs npm on the current platform', async () => {
const child = spawnNpm(['--version']);
let out = '';
child.stdout.on('data', (d) => (out += d));
const code = await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', resolve);
});
expect(code).toBe(0);
expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/);
}, 20_000);
});
+4 -3
View File
@@ -23,6 +23,8 @@ import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { npmSpawnArgs } from './npmSpawn.mjs';
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
// `npm start --flag=x` arrives as the npm_config_<flag> env var;
@@ -51,7 +53,6 @@ const run = (cmd, args, opts = {}) => new Promise((resolve, reject) => {
});
});
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const server = getFlag('server');
const extensions = getFlag('extensions');
@@ -66,8 +67,8 @@ try {
if ( extensions ) {
console.warn('--extensions only applies to --server (GUI-only) mode; ignoring.');
}
await run(npm, ['run', 'setupExtensions']);
await run(npm, ['run', 'build:ts']);
await run(...npmSpawnArgs(['run', 'setupExtensions']));
await run(...npmSpawnArgs(['run', 'build:ts']));
await run(process.execPath, [
'--enable-source-maps',
'-r', './dist/src/backend/telemetry.js',