diff --git a/pack.sh b/pack.sh index b85c92f3e68a5..128d8e3fd8a90 100755 --- a/pack.sh +++ b/pack.sh @@ -63,6 +63,19 @@ for dir in $(find packages -name dist | grep -v node_modules | grep -v run-wrapp rsync -a $dir/ ${distdir}/ done +# Make sure that none of the NPM tarballs have stray `.ts` files in them. +# This is necessary because the presence of .ts files without a matching tsconfig will +# make `ts-node` fail to load the files with a "Cannot use import statement outside a module" error. +for tarball in ${distdir}/js/*.tgz; do + # Ignore init-templates, we purposely ship .ts files in there. + ts_files=$(tar tzf ${tarball} | (set +e; grep '\.ts$' | grep -v '\.d\.ts$' | grep -v init-templates)) + if [[ "$ts_files" != "" ]]; then + echo "Found TypeScript source files in $tarball. This will confuse ts-node:" >&2 + echo "$ts_files" >&2 + exit 1 + fi +done + # Record the dependency order of NPM packages into a file # (This file will be opportunistically used during publishing) # diff --git a/package.json b/package.json index d284a9710ee61..0995b296d13bf 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,9 @@ "nohoist": [ "**/jszip", "**/jszip/**", + "@aws-cdk/aws-codebuild/@aws-cdk/yaml-cfn", + "@aws-cdk/aws-codebuild/@aws-cdk/yaml-cfn/yaml", + "@aws-cdk/aws-codebuild/@aws-cdk/yaml-cfn/yaml/**", "@aws-cdk/aws-codepipeline-actions/case", "@aws-cdk/aws-codepipeline-actions/case/**", "@aws-cdk/aws-cognito/punycode", @@ -63,6 +66,9 @@ "@aws-cdk/cloud-assembly-schema/jsonschema/**", "@aws-cdk/cloud-assembly-schema/semver", "@aws-cdk/cloud-assembly-schema/semver/**", + "@aws-cdk/cloudformation-include/@aws-cdk/yaml-cfn", + "@aws-cdk/cloudformation-include/@aws-cdk/yaml-cfn/yaml", + "@aws-cdk/cloudformation-include/@aws-cdk/yaml-cfn/yaml/**", "@aws-cdk/core/@balena/dockerignore", "@aws-cdk/core/@balena/dockerignore/**", "@aws-cdk/core/fs-extra", @@ -75,6 +81,9 @@ "@aws-cdk/cx-api/semver/**", "@aws-cdk/yaml-cfn/yaml", "@aws-cdk/yaml-cfn/yaml/**", + "aws-cdk-lib/@aws-cdk/yaml-cfn", + "aws-cdk-lib/@aws-cdk/yaml-cfn/yaml", + "aws-cdk-lib/@aws-cdk/yaml-cfn/yaml/**", "aws-cdk-lib/@balena/dockerignore", "aws-cdk-lib/@balena/dockerignore/**", "aws-cdk-lib/case", @@ -93,6 +102,9 @@ "aws-cdk-lib/semver/**", "aws-cdk-lib/yaml", "aws-cdk-lib/yaml/**", + "monocdk/@aws-cdk/yaml-cfn", + "monocdk/@aws-cdk/yaml-cfn/yaml", + "monocdk/@aws-cdk/yaml-cfn/yaml/**", "monocdk/@balena/dockerignore", "monocdk/@balena/dockerignore/**", "monocdk/case", diff --git a/packages/@aws-cdk/aws-codebuild/package.json b/packages/@aws-cdk/aws-codebuild/package.json index e5a9a34ecd807..e07695b75ba31 100644 --- a/packages/@aws-cdk/aws-codebuild/package.json +++ b/packages/@aws-cdk/aws-codebuild/package.json @@ -49,6 +49,7 @@ "build+test": "yarn build && yarn test", "compat": "cdk-compat", "gen": "cfn2ts", + "postpack": "cdk-postpack", "rosetta:extract": "yarn --silent jsii-rosetta extract" }, "cdk-build": { @@ -103,6 +104,9 @@ "@aws-cdk/yaml-cfn": "0.0.0", "constructs": "^3.3.69" }, + "bundledDependencies": [ + "@aws-cdk/yaml-cfn" + ], "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { "@aws-cdk/aws-cloudwatch": "0.0.0", @@ -119,7 +123,6 @@ "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/region-info": "0.0.0", - "@aws-cdk/yaml-cfn": "0.0.0", "constructs": "^3.3.69" }, "engines": { diff --git a/packages/@aws-cdk/cloudformation-include/package.json b/packages/@aws-cdk/cloudformation-include/package.json index b9b82e67f55a3..ac7261b681d34 100644 --- a/packages/@aws-cdk/cloudformation-include/package.json +++ b/packages/@aws-cdk/cloudformation-include/package.json @@ -47,7 +47,8 @@ "build+test": "yarn build && yarn test", "build+test+package": "yarn build+test && yarn package", "compat": "cdk-compat", - "rosetta:extract": "yarn --silent jsii-rosetta extract" + "rosetta:extract": "yarn --silent jsii-rosetta extract", + "postpack": "cdk-postpack" }, "cdk-build": { "pre": [ @@ -363,7 +364,6 @@ "@aws-cdk/aws-wafv2": "0.0.0", "@aws-cdk/aws-workspaces": "0.0.0", "@aws-cdk/core": "0.0.0", - "@aws-cdk/yaml-cfn": "0.0.0", "constructs": "^3.3.69" }, "devDependencies": { @@ -375,6 +375,9 @@ "pkglint": "0.0.0", "ts-jest": "^26.5.4" }, + "bundledDependencies": [ + "@aws-cdk/yaml-cfn" + ], "keywords": [ "aws", "cdk", diff --git a/packages/@aws-cdk/yaml-cfn/.gitignore b/packages/@aws-cdk/yaml-cfn/.gitignore index bb785cfb74f08..8b9c845e5d12a 100644 --- a/packages/@aws-cdk/yaml-cfn/.gitignore +++ b/packages/@aws-cdk/yaml-cfn/.gitignore @@ -3,7 +3,6 @@ *.d.ts node_modules dist -tsconfig.json .jsii .LAST_BUILD @@ -15,4 +14,4 @@ nyc.config.js !.eslintrc.js !jest.config.js -junit.xml \ No newline at end of file +junit.xml diff --git a/packages/@aws-cdk/yaml-cfn/package.json b/packages/@aws-cdk/yaml-cfn/package.json index f10beb171366c..a50d4ab10eece 100644 --- a/packages/@aws-cdk/yaml-cfn/package.json +++ b/packages/@aws-cdk/yaml-cfn/package.json @@ -23,32 +23,6 @@ "cloudformation", "yaml" ], - "jsii": { - "outdir": "dist", - "targets": { - "java": { - "package": "software.amazon.awscdk.yaml.cfn", - "maven": { - "groupId": "software.amazon.awscdk", - "artifactId": "cdk-yaml-cfn" - } - }, - "dotnet": { - "namespace": "Amazon.CDK.Yaml.Cfn", - "packageId": "Amazon.CDK.Yaml.Cfn", - "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/master/logo/default-256-dark.png" - }, - "python": { - "distName": "aws-cdk.yaml-cfn", - "module": "aws_cdk.yaml_cfn", - "classifiers": [ - "Framework :: AWS CDK", - "Framework :: AWS CDK :: 1" - ] - } - }, - "projectReferences": true - }, "scripts": { "build": "cdk-build", "watch": "cdk-watch", diff --git a/packages/@aws-cdk/yaml-cfn/tsconfig.json b/packages/@aws-cdk/yaml-cfn/tsconfig.json new file mode 100644 index 0000000000000..5e75173fa8734 --- /dev/null +++ b/packages/@aws-cdk/yaml-cfn/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target":"ES2018", + "module": "commonjs", + "lib": ["es2016", "es2017.object", "es2017.string"], + "declaration": true, + "composite": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization":false + }, + "include": ["**/*.ts" ], + "exclude": ["node_modules"] +} diff --git a/packages/aws-cdk-lib/package.json b/packages/aws-cdk-lib/package.json index cef21953bf34a..9c3057ec1e728 100644 --- a/packages/aws-cdk-lib/package.json +++ b/packages/aws-cdk-lib/package.json @@ -23,7 +23,8 @@ "build+test+package": "yarn build+test && yarn package", "watch": "cdk-watch", "compat": "cdk-compat", - "rosetta:extract": "yarn --silent jsii-rosetta extract" + "rosetta:extract": "yarn --silent jsii-rosetta extract", + "postpack": "cdk-postpack" }, "awslint": { "exclude": [ @@ -82,6 +83,7 @@ }, "license": "Apache-2.0", "bundledDependencies": [ + "@aws-cdk/yaml-cfn", "@balena/dockerignore", "case", "fs-extra", @@ -93,6 +95,7 @@ "yaml" ], "dependencies": { + "@aws-cdk/yaml-cfn": "0.0.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^9.1.0", diff --git a/packages/awslint/.npmignore b/packages/awslint/.npmignore index 7cb354bd2b155..d3305e57c78c2 100644 --- a/packages/awslint/.npmignore +++ b/packages/awslint/.npmignore @@ -1,4 +1,5 @@ - +*.ts +!*.d.ts dist .LAST_PACKAGE .LAST_BUILD @@ -9,4 +10,4 @@ dist tsconfig.json .eslintrc.js junit.xml -test/ \ No newline at end of file +test/ diff --git a/packages/decdk/test/schema.test.ts b/packages/decdk/test/schema.test.ts index 7b097dcc9fcbe..84888d94b365b 100644 --- a/packages/decdk/test/schema.test.ts +++ b/packages/decdk/test/schema.test.ts @@ -79,7 +79,7 @@ test('schemaForInterface: interface with primitives', async () => { * are propagated outwards. */ function spawn(command: string, options: SpawnOptions | undefined) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const cp = spawnAsync(command, [], { stdio: 'inherit', ...options }); cp.on('error', reject); diff --git a/packages/monocdk/package.json b/packages/monocdk/package.json index ce9057690ff00..c2fa7b0bbbf5a 100644 --- a/packages/monocdk/package.json +++ b/packages/monocdk/package.json @@ -22,7 +22,8 @@ "build+test+package": "yarn build+test && yarn package", "watch": "cdk-watch", "compat": "cdk-compat", - "rosetta:extract": "yarn --silent jsii-rosetta extract" + "rosetta:extract": "yarn --silent jsii-rosetta extract", + "postpack": "cdk-postpack" }, "awslint": { "exclude": [ @@ -87,6 +88,7 @@ }, "license": "Apache-2.0", "bundledDependencies": [ + "@aws-cdk/yaml-cfn", "@balena/dockerignore", "case", "fs-extra", @@ -98,6 +100,7 @@ "yaml" ], "dependencies": { + "@aws-cdk/yaml-cfn": "0.0.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^9.1.0", diff --git a/tools/cdk-build-tools/bin/cdk-postpack b/tools/cdk-build-tools/bin/cdk-postpack new file mode 100755 index 0000000000000..0805422de11c8 --- /dev/null +++ b/tools/cdk-build-tools/bin/cdk-postpack @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('./cdk-postpack.js'); diff --git a/tools/cdk-build-tools/bin/cdk-postpack.ts b/tools/cdk-build-tools/bin/cdk-postpack.ts new file mode 100644 index 0000000000000..02b486b290512 --- /dev/null +++ b/tools/cdk-build-tools/bin/cdk-postpack.ts @@ -0,0 +1,179 @@ +import { promises as fs, createReadStream, createWriteStream } from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import * as npmPacklist from 'npm-packlist'; +import * as tar from 'tar-stream'; + +/** + * This script has literally ONE job: + * + * To massage the tarball that `npm pack` produces if we have bundled monorepo dependencies. + * + * The reason is that `npm pack` will SKIP the `.npmignore` files from + * `bundledDependencies`[0]. Not a problem for dependencies that were themselves + * downloaded from NPM, but definitely a problem for dependencies that are symlinked + * to source locations (such as those by `npm link` or by a monorepo setup). + * + * This leads to all the `.ts` files from the bundledDependency being included, + * which in turn leads `ts-node` to read those files (in preference over `.js` + * files with the same name), and then subsequently getting confused because + * there is no `tsconfig.json` file that tells it how to compile them them (and + * the defaults don't work out). + * + * (Shitty) solution: postprocess the tarball that `npm pack` produces, to + * remove the files that should have been excluded by NPM in the first place. + * + * [0]: https://github.com/npm/cli/issues/718 + */ +async function main() { + const tarball = await findTarballFile(); + const rewritten = `${tarball}.tmp`; + + await transformTarball(tarball, rewritten); + + await fs.rename(rewritten, tarball); +} + +async function transformTarball(oldPath: string, newPath: string) { + const cache = new NpmListCache(); + + const { input, output } = createTarballFilterStream(async (headers) => { + // Paths in a NPM tarball always start with 'package/', strip it off to get to a real relative path + const relativePath = headers.name.replace(/^package\//, ''); + + // We only have to care about files that are in bundled dependencies (which means their + // path starts with 'node_modules/') + if (!relativePath.startsWith('node_modules/')) { return true; } + + // Resolve symlinks. Only do special things if the symlink does NOT have /node_modules/ in the path, which + // is when we're dealing with a symlinked package that NPM will have misprocessed. + // Otherwise just include the file. + const absPath = await fs.realpath(relativePath); + if (absPath.includes('/node_modules/')) { return true; } + + return cache.shouldPublish(absPath); + }); + + await new Promise((ok, ko) => { + // Thanks Node. This is a really thoughtful API and really much better! [1] + createReadStream(oldPath) + .on('error', () => ko()) + .pipe(zlib.createGunzip()) + .on('error', () => ko()) + .pipe(input) + .on('error', () => ko()); + + const outputStream = createWriteStream(newPath); + + output + .on('error', () => ko()) + .pipe(zlib.createGzip()) + .on('error', () => ko()) + .pipe(outputStream) + .on('error', () => ko()); + + outputStream.on('close', () => ok()); + }); +} + +/** + * Create a stream that will retain/remove entries from a tarball based on a predicate + * + * Returns the input and output ends of the stream. + */ +function createTarballFilterStream(include: (headers: tar.Headers) => Promise) { + const input = tar.extract(); + const output = tar.pack(); + + input.on('entry', (headers, stream, next) => { + include(headers).then(doInclude => { + if (doInclude) { + // Pipe body to output + stream.pipe(output.entry(headers, next)); + } else { + // eslint-disable-next-line no-console + console.error(`[cdk-postpack] Belatedly npmignored: ${headers.name}`); + // Consume the stream without writing it anywhere + stream.on('end', next); + stream.resume(); + } + }).catch(e => { + input.destroy(e); + output.destroy(e); + }); + }); + + input.on('finish', () => { + output.finalize(); + }); + + return { input, output }; +} + +/** + * Expect one .tgz file in the current directory and return its name + */ +async function findTarballFile() { + const entries = await fs.readdir('.'); + const tgzs = entries.filter(e => e.endsWith('.tgz')); + if (tgzs.length !== 1) { + throw new Error(`Expecting extactly one .tgz file, got: ${tgzs}`); + } + return tgzs[0]; +} + +class NpmListCache { + private listCache: Record = {}; + + public async shouldPublish(absPath: string) { + const pjDir = path.dirname(await findUp('package.json', path.dirname(absPath))); + const files = await this.obtainNpmPackList(pjDir); + + const relativePath = path.relative(pjDir, absPath); + return files.includes(relativePath); + } + + /** + * Use 'npm-packlist' (official NPM package) to get the list of files that NPM would publish from the given directory + * + * This will take into account the .npmignore files. + */ + private async obtainNpmPackList(dir: string) { + if (this.listCache[dir]) { return this.listCache[dir]; } + + const files = await npmPacklist({ path: dir }); + this.listCache[dir] = files; + return files; + } +} + +async function findUp(fileName: string, start: string) { + let dir = start; + while (!await fileExists(path.join(dir, fileName))) { + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`No ${fileName} found upwards from ${start}`); + } + dir = parent; + } + return path.join(dir, fileName); +} + +async function fileExists(fullPath: string): Promise { + try { + await fs.stat(fullPath); + return true; + } catch (e) { + if (e.code === 'ENOENT' || e.code === 'ENOTDIR') { return false; } + throw e; + } +} + +main().catch(e => { + // eslint-disable-next-line no-console + console.log('Error', e); + process.exitCode = 1; +}); + + +// [1] /s \ No newline at end of file diff --git a/tools/cdk-build-tools/package.json b/tools/cdk-build-tools/package.json index f061a6249a738..e1d1798b7ff80 100644 --- a/tools/cdk-build-tools/package.json +++ b/tools/cdk-build-tools/package.json @@ -16,7 +16,8 @@ "cdk-test": "bin/cdk-test", "cdk-package": "bin/cdk-package", "cdk-awslint": "bin/cdk-awslint", - "cdk-lint": "bin/cdk-lint" + "cdk-lint": "bin/cdk-lint", + "cdk-postpack": "bin/cdk-postpack" }, "scripts": { "build": "tsc -b && chmod +x bin/cdk-build && chmod +x bin/cdk-test && chmod +x bin/cdk-watch && chmod +x bin/cdk-awslint && chmod +x bin/cdk-lint && pkglint && eslint . --ext=.ts", @@ -35,7 +36,9 @@ "devDependencies": { "@types/fs-extra": "^8.1.1", "@types/jest": "^26.0.22", + "@types/tar-stream": "^2.2.0", "@types/yargs": "^15.0.13", + "@types/npm-packlist": "^1.1.1", "pkglint": "0.0.0" }, "dependencies": { @@ -55,8 +58,10 @@ "jsii-pacmak": "^1.26.0", "markdownlint-cli": "^0.27.1", "nodeunit": "^0.11.3", + "npm-packlist": "2.1.5", "nyc": "^15.1.0", "semver": "^7.3.5", + "tar-stream": "^2.2.0", "ts-jest": "^26.5.4", "typescript": "~3.9.9", "yargs": "^16.2.0", diff --git a/tools/nodeunit-shim/index.ts b/tools/nodeunit-shim/index.ts index 8ba50bedefefd..1c4ee174ff229 100644 --- a/tools/nodeunit-shim/index.ts +++ b/tools/nodeunit-shim/index.ts @@ -81,7 +81,7 @@ export function nodeunitShim(exports: Record) { }); } else { // It's a test - test(testName, () => new Promise(ok => { + test(testName, () => new Promise(ok => { testObj(new Test(ok)); })); } diff --git a/tools/pkglint/bin/pkglint.ts b/tools/pkglint/bin/pkglint.ts index 0b5cd61ef1649..31cf7f5d2c74d 100644 --- a/tools/pkglint/bin/pkglint.ts +++ b/tools/pkglint/bin/pkglint.ts @@ -24,8 +24,16 @@ async function main(): Promise { const pkgs = findPackageJsons(argv.directory as string); - rules.forEach(rule => pkgs.filter(pkg => pkg.shouldApply(rule)).forEach(pkg => rule.prepare(pkg))); - rules.forEach(rule => pkgs.filter(pkg => pkg.shouldApply(rule)).forEach(pkg => rule.validate(pkg))); + for (const rule of rules) { + for (const pkg of pkgs.filter(pkg => pkg.shouldApply(rule))) { + rule.prepare(pkg); + } + } + for (const rule of rules) { + for (const pkg of pkgs.filter(pkg => pkg.shouldApply(rule))) { + await rule.validate(pkg); + } + } if (argv.fix) { pkgs.forEach(pkg => pkg.applyFixes()); diff --git a/tools/pkglint/lib/packagejson.ts b/tools/pkglint/lib/packagejson.ts index a59e8f1c6e307..7a7375fbe6fab 100644 --- a/tools/pkglint/lib/packagejson.ts +++ b/tools/pkglint/lib/packagejson.ts @@ -361,5 +361,5 @@ export abstract class ValidationRule { /** * Will be executed for every package definition once, should mutate the package object */ - public abstract validate(pkg: PackageJson): void; + public abstract validate(pkg: PackageJson): void | Promise; } diff --git a/tools/pkglint/lib/rules.ts b/tools/pkglint/lib/rules.ts index f7929a353035a..6b32110ad99d6 100644 --- a/tools/pkglint/lib/rules.ts +++ b/tools/pkglint/lib/rules.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as caseUtils from 'case'; +import * as fse from 'fs-extra'; import * as glob from 'glob'; import * as semver from 'semver'; import { LICENSE, NOTICE } from './licensing'; @@ -11,6 +12,7 @@ import { fileShouldBe, fileShouldBeginWith, fileShouldContain, fileShouldNotContain, findInnerPackages, + findPackageDir, monoRepoRoot, } from './util'; @@ -1371,25 +1373,42 @@ export class FastFailingBuildScripts extends ValidationRule { } } +/** + * For every bundled dependency, we need to make sure that package and all of its transitive dependencies are nohoisted + * + * Bundling literally works by including `/node_modules/` into + * the tarball when `npm pack` is run, and if that directory does not exist at + * that exact location (because it has been hoisted) then NPM shrugs its + * shoulders and the dependency will be missing from the distribution. + * + * -- + * + * We also must not forget to nohoist transitive dependencies. Strictly + * speaking, we need to only hoist transitive *runtime* dependencies (`dependencies`, not + * `devDependencies`). + * + * For 3rd party deps, there is no difference and we short-circuit by adding a + * catch-all glob (`/node_modules//**`), but for in-repo bundled + * dependencies, we DO need the `devDependencies` installed as per normal and + * only the transitive runtime dependencies nohoisted (recursively). + */ export class YarnNohoistBundledDependencies extends ValidationRule { public readonly name = 'yarn/nohoist-bundled-dependencies'; - public validate(pkg: PackageJson) { + public async validate(pkg: PackageJson) { const bundled: string[] = pkg.json.bundleDependencies || pkg.json.bundledDependencies || []; - if (bundled.length === 0) { return; } const repoPackageJson = path.resolve(__dirname, '../../../package.json'); + const nohoist = new Set(require(repoPackageJson).workspaces.nohoist); // eslint-disable-line @typescript-eslint/no-require-imports - const nohoist: string[] = require(repoPackageJson).workspaces.nohoist; // eslint-disable-line @typescript-eslint/no-require-imports + const expectedNoHoistEntries = new Array(); - const missing = new Array(); for (const dep of bundled) { - for (const entry of [`${pkg.packageName}/${dep}`, `${pkg.packageName}/${dep}/**`]) { - if (nohoist.indexOf(entry) >= 0) { continue; } - missing.push(entry); - } + await noHoistDependency(pkg.packageName, dep, pkg.packageRoot); } + const missing = expectedNoHoistEntries.filter(entry => !nohoist.has(entry)); + if (missing.length > 0) { pkg.report({ ruleName: this.name, @@ -1401,6 +1420,43 @@ export class YarnNohoistBundledDependencies extends ValidationRule { }, }); } + + async function noHoistDependency(parentPackageHierarchy: string, depName: string, parentPackageDir: string) { + expectedNoHoistEntries.push(`${parentPackageHierarchy}/${depName}`); + + const dependencyDir = await findPackageDir(depName, parentPackageDir); + if (!isMonoRepoPackageDir(dependencyDir)) { + // Not one of ours, so we can just ignore everything underneath as well + expectedNoHoistEntries.push(`${parentPackageHierarchy}/${depName}/**`); + return; + } + + // A monorepo package, recurse into dependencies (but not devDependencies) + const packageJson = await fse.readJson(path.join(dependencyDir, 'package.json')); + for (const dep of Object.keys(packageJson.dependencies ?? {})) { + await noHoistDependency(`${parentPackageHierarchy}/${depName}`, dep, dependencyDir); + } + } + } +} + +/** + * If this package has bundled dependencies that come from the monorepo, it MUST use `cdk-postpack` as postpack script + */ +export class BundledMonorepoDependencies extends ValidationRule { + public readonly name = 'monorepo/monorepo-bundled-dependencies'; + + public async validate(pkg: PackageJson) { + const bundled: string[] = pkg.json.bundleDependencies || pkg.json.bundledDependencies || []; + + const bundledDepsMonoRepo = await Promise.all(bundled.map(async (dep) => { + const dependencyDir = await findPackageDir(dep, pkg.packageRoot); + return isMonoRepoPackageDir(dependencyDir); + })); + + if (bundledDepsMonoRepo.some(x => x)) { + expectJSON(this.name, pkg, 'scripts.postpack', 'cdk-postpack'); + } } } @@ -1670,3 +1726,14 @@ function cdkMajorVersion() { const releaseJson = require(`${__dirname}/../../../release.json`); return releaseJson.majorVersion as number; } + +/** + * Whether this is a package in the monorepo or not + * + * We're going to be cheeky and not do too much analysis, and say that + * a package that has `/node_modules/` in the directory name is NOT in the + * monorepo, otherwise it is. + */ +function isMonoRepoPackageDir(packageDir: string) { + return path.resolve(packageDir).indexOf(`${path.sep}node_modules${path.sep}`) === -1; +} \ No newline at end of file diff --git a/tools/pkglint/lib/util.ts b/tools/pkglint/lib/util.ts index 10b4415a6c3ca..fe56512113ec2 100644 --- a/tools/pkglint/lib/util.ts +++ b/tools/pkglint/lib/util.ts @@ -190,3 +190,39 @@ export function* findInnerPackages(dir: string): IterableIterator { yield* findInnerPackages(path.join(dir, fname)); } } + +/** + * Find package directory + * + * Do this by walking upwards in the directory tree until we find + * `/node_modules//package.json`. + * + * ------- + * + * Things that we tried but don't work: + * + * 1. require.resolve(`${depName}/package.json`, { paths: [rootDir] }); + * + * Breaks with ES Modules if `package.json` has not been exported, which is + * being enforced starting Node12. + * + * 2. findPackageJsonUpwardFrom(require.resolve(depName, { paths: [rootDir] })) + * + * Breaks if a built-in NodeJS package name conflicts with an NPM package name + * (in Node15 `string_decoder` is introduced...) + */ +export async function findPackageDir(depName: string, rootDir: string) { + let prevDir; + let dir = rootDir; + while (dir !== prevDir) { + const candidateDir = path.join(dir, 'node_modules', depName); + if (await new Promise(ok => fs.exists(path.join(candidateDir, 'package.json'), ok))) { + return new Promise((ok, ko) => fs.realpath(candidateDir, (err, result) => err ? ko(err) : ok(result))); + } + + prevDir = dir; + dir = path.dirname(dir); // dirname('/') -> '/', dirname('c:\\') -> 'c:\\' + } + + throw new Error(`Did not find '${depName}' upwards of '${rootDir}'`); +} \ No newline at end of file