From 86b554cffc16f9178731b1371d21185f3a9a2bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Tue, 24 Feb 2026 20:21:14 +0100 Subject: [PATCH] Harden Node.js custom install --- .../src/__unit__/runtimeEnvironment.test.ts | 110 +++++++++++++++--- packages/worker/src/runtimeEnvironment.ts | 80 ++++++++++--- 2 files changed, 159 insertions(+), 31 deletions(-) diff --git a/packages/worker/src/__unit__/runtimeEnvironment.test.ts b/packages/worker/src/__unit__/runtimeEnvironment.test.ts index ad1fd429aa..545a3e12ce 100644 --- a/packages/worker/src/__unit__/runtimeEnvironment.test.ts +++ b/packages/worker/src/__unit__/runtimeEnvironment.test.ts @@ -31,6 +31,12 @@ const builderConfig: Ios.BuilderEnvironment | Android.BuilderEnvironment = {}; describe('prepareRuntimeEnvironment', () => { beforeEach(() => { jest.mocked(spawn).mockReset(); + jest.mocked(pathExists).mockReset(); + jest.mocked(ctx.logger.info).mockReset(); + jest.mocked(ctx.logger.warn).mockReset(); + jest.mocked(ctx.logger.error).mockReset(); + ctx.env = { ...process.env }; + builderConfig.node = undefined; }); describe('installNode', () => { @@ -48,7 +54,6 @@ describe('prepareRuntimeEnvironment', () => { } return Promise.resolve(spawnResult); }); - jest.mocked(spawn).mockResolvedValue(spawnResult); }); it('should install the specified version of Node.js', async () => { @@ -159,30 +164,109 @@ describe('prepareRuntimeEnvironment', () => { }); }); - it('should throw an error if installation fails', async () => { + it('logs nvm ls-remote output after install failure', async () => { const version = 'invalid-version'; builderConfig.node = version; - jest.mocked(spawn).mockImplementation((cmd, _args, _opts) => { - if (cmd === 'bash') { + jest.mocked(spawn).mockImplementation((cmd, args, _opts) => { + if (cmd === 'bash' && args?.[1]?.includes('nvm install')) { + return Promise.reject(new Error('nvm install failed')); + } + if (cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')) { + return Promise.resolve({ + ...spawnResult, + stdout: 'v20.18.0\nv20.18.1\n', + }); + } + return Promise.resolve(spawnResult); + }); + jest.mocked(pathExists).mockResolvedValue(true); + + await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).rejects.toThrow( + 'Failed to install Node.js' + ); + expect(spawn).toHaveBeenCalledWith( + 'bash', + ['-c', 'source ~/.nvm/nvm.sh && nvm ls-remote'], + expect.objectContaining({ stdio: 'pipe', env: ctx.env }) + ); + expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining('nvm ls-remote output')); + }); + + it('retries node install without NVM_NODEJS_ORG_MIRROR', async () => { + const version = '20.18.1'; + builderConfig.node = version; + ctx.env.NVM_NODEJS_ORG_MIRROR = 'https://example-mirror.invalid'; + + let installAttempt = 0; + jest.mocked(spawn).mockImplementation((cmd, args, _opts) => { + if (cmd === 'bash' && args?.[1]?.includes('nvm install')) { + installAttempt += 1; + if (installAttempt === 1) { + return Promise.reject(new Error('first install failed')); + } + return Promise.resolve({ + ...spawnResult, + stdout: + 'Downloading and installing node v20.18.1...\nNow using node v20.18.1 (npm v10.8.2)\n', + }); + } + if (cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')) { return Promise.resolve({ ...spawnResult, - output: [ - '', - "Version 'invalid-version' not found - try `nvm ls-remote` to browse available versions.\n", - ], - stdout: '', - stderr: - "Version 'invalid-version' not found - try `nvm ls-remote` to browse available versions.\n", - status: 3, + stdout: 'v20.18.0\nv20.18.1\n', }); } return Promise.resolve(spawnResult); }); - jest.mocked(pathExists).mockResolvedValue(false); + jest.mocked(pathExists).mockResolvedValue(true); + + await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).resolves.toBeUndefined(); + + const installCalls = jest + .mocked(spawn) + .mock.calls.filter(([cmd, args]) => cmd === 'bash' && args?.[1]?.includes('nvm install')); + expect(installCalls).toHaveLength(2); + + const firstAttemptEnv = installCalls[0][2].env; + const secondAttemptEnv = installCalls[1][2].env; + expect(firstAttemptEnv.NVM_NODEJS_ORG_MIRROR).toBe('https://example-mirror.invalid'); + expect(secondAttemptEnv.NVM_NODEJS_ORG_MIRROR).toBeUndefined(); + }); + + it('throws when retry also fails', async () => { + const version = 'invalid-version'; + builderConfig.node = version; + ctx.env.NVM_NODEJS_ORG_MIRROR = 'https://example-mirror.invalid'; + + let installAttempt = 0; + jest.mocked(spawn).mockImplementation((cmd, args, _opts) => { + if (cmd === 'bash' && args?.[1]?.includes('nvm install')) { + installAttempt += 1; + return Promise.reject(new Error(`nvm install failed on attempt ${installAttempt}`)); + } + if (cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')) { + return Promise.resolve({ + ...spawnResult, + stdout: 'v20.18.0\n', + }); + } + return Promise.resolve(spawnResult); + }); + jest.mocked(pathExists).mockResolvedValue(true); + await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).rejects.toThrow( 'Failed to install Node.js' ); + + const installCalls = jest + .mocked(spawn) + .mock.calls.filter(([cmd, args]) => cmd === 'bash' && args?.[1]?.includes('nvm install')); + const lsRemoteCalls = jest + .mocked(spawn) + .mock.calls.filter(([cmd, args]) => cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')); + expect(installCalls).toHaveLength(2); + expect(lsRemoteCalls).toHaveLength(2); }); }); }); diff --git a/packages/worker/src/runtimeEnvironment.ts b/packages/worker/src/runtimeEnvironment.ts index b616a592d1..62949bb8b2 100644 --- a/packages/worker/src/runtimeEnvironment.ts +++ b/packages/worker/src/runtimeEnvironment.ts @@ -135,31 +135,75 @@ async function installNode(ctx: PreDownloadBuildContext, version: string): Promi let sanitizedVersion = version.startsWith('v') ? version.slice(1) : version; try { ctx.logger.info(`Installing node v${sanitizedVersion}`); + sanitizedVersion = await installNodeWithNvm(ctx, version, sanitizedVersion, ctx.env); + } catch (err: any) { + ctx.logger.error({ err }, `Failed to install Node.js v${version}\n`); + await logNvmLsRemoteOutput(ctx, ctx.env); + if (!ctx.env.NVM_NODEJS_ORG_MIRROR) { + throw new SystemDepsInstallError('Node.js'); + } + + const retryEnv = { ...ctx.env }; + delete retryEnv.NVM_NODEJS_ORG_MIRROR; + ctx.logger.warn( + `Retrying Node.js install for v${version} after unsetting NVM_NODEJS_ORG_MIRROR` + ); + try { + sanitizedVersion = await installNodeWithNvm(ctx, version, sanitizedVersion, retryEnv); + } catch (retryErr: any) { + ctx.logger.error( + { err: retryErr }, + `Failed to install Node.js v${version} after retrying without NVM_NODEJS_ORG_MIRROR\n` + ); + await logNvmLsRemoteOutput(ctx, retryEnv); + throw new SystemDepsInstallError('Node.js'); + } + } + return sanitizedVersion; +} + +async function installNodeWithNvm( + ctx: PreDownloadBuildContext, + version: string, + sanitizedVersion: string, + env: PreDownloadBuildContext['env'] +): Promise { + const { stdout } = await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`], { + logger: ctx.logger, + env, + }); + + const installedNodeVersion = stdout.match(/Now using node v(\d+\.\d+\.\d+)/)?.[1] ?? sanitizedVersion; + await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm alias default ${installedNodeVersion}`], { + logger: ctx.logger, + env, + }); + const nodeDir = `${os.homedir()}/.nvm/versions/node/v${installedNodeVersion}`; + ctx.env.PATH = `${nodeDir}/bin:${ctx.env.PATH}`; + const nodeBinPath = `${nodeDir}/bin/node`; + if (!(await fs.pathExists(nodeBinPath))) { + throw new Error(`node executable was not found in ${nodeBinPath}`); + } + return installedNodeVersion; +} + +async function logNvmLsRemoteOutput( + ctx: PreDownloadBuildContext, + env: PreDownloadBuildContext['env'] +): Promise { + try { const { stdout } = await spawn( 'bash', - ['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`], + ['-c', 'source ~/.nvm/nvm.sh && nvm ls-remote'], { - logger: ctx.logger, - env: ctx.env, + stdio: 'pipe', + env, } ); - - sanitizedVersion = stdout.match(/Now using node v(\d+\.\d+\.\d+)/)?.[1] ?? sanitizedVersion; - await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm alias default ${sanitizedVersion}`], { - logger: ctx.logger, - env: ctx.env, - }); - const nodeDir = `${os.homedir()}/.nvm/versions/node/v${sanitizedVersion}`; - ctx.env.PATH = `${nodeDir}/bin:${ctx.env.PATH}`; - const nodeBinPath = `${nodeDir}/bin/node`; - if (!(await fs.pathExists(nodeBinPath))) { - throw new Error(`node executable was not found in ${nodeBinPath}`); - } + ctx.logger.info(`nvm ls-remote output:\n${stdout}`); } catch (err: any) { - ctx.logger.error({ err }, `Failed to install Node.js v${version}\n`); - throw new SystemDepsInstallError('Node.js'); + ctx.logger.warn({ err }, 'Failed to run nvm ls-remote'); } - return sanitizedVersion; } async function installBundler(ctx: PreDownloadBuildContext, version: string): Promise {