Skip to content

Commit c2c62dd

Browse files
Lms24andreiborza
andauthored
ref(clack-utils): Use child_process spawn instead of exec when installing package (#859)
Fixes #851 - child_process spawn instead of exec - pipe std streams to fix #851 but ignore stdout - send stacktrace-less error to sentry if install fails --------- Co-authored-by: Andrei Borza <andrei.borza@sentry.io>
1 parent 40f62ce commit c2c62dd

File tree

4 files changed

+150
-72
lines changed

4 files changed

+150
-72
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
## Unreleased
44

55
- feat(nextjs): Remove react component annotation prompt and insertion ([#858](https://github.com/getsentry/sentry-wizard/pull/858))
6-
- Prevent addition of multiple `sentry:sourcemaps` commands ([#840](https://github.com/getsentry/sentry-wizard/pull/840))
6+
- fix: Prevent addition of multiple `sentry:sourcemaps` commands ([#840](https://github.com/getsentry/sentry-wizard/pull/840))
7+
- ref(clack-utils): Use child_process spawn instead of exec when to install package ([#859](https://github.com/getsentry/sentry-wizard/pull/859))
78

89
## 4.4.0
910

src/utils/clack-utils.ts

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,18 @@ export async function confirmContinueIfPackageVersionNotSupported({
332332
});
333333
}
334334

335+
type InstallPackageOptions = {
336+
/** The string that is passed to the package manager CLI as identifier to install (e.g. `@sentry/nextjs`, or `@sentry/nextjs@^8`) */
337+
packageName: string;
338+
alreadyInstalled: boolean;
339+
askBeforeUpdating?: boolean;
340+
/** Overrides what is shown in the installation logs in place of the `packageName` option. Useful if the `packageName` is ugly (e.g. `@sentry/nextjs@^8`) */
341+
packageNameDisplayLabel?: string;
342+
packageManager?: PackageManager;
343+
/** Add force install flag to command to skip install precondition fails */
344+
forceInstall?: boolean;
345+
};
346+
335347
/**
336348
* Installs or updates a package with the user's package manager.
337349
*
@@ -345,17 +357,7 @@ export async function installPackage({
345357
packageNameDisplayLabel,
346358
packageManager,
347359
forceInstall = false,
348-
}: {
349-
/** The string that is passed to the package manager CLI as identifier to install (e.g. `@sentry/nextjs`, or `@sentry/nextjs@^8`) */
350-
packageName: string;
351-
alreadyInstalled: boolean;
352-
askBeforeUpdating?: boolean;
353-
/** Overrides what is shown in the installation logs in place of the `packageName` option. Useful if the `packageName` is ugly (e.g. `@sentry/nextjs@^8`) */
354-
packageNameDisplayLabel?: string;
355-
packageManager?: PackageManager;
356-
/** Add force install flag to command to skip install precondition fails */
357-
forceInstall?: boolean;
358-
}): Promise<{ packageManager?: PackageManager }> {
360+
}: InstallPackageOptions): Promise<{ packageManager?: PackageManager }> {
359361
return traceStep('install-package', async () => {
360362
if (alreadyInstalled && askBeforeUpdating) {
361363
const shouldUpdatePackage = await abortIfCancelled(
@@ -383,31 +385,81 @@ export async function installPackage({
383385

384386
try {
385387
await new Promise<void>((resolve, reject) => {
386-
childProcess.exec(
387-
`${pkgManager.installCommand} ${packageName} ${pkgManager.flags} ${
388-
forceInstall ? pkgManager.forceInstallFlag : ''
389-
}`,
390-
(err, stdout, stderr) => {
391-
if (err) {
392-
// Write a log file so we can better troubleshoot issues
393-
fs.writeFileSync(
394-
join(
395-
process.cwd(),
396-
`sentry-wizard-installation-error-${Date.now()}.log`,
397-
),
398-
JSON.stringify({
399-
stdout,
400-
stderr,
401-
}),
402-
{ encoding: 'utf8' },
403-
);
388+
const installArgs = [
389+
pkgManager.installCommand,
390+
packageName,
391+
...(pkgManager.flags ? pkgManager.flags.split(' ') : []),
392+
...(forceInstall ? [pkgManager.forceInstallFlag] : []),
393+
];
394+
395+
const stringifiedInstallCmd = `${pkgManager.name} ${installArgs.join(
396+
' ',
397+
)}`;
398+
399+
function handleErrorAndReject(
400+
code: number | null,
401+
cause: Error | string,
402+
type: 'spawn_error' | 'process_error',
403+
) {
404+
// Write a log file so we can better troubleshoot issues
405+
fs.writeFileSync(
406+
join(
407+
process.cwd(),
408+
`sentry-wizard-installation-error-${Date.now()}.log`,
409+
),
410+
JSON.stringify(stderr, null, 2),
411+
{ encoding: 'utf8' },
412+
);
413+
414+
Sentry.captureException('Package Installation Error', {
415+
tags: {
416+
'install-command': stringifiedInstallCmd,
417+
'package-manager': pkgManager.name,
418+
'package-name': packageName,
419+
'error-type': type,
420+
},
421+
});
422+
423+
reject(
424+
new Error(
425+
`Installation command ${chalk.cyan(
426+
stringifiedInstallCmd,
427+
)} exited with code ${code}.`,
428+
{
429+
cause,
430+
},
431+
),
432+
);
433+
}
404434

405-
reject(err);
406-
} else {
407-
resolve();
408-
}
435+
const installProcess = childProcess.spawn(
436+
pkgManager.name,
437+
installArgs,
438+
{
439+
shell: true,
440+
// Ignoring `stdout` to prevent certain node + yarn v4 (observed on ubuntu + snap)
441+
// combinations from crashing here. See #851
442+
stdio: ['pipe', 'ignore', 'pipe'],
409443
},
410444
);
445+
446+
let stderr = '';
447+
448+
installProcess.stderr.on('data', (data) => {
449+
stderr += data.toString();
450+
});
451+
452+
installProcess.on('error', (err) => {
453+
handleErrorAndReject(null, err, 'spawn_error');
454+
});
455+
456+
installProcess.on('close', (code) => {
457+
if (code !== 0) {
458+
handleErrorAndReject(code, stderr, 'process_error');
459+
} else {
460+
resolve();
461+
}
462+
});
411463
});
412464
} catch (e) {
413465
sdkInstallSpinner.stop('Installation failed.');

src/utils/package-manager.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
/* eslint-disable @typescript-eslint/typedef */
2-
import * as fs from 'fs';
3-
import * as path from 'path';
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
43

54
import * as Sentry from '@sentry/node';
65
import { traceStep } from '../telemetry';
@@ -22,7 +21,7 @@ export interface PackageManager {
2221
export const BUN: PackageManager = {
2322
name: 'bun',
2423
label: 'Bun',
25-
installCommand: 'bun add',
24+
installCommand: 'add',
2625
buildCommand: 'bun run build',
2726
runScriptCommand: 'bun run',
2827
flags: '',
@@ -47,7 +46,7 @@ export const BUN: PackageManager = {
4746
export const YARN_V1: PackageManager = {
4847
name: 'yarn',
4948
label: 'Yarn V1',
50-
installCommand: 'yarn add',
49+
installCommand: 'add',
5150
buildCommand: 'yarn build',
5251
runScriptCommand: 'yarn',
5352
flags: '--ignore-workspace-root-check',
@@ -79,7 +78,7 @@ export const YARN_V1: PackageManager = {
7978
export const YARN_V2: PackageManager = {
8079
name: 'yarn',
8180
label: 'Yarn V2/3/4',
82-
installCommand: 'yarn add',
81+
installCommand: 'add',
8382
buildCommand: 'yarn build',
8483
runScriptCommand: 'yarn',
8584
flags: '',
@@ -110,7 +109,7 @@ export const YARN_V2: PackageManager = {
110109
export const PNPM: PackageManager = {
111110
name: 'pnpm',
112111
label: 'PNPM',
113-
installCommand: 'pnpm add',
112+
installCommand: 'add',
114113
buildCommand: 'pnpm build',
115114
runScriptCommand: 'pnpm',
116115
flags: '--ignore-workspace-root-check',
@@ -136,7 +135,7 @@ export const PNPM: PackageManager = {
136135
export const NPM: PackageManager = {
137136
name: 'npm',
138137
label: 'NPM',
139-
installCommand: 'npm add',
138+
installCommand: 'install',
140139
buildCommand: 'npm run build',
141140
runScriptCommand: 'npm run',
142141
flags: '',

test/utils/clack-utils.test.ts

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,25 @@ describe('installPackage', () => {
157157
jest.clearAllMocks();
158158
});
159159

160+
const spawnSpy = jest
161+
.spyOn(ChildProcess, 'spawn')
162+
// @ts-expect-error - ignoring complete typing
163+
.mockImplementation(() => ({
164+
// @ts-expect-error - not passing the full object but directly resolving
165+
// to simulate a successful install
166+
on: jest.fn((evt: 'close', cb: (args) => void) => {
167+
if (evt === 'close') {
168+
cb(0);
169+
}
170+
}),
171+
stderr: { on: jest.fn() },
172+
}));
173+
160174
it('force-installs a package if the forceInstall flag is set', async () => {
161175
const packageManagerMock: PackageManager = {
162176
name: 'npm',
163177
label: 'NPM',
164-
installCommand: 'npm install',
178+
installCommand: 'install',
165179
buildCommand: 'npm run build',
166180
runScriptCommand: 'npm run',
167181
flags: '',
@@ -170,28 +184,19 @@ describe('installPackage', () => {
170184
addOverride: jest.fn(),
171185
};
172186

173-
const execSpy = jest
174-
.spyOn(ChildProcess, 'exec')
175-
// @ts-expect-error - don't care about the return value
176-
.mockImplementationOnce((cmd, cb) => {
177-
if (cb) {
178-
// @ts-expect-error - don't care about the options value
179-
cb(null, '', '');
180-
}
181-
});
182-
183187
await installPackage({
184188
alreadyInstalled: false,
185-
packageName: '@sentry/sveltekit',
186-
packageNameDisplayLabel: '@sentry/sveltekit',
189+
packageName: '@some/package',
190+
packageNameDisplayLabel: '@some/package',
187191
forceInstall: true,
188192
askBeforeUpdating: false,
189193
packageManager: packageManagerMock,
190194
});
191195

192-
expect(execSpy).toHaveBeenCalledWith(
193-
'npm install @sentry/sveltekit --force',
194-
expect.any(Function),
196+
expect(spawnSpy).toHaveBeenCalledWith(
197+
'npm',
198+
['install', '@some/package', '--force'],
199+
{ shell: true, stdio: ['pipe', 'ignore', 'pipe'] },
195200
);
196201
});
197202

@@ -201,7 +206,7 @@ describe('installPackage', () => {
201206
const packageManagerMock: PackageManager = {
202207
name: 'npm',
203208
label: 'NPM',
204-
installCommand: 'npm install',
209+
installCommand: 'install',
205210
buildCommand: 'npm run build',
206211
runScriptCommand: 'npm run',
207212
flags: '',
@@ -210,16 +215,6 @@ describe('installPackage', () => {
210215
addOverride: jest.fn(),
211216
};
212217

213-
const execSpy = jest
214-
.spyOn(ChildProcess, 'exec')
215-
// @ts-expect-error - don't care about the return value
216-
.mockImplementationOnce((cmd, cb) => {
217-
if (cb) {
218-
// @ts-expect-error - don't care about the options value
219-
cb(null, '', '');
220-
}
221-
});
222-
223218
await installPackage({
224219
alreadyInstalled: false,
225220
packageName: '@sentry/sveltekit',
@@ -229,12 +224,43 @@ describe('installPackage', () => {
229224
packageManager: packageManagerMock,
230225
});
231226

232-
expect(execSpy).toHaveBeenCalledWith(
233-
'npm install @sentry/sveltekit ',
234-
expect.any(Function),
227+
expect(spawnSpy).toHaveBeenCalledWith(
228+
'npm',
229+
['install', '@sentry/sveltekit'],
230+
{ shell: true, stdio: ['pipe', 'ignore', 'pipe'] },
235231
);
236232
},
237233
);
234+
235+
it('adds install flags if defined', async () => {
236+
const packageManagerMock: PackageManager = {
237+
name: 'npm',
238+
label: 'NPM',
239+
installCommand: 'install',
240+
buildCommand: 'npm run build',
241+
runScriptCommand: 'npm run',
242+
flags: '--ignore-workspace-root-check',
243+
forceInstallFlag: '--force',
244+
detect: jest.fn(),
245+
addOverride: jest.fn(),
246+
};
247+
248+
await installPackage({
249+
alreadyInstalled: false,
250+
packageName: '@some/package',
251+
packageNameDisplayLabel: '@some/package',
252+
forceInstall: true,
253+
askBeforeUpdating: false,
254+
packageManager: packageManagerMock,
255+
});
256+
257+
expect(spawnSpy).toHaveBeenCalledWith(
258+
'npm',
259+
260+
['install', '@some/package', '--ignore-workspace-root-check', '--force'],
261+
{ shell: true, stdio: ['pipe', 'ignore', 'pipe'] },
262+
);
263+
});
238264
});
239265

240266
describe('askForWizardLogin', () => {

0 commit comments

Comments
 (0)