From 205d8e28b14dd923b9c6227fd045a870b3aee7d9 Mon Sep 17 00:00:00 2001 From: Rupak Raj Date: Fri, 7 Aug 2026 13:00:08 +0530 Subject: [PATCH] fix: app:pack aborts when Node writes process warnings to api-mesh stderr `createDeployYamlFile` treats any output on the `aio api-mesh get --json` child's stderr as a fatal error. Node process warnings (e.g. DEP0040, the punycode deprecation emitted on Node 22) are written to stderr while the command exits 0, so `app:pack` aborts on a successful mesh lookup with the deprecation notice presented as the error. The stderr channel is still used to detect the "no mesh found" case, since the api-mesh plugin reports it via `this.error(msg, { exit: false })` which writes to stderr and exits 0. Rather than removing that check, Node process warnings are suppressed in the child via NODE_NO_WARNINGS and any that still reach stderr are stripped before the check. NODE_NO_WARNINGS is used instead of NODE_OPTIONS so a user-set NODE_OPTIONS is preserved. Also fixes an unguarded property access in the same catch block: `err?.message` stops one level short, so a thrown value without a `message` (a non-Error throw, or an execa error carrying output only on `stderr`) raised a TypeError inside the catch and masked the original failure. The existing test asserted that TypeError as expected behaviour; it now asserts that the "no mesh" message on `stderr` is recognised. Error details now go to aioLogger.debug instead of console.error, which double-reported since `err` is rethrown. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/app/pack.js | 46 ++++++++++++++--- test/commands/app/pack.test.js | 92 +++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/src/commands/app/pack.js b/src/commands/app/pack.js index dd83c478..de50a874 100644 --- a/src/commands/app/pack.js +++ b/src/commands/app/pack.js @@ -31,6 +31,25 @@ const DEFAULTS = { DEPLOY_YAML_FILE_NAME: 'deploy.yaml' } +// matches Node process warnings, e.g. +// (node:1234) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. +// (Use `node --trace-deprecation ...` to show where the warning was created) +const NODE_WARNING_LINE = /^\(node:\d+\)|^\(Use `node --trace-/ + +/** + * Removes Node process warning lines from a child process' stderr. + * + * @param {string} stderr the raw stderr of a child process + * @returns {string} the stderr with any Node process warnings removed + */ +function stripNodeWarnings (stderr) { + return (stderr ?? '') + .split('\n') + .filter(line => !NODE_WARNING_LINE.test(line)) + .join('\n') + .trim() +} + class Pack extends BaseCommand { async run () { const { args, flags } = await this.parse(Pack) @@ -195,21 +214,34 @@ class Pack extends BaseCommand { if (command) { try { this.spinner.start('Getting api-mesh config...') - const { stdout, stderr } = await execa('aio', ['api-mesh', 'get', '--json'], { cwd: process.cwd() }) - - if (stderr) { - throw new Error(stderr) + // the child's stderr is used below to detect the "no mesh" case, so Node process + // warnings (e.g. DEP0040 punycode) must not pollute it. NODE_NO_WARNINGS is used + // rather than NODE_OPTIONS so that a user-set NODE_OPTIONS is preserved (execa + // merges `env` with process.env by default). + const { stdout, stderr } = await execa('aio', ['api-mesh', 'get', '--json'], { + cwd: process.cwd(), + env: { NODE_NO_WARNINGS: '1' } + }) + + // defensively strip any Node process-warning lines that still reach stderr + const meshStderr = stripNodeWarnings(stderr) + + if (meshStderr) { + throw new Error(meshStderr) } meshConfig = JSON.parse(stdout).meshConfig aioLogger.debug(`api-mesh:get - ${JSON.stringify(meshConfig, null, 2)}`) this.spinner.succeed('Got api-mesh config') } catch (err) { - // Ignore error if no mesh found, otherwise throw - if (err?.message.includes('Error: Unable to get mesh config.')) { + // Ignore error if no mesh found, otherwise throw. + // Thrown execa errors carry the child's output on `stderr`, which is not always + // reflected in `message` (and `message` is absent entirely for non-Error throws). + const details = [err?.message, err?.stderr].filter(Boolean).join('\n') + if (details.includes('Unable to get mesh config.')) { aioLogger.debug('No api-mesh config found') } else { - console.error(err) + aioLogger.debug(`api-mesh:get failed - ${details || err}`) throw err } } diff --git a/test/commands/app/pack.test.js b/test/commands/app/pack.test.js index 2d6319f1..550407ea 100644 --- a/test/commands/app/pack.test.js +++ b/test/commands/app/pack.test.js @@ -204,7 +204,97 @@ test('createDeployYamlFile (1 extension), no api-mesh, plugin throws error', asy } }) - await expect(command.createDeployYamlFile(extConfig)).rejects.toEqual(TypeError('Cannot read properties of undefined (reading \'includes\')')) + // the thrown value carries the "no mesh" message on `stderr` and has no `message`, + // so it must be treated as "no mesh found" rather than crashing on `message.includes` + await command.createDeployYamlFile(extConfig) + + await expect(importHelper.writeFile.mock.calls[0][0]).toMatch(path.join('dist', 'app-package', 'deploy.yaml')) + await expect(importHelper.writeFile.mock.calls[0][1]).toMatchFixture('pack/2.deploy.no-mesh.yaml') + await expect(importHelper.writeFile.mock.calls[0][2]).toMatchObject({ overwrite: true }) +}) + +test('createDeployYamlFile (1 extension), api-mesh stderr has Node process warnings', async () => { + const extConfig = fixtureJson('pack/2.all.config.json') + const meshOutput = fixtureFile('pack/3.api-mesh.get.json') + + const command = new TheCommand() + command.argv = [] + command.config = { + findCommand: jest.fn().mockReturnValue({}), + runCommand: jest.fn(), + runHook: jest.fn().mockResolvedValue({ successes: [] }) + } + + execa.mockImplementationOnce((cmd, args, opts) => { + expect(cmd).toEqual('aio') + expect(args).toEqual(['api-mesh', 'get', '--json']) + // Node process warnings must be suppressed in the child + expect(opts.env).toMatchObject({ NODE_NO_WARNINGS: '1' }) + + return { + stdout: meshOutput, + stderr: [ + '(node:12345) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.', + '(Use `node --trace-deprecation ...` to show where the warning was created)' + ].join('\n') + } + }) + + // warnings on stderr are not errors: the mesh config must still be picked up + await command.createDeployYamlFile(extConfig) + + await expect(importHelper.writeFile.mock.calls[0][0]).toMatch(path.join('dist', 'app-package', 'deploy.yaml')) + await expect(importHelper.writeFile.mock.calls[0][1]).toMatchFixture('pack/2.deploy.yaml') + await expect(importHelper.writeFile.mock.calls[0][2]).toMatchObject({ overwrite: true }) +}) + +test('createDeployYamlFile (1 extension), api-mesh real error mixed with Node process warnings', async () => { + const extConfig = fixtureJson('pack/2.all.config.json') + + const command = new TheCommand() + command.argv = [] + command.config = { + findCommand: jest.fn().mockReturnValue({}), + runCommand: jest.fn(), + runHook: jest.fn().mockResolvedValue({ successes: [] }) + } + + execa.mockImplementationOnce((cmd, args) => { + expect(cmd).toEqual('aio') + expect(args).toEqual(['api-mesh', 'get', '--json']) + + return { + stderr: [ + '(node:12345) [DEP0040] DeprecationWarning: The `punycode` module is deprecated.', + 'Error: api-mesh service is unavailable' + ].join('\n') + } + }) + + // the warning is stripped, the real error is still surfaced + await expect(command.createDeployYamlFile(extConfig)).rejects.toEqual(Error('Error: api-mesh service is unavailable')) +}) + +test('createDeployYamlFile (1 extension), api-mesh throws a value with no message or stderr', async () => { + const extConfig = fixtureJson('pack/2.all.config.json') + + const command = new TheCommand() + command.argv = [] + command.config = { + findCommand: jest.fn().mockReturnValue({}), + runCommand: jest.fn(), + runHook: jest.fn().mockResolvedValue({ successes: [] }) + } + + execa.mockImplementationOnce((cmd, args) => { + expect(cmd).toEqual('aio') + expect(args).toEqual(['api-mesh', 'get', '--json']) + // eslint-disable-next-line no-throw-literal + throw { code: 'ENOENT' } + }) + + // must rethrow the original value, not crash while inspecting it + await expect(command.createDeployYamlFile(extConfig)).rejects.toEqual({ code: 'ENOENT' }) }) test('createDeployYamlFile (1 extension), api-mesh get call throws non 404 error', async () => {