Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions packages/build/src/npm-packages/generate-shrinkwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,48 @@ export async function generateShrinkwrap(
lockfile.name = packageJson.name;
lockfile.version = packageJson.version;
lockfile.packages[''] = { ...prodPkg };

// Move the workspace's nested dependencies onto the new root, so their
// versions resolve from the lockfile rather than from the registry.
const workspaceLocation = (
lockfile.packages[`node_modules/${packageJson.name}`] as
| LockfilePackageEntry
| undefined
)?.resolved;
if (workspaceLocation) {
const workspacePrefix = `${workspaceLocation}/node_modules/`;
for (const [key, entry] of Object.entries(lockfile.packages)) {
if (!key.startsWith(workspacePrefix)) continue;
lockfile.packages[`node_modules/${key.slice(workspacePrefix.length)}`] =
entry;
delete lockfile.packages[key];
}
}

await fs.writeFile(
path.join(tmpDir, 'package-lock.json'),
JSON.stringify(lockfile, null, 2)
);

// Let npm prune the lockfile to only the reachable dependencies
spawnSync('npm', ['install', '--package-lock-only', '--ignore-scripts'], {
cwd: tmpDir,
stdio: 'pipe',
encoding: 'utf8',
});
// Let npm prune the lockfile to only the reachable dependencies.
// --offline keeps this free of network calls and fails loudly if a version
// cannot be resolved from the lockfile.
spawnSync(
'npm',
[
'install',
'--package-lock-only',
'--ignore-scripts',
'--no-audit',
'--no-fund',
'--offline',
],
{
cwd: tmpDir,
stdio: 'pipe',
encoding: 'utf8',
}
);

// Read the pruned lockfile and post-process it
const prunedContent = await fs.readFile(
Expand Down
88 changes: 87 additions & 1 deletion packages/build/src/packaging/package/zip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,94 @@ describe('package zip', function () {
path.join(tmpPkg.tarballDir, 'outfile.zip'),
execFileStub
);
expect(execFileStub).to.have.been.calledTwice;
const outFile = path.join(tmpPkg.tarballDir, 'outfile.zip');
expect(execFileStub.callCount).to.equal(2);
expect(execFileStub.getCalls()[1].args[0]).to.equal('7z');
expect(execFileStub.getCalls()[1].args[1]).to.deep.equal([
'a',
outFile,
'.',
]);
});

// TODO(DEVPROD-42642): drop the workaround test once 7-Zip is restored on the Windows image.
it('falls back to 7z.exe if the bare 7z does not resolve', async function () {
const execFileStub = sinon.stub();
for (const missing of ['zip', '7z']) {
execFileStub
.withArgs(missing, sinon.match.any, sinon.match.any)
.rejects(new FakeNOENTError());
}

const outFile = path.join(tmpPkg.tarballDir, 'outfile.zip');
await createZipPackage(tmpPkg.pkgConfig, outFile, execFileStub);
expect(execFileStub.callCount).to.equal(3);
expect(execFileStub.lastCall.args[0]).to.equal('7z.exe');
expect(execFileStub.lastCall.args[1]).to.deep.equal(['a', outFile, '.']);
});

// TODO(DEVPROD-42642): drop the workaround test once 7-Zip is restored on the Windows image.
it('falls back to the 7-Zip install path if it is not on PATH at all', async function () {
const execFileStub = sinon.stub();
for (const missing of ['zip', '7z', '7z.exe']) {
execFileStub
.withArgs(missing, sinon.match.any, sinon.match.any)
.rejects(new FakeNOENTError());
}

const outFile = path.join(tmpPkg.tarballDir, 'outfile.zip');
await createZipPackage(tmpPkg.pkgConfig, outFile, execFileStub);
expect(execFileStub.callCount).to.equal(4);
expect(execFileStub.lastCall.args[0]).to.equal(
'C:\\Program Files\\7-Zip\\7z.exe'
);
expect(execFileStub.lastCall.args[1]).to.deep.equal(['a', outFile, '.']);
});

// TODO(DEVPROD-42642): drop the workaround test once 7-Zip is restored on the Windows image.
it('tries the x86 7-Zip install path last', async function () {
const execFileStub = sinon.stub();
for (const missing of [
'zip',
'7z',
'7z.exe',
'C:\\Program Files\\7-Zip\\7z.exe',
]) {
execFileStub
.withArgs(missing, sinon.match.any, sinon.match.any)
.rejects(new FakeNOENTError());
}

await createZipPackage(
tmpPkg.pkgConfig,
path.join(tmpPkg.tarballDir, 'outfile.zip'),
execFileStub
);
expect(execFileStub.callCount).to.equal(5);
expect(execFileStub.lastCall.args[0]).to.equal(
'C:\\Program Files (x86)\\7-Zip\\7z.exe'
);
});

// TODO(DEVPROD-42642): keep this test, but update the expected last
// candidate once the workaround entries are dropped.
it('rethrows ENOENT if no archiver is available at all', async function () {
const execFileStub = sinon.stub().rejects(new FakeNOENTError());

try {
await createZipPackage(
tmpPkg.pkgConfig,
path.join(tmpPkg.tarballDir, 'outfile.zip'),
execFileStub
);
} catch (e: any) {
expect(e.code).to.equal('ENOENT');
expect(execFileStub.lastCall.args[0]).to.equal(
'C:\\Program Files (x86)\\7-Zip\\7z.exe'
);
return;
}
expect.fail('Expected error');
});

it('rethrows errors', async function () {
Expand Down
49 changes: 38 additions & 11 deletions packages/build/src/packaging/package/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ import {
} from './helpers';
import type { PackageInformation } from './package-information';

/**
* The list of external tools that we can use to create a ZIP archive, in the
* order in which we try them. Using these has the advantage of preserving
* executable permissions as opposed to using libraries like adm-zip.
*
* TODO(DEVPROD-42642): the Windows image no longer has 7-Zip on PATH, so we
* fall back to the two standard install locations. Drop the `7z.exe` and
* absolute-path entries once 7-Zip is properly available again.
*/
function zipCommandCandidates(
outFile: string
): { cmd: string; args: string[] }[] {
return [
{ cmd: 'zip', args: ['-r', outFile, '.'] },
{ cmd: '7z', args: ['a', outFile, '.'] },
{ cmd: '7z.exe', args: ['a', outFile, '.'] },
{
cmd: 'C:\\Program Files\\7-Zip\\7z.exe',
args: ['a', outFile, '.'],
},
{
cmd: 'C:\\Program Files (x86)\\7-Zip\\7z.exe',
args: ['a', outFile, '.'],
},
];
}

/**
* Create a ZIP archive.
*/
Expand All @@ -15,19 +42,19 @@ export async function createZipPackage(
outFile: string,
execFile: typeof execFileFn = execFileFn
): Promise<void> {
// Let's assume that either zip or 7z are installed. That's true for the
// evergreen macOS and Windows machines, respectively, at this point.
// In either case, using these has the advantage of preserving executable permissions
// as opposed to using libraries like adm-zip.
const filename = path.basename(outFile).replace(/\.[^.]+$/, '');
const tmpDir = await createCompressedArchiveContents(filename, pkg);
try {
await execFile('zip', ['-r', outFile, '.'], { cwd: tmpDir });
} catch (err: any) {
if (err?.code === 'ENOENT') {
await execFile('7z', ['a', outFile, '.'], { cwd: tmpDir });
} else {
throw err;
const candidates = zipCommandCandidates(outFile);
for (const [index, { cmd, args }] of candidates.entries()) {
try {
await execFile(cmd, args, { cwd: tmpDir });
break;
} catch (err: any) {
// Only a missing binary makes us move on to the next candidate.
// An actual failure of one of these tools is a genuine error.
if (err?.code !== 'ENOENT' || index === candidates.length - 1) {
throw err;
}
}
}
await promisify(rimraf)(tmpDir);
Expand Down
36 changes: 33 additions & 3 deletions packages/cli-repl/npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 15 additions & 10 deletions packages/cli-repl/src/async-repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,21 @@ function wrapNoSyncDomainError<Args extends any[], Ret>(
repl: any
) {
return (...args: Args): Ret => {
// Node.js dropped the REPL's dependency on `domain` in favour of _handleError
// https://github.com/nodejs/node/commit/a9da9ffc04c923f383a0aa220123687909dd2263
// Where exists, prefer it and skip loading `domain`, which emits a DEP0032 warning into our output.
const origHandleError = repl._handleError;
if (origHandleError) {
repl._handleError = (err: unknown) => {
throw err;
};
try {
return fn(...args);
} finally {
repl._handleError = origHandleError;
}
}

// 'domain' is not supported in startup snapshots yet.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Domain } = require('domain');
Expand Down Expand Up @@ -324,19 +339,9 @@ function wrapNoSyncDomainError<Args extends any[], Ret>(
return origEmit.call(this, ev, ...eventArgs);
};

// _handleError is used instead of the above since
// https://github.com/nodejs/node/commit/a9da9ffc04c923f383a0aa220123687909dd2263
// which is nice, since it's quite a bit cleaner to monkey-patch
// and could be turned into an actual API more easily, but it still requires
// us to monkey-patch here for now
const origHandleError = repl._handleError;
repl._handleError = (err: unknown) => {
throw err;
};
try {
return fn(...args);
} finally {
repl._handleError = origHandleError;
// Reset the `emit` function after synchronous evaluation, because
// we need the Domain functionality for the asynchronous bits.
Domain.prototype.emit = origEmit;
Expand Down
Loading
Loading