Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela 86b554cffc Harden Node.js custom install 2026-02-24 20:21:14 +01:00
2 changed files with 162 additions and 34 deletions

No files matched your search

@@ -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,
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)).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,
stdout: 'v20.18.0\nv20.18.1\n',
});
}
return Promise.resolve(spawnResult);
});
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);
});
});
});
+65 -21
View File
@@ -135,33 +135,77 @@ 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}`);
const { stdout } = await spawn(
'bash',
['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`],
{
logger: ctx.logger,
env: ctx.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}`);
}
sanitizedVersion = await installNodeWithNvm(ctx, version, sanitizedVersion, ctx.env);
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install Node.js v${version}\n`);
throw new SystemDepsInstallError('Node.js');
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<string> {
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<void> {
try {
const { stdout } = await spawn(
'bash',
['-c', 'source ~/.nvm/nvm.sh && nvm ls-remote'],
{
stdio: 'pipe',
env,
}
);
ctx.logger.info(`nvm ls-remote output:\n${stdout}`);
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to run nvm ls-remote');
}
}
async function installBundler(ctx: PreDownloadBuildContext, version: string): Promise<void> {
try {
ctx.logger.info(`Installing bundler ${version}`);