diff --git a/packages/cli/src/commands/pack.ts b/packages/cli/src/commands/pack.ts index 6c88ac055f..9c101e97fe 100644 --- a/packages/cli/src/commands/pack.ts +++ b/packages/cli/src/commands/pack.ts @@ -14,77 +14,16 @@ * limitations under the License. */ -import fs from 'fs-extra'; +import { + productionPack, + revertProductionPack, +} from '../lib/packager/productionPack'; import { paths } from '../lib/paths'; -import { join as joinPath } from 'path'; - -const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes']; - -const PKG_PATH = 'package.json'; -const PKG_BACKUP_PATH = 'package.json-prepack'; - -function resolveEntrypoint(pkg: any, name: string) { - const targetEntry = pkg.publishConfig[name] || pkg[name]; - return targetEntry && joinPath('..', targetEntry); -} - -// Writes e.g. alpha/package.json -async function writeReleaseStageEntrypoint(pkg: any, stage: 'alpha' | 'beta') { - await fs.ensureDir(paths.resolveTarget(stage)); - await fs.writeJson( - paths.resolveTarget(stage, PKG_PATH), - { - name: pkg.name, - version: pkg.version, - main: resolveEntrypoint(pkg, 'main'), - module: resolveEntrypoint(pkg, 'module'), - browser: resolveEntrypoint(pkg, 'browser'), - types: joinPath('..', pkg.publishConfig[`${stage}Types`]), - }, - { encoding: 'utf8', spaces: 2 }, - ); -} export const pre = async () => { - const pkgPath = paths.resolveTarget(PKG_PATH); - - const pkgContent = await fs.readFile(pkgPath, 'utf8'); - const pkg = JSON.parse(pkgContent); - await fs.writeFile(PKG_BACKUP_PATH, pkgContent); - - const publishConfig = pkg.publishConfig ?? {}; - for (const key of Object.keys(publishConfig)) { - if (!SKIPPED_KEYS.includes(key)) { - pkg[key] = publishConfig[key]; - } - } - await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 }); - - if (publishConfig.alphaTypes) { - await writeReleaseStageEntrypoint(pkg, 'alpha'); - } - if (publishConfig.betaTypes) { - await writeReleaseStageEntrypoint(pkg, 'beta'); - } + await productionPack({ packageDir: paths.targetDir }); }; export const post = async () => { - // postpack isn't called by yarn right now, so it needs to be called manually - try { - await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true }); - - // Check if we're shipping types for other release stages, clean up in that case - const pkg = await fs.readJson(PKG_PATH); - if (pkg.publishConfig?.alphaTypes) { - await fs.remove(paths.resolveTarget('alpha')); - } - if (pkg.publishConfig?.betaTypes) { - await fs.remove(paths.resolveTarget('beta')); - } - } catch (error) { - console.warn( - `Failed to restore package.json during postpack, ${error}. ` + - 'Your package will be fine but you may have ended up with some garbage in the repo.', - ); - } + await revertProductionPack(paths.targetDir); }; diff --git a/packages/cli/src/lib/monorepo/PackageGraph.ts b/packages/cli/src/lib/monorepo/PackageGraph.ts index 421d9aa25b..b6d6d927a6 100644 --- a/packages/cli/src/lib/monorepo/PackageGraph.ts +++ b/packages/cli/src/lib/monorepo/PackageGraph.ts @@ -27,6 +27,7 @@ type PackageJSON = Package['packageJson']; export interface ExtendedPackageJSON extends PackageJSON { main?: string; module?: string; + types?: string; scripts?: { [key: string]: string; diff --git a/packages/cli/src/lib/packager/copyPackageDist.ts b/packages/cli/src/lib/packager/copyPackageDist.ts deleted file mode 100644 index b704148542..0000000000 --- a/packages/cli/src/lib/packager/copyPackageDist.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs-extra'; -import npmPackList from 'npm-packlist'; -import { join as joinPath, resolve as resolvePath } from 'path'; - -const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes']; - -function resolveEntrypoint(pkg: any, name: string) { - const targetEntry = pkg.publishConfig[name] || pkg[name]; - return targetEntry && joinPath('..', targetEntry); -} - -// Writes e.g. alpha/package.json -async function writeReleaseStageEntrypoint( - pkg: any, - stage: 'alpha' | 'beta', - targetDir: string, -) { - await fs.ensureDir(resolvePath(targetDir, stage)); - await fs.writeJson( - resolvePath(targetDir, stage, 'package.json'), - { - name: pkg.name, - version: pkg.version, - main: resolveEntrypoint(pkg, 'main'), - module: resolveEntrypoint(pkg, 'module'), - browser: resolveEntrypoint(pkg, 'browser'), - types: joinPath('..', pkg.publishConfig[`${stage}Types`]), - }, - { encoding: 'utf8', spaces: 2 }, - ); -} - -export async function copyPackageDist(packageDir: string, targetDir: string) { - const pkgPath = resolvePath(packageDir, 'package.json'); - const pkgContent = await fs.readFile(pkgPath, 'utf8'); - const pkg = JSON.parse(pkgContent); - - const publishConfig = pkg.publishConfig ?? {}; - for (const key of Object.keys(publishConfig)) { - if (!SKIPPED_KEYS.includes(key)) { - pkg[key] = publishConfig[key]; - } - } - - // We remove the dependencies from package.json of packages that are marked - // as bundled, so that yarn doesn't try to install them. - if (pkg.bundled) { - delete pkg.dependencies; - delete pkg.devDependencies; - delete pkg.peerDependencies; - delete pkg.optionalDependencies; - } - - // Lists all dist files, respecting .npmignore, files field in package.json, etc. - const filePaths = await npmPackList({ - path: packageDir, - // This makes sure we use the update package.json when listing files - packageJsonCache: new Map([[resolvePath(packageDir, 'package.json'), pkg]]), - }); - - await fs.ensureDir(targetDir); - for (const filePath of filePaths.sort()) { - const target = resolvePath(targetDir, filePath); - if (filePath === 'package.json') { - await fs.writeJson(target, pkg, { encoding: 'utf8', spaces: 2 }); - } else { - await fs.copy(resolvePath(packageDir, filePath), target); - } - } - - if (publishConfig.alphaTypes) { - await writeReleaseStageEntrypoint(pkg, 'alpha', targetDir); - } - if (publishConfig.betaTypes) { - await writeReleaseStageEntrypoint(pkg, 'beta', targetDir); - } -} diff --git a/packages/cli/src/lib/packager/createDistWorkspace.ts b/packages/cli/src/lib/packager/createDistWorkspace.ts index 28b5526fc9..2d2967fc44 100644 --- a/packages/cli/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/lib/packager/createDistWorkspace.ts @@ -37,7 +37,7 @@ import { getOutputsForRole, Output, } from '../builder'; -import { copyPackageDist } from './copyPackageDist'; +import { productionPack } from './productionPack'; import { getRoleInfo } from '../role'; import { runParallelWorkers } from '../parallel'; @@ -258,7 +258,10 @@ async function moveToDistWorkspace( const outputDir = relativePath(paths.targetRoot, target.dir); const absoluteOutputPath = resolvePath(workspaceDir, outputDir); - await copyPackageDist(target.dir, absoluteOutputPath); + await productionPack({ + packageDir: target.dir, + targetDir: absoluteOutputPath, + }); }), ); diff --git a/packages/cli/src/lib/packager/productionPack.ts b/packages/cli/src/lib/packager/productionPack.ts new file mode 100644 index 0000000000..c58cdab5a0 --- /dev/null +++ b/packages/cli/src/lib/packager/productionPack.ts @@ -0,0 +1,223 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import npmPackList from 'npm-packlist'; +import { join as joinPath, resolve as resolvePath } from 'path'; +import { ExtendedPackageJSON } from '../monorepo'; +import { readEntryPoints } from '../monorepo/entryPoints'; + +const PKG_PATH = 'package.json'; +const PKG_BACKUP_PATH = 'package.json-prepack'; + +const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes']; + +interface ProductionPackOptions { + packageDir: string; + targetDir?: string; +} + +export async function productionPack(options: ProductionPackOptions) { + const { packageDir, targetDir } = options; + const pkgPath = resolvePath(packageDir, PKG_PATH); + const pkgContent = await fs.readFile(pkgPath, 'utf8'); + const pkg = JSON.parse(pkgContent) as ExtendedPackageJSON; + + // If we're making the update in-line, back up the package.json + if (!targetDir) { + await fs.writeFile(PKG_BACKUP_PATH, pkgContent); + } + + const hasStageEntry = + !!pkg.publishConfig?.alphaTypes || !!pkg.publishConfig?.betaTypes; + if (pkg.exports && hasStageEntry) { + throw new Error( + 'Combining both exports and alpha/beta types is not supported', + ); + } + + // This mutates pkg to fill in index exports, so call it before applying publishConfig + if (pkg.exports) { + await writeExportsEntryPoints(pkg, targetDir ?? packageDir); + } + + // TODO(Rugvip): Once exports are rolled out more broadly we should deprecate and remove this behavior + const publishConfig = pkg.publishConfig ?? {}; + for (const key of Object.keys(publishConfig)) { + if (!SKIPPED_KEYS.includes(key)) { + (pkg as any)[key] = publishConfig[key as keyof typeof publishConfig]; + } + } + + // We remove the dependencies from package.json of packages that are marked + // as bundled, so that yarn doesn't try to install them. + if (pkg.bundled) { + delete pkg.dependencies; + delete pkg.devDependencies; + delete pkg.peerDependencies; + delete pkg.optionalDependencies; + } + + if (targetDir) { + // Lists all dist files, respecting .npmignore, files field in package.json, etc. + const filePaths = await npmPackList({ + path: packageDir, + // This makes sure we use the updated package.json when listing files + packageJsonCache: new Map([ + [resolvePath(packageDir, PKG_PATH), pkg], + ]) as any, // Seems like this parameter type is wrong, + }); + + await fs.ensureDir(targetDir); + for (const filePath of filePaths.sort()) { + const target = resolvePath(targetDir, filePath); + if (filePath === PKG_PATH) { + await fs.writeJson(target, pkg, { encoding: 'utf8', spaces: 2 }); + } else { + await fs.copy(resolvePath(packageDir, filePath), target); + } + } + } else { + await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 }); + } + + if (publishConfig.alphaTypes) { + await writeReleaseStageEntrypoint(pkg, 'alpha', targetDir ?? packageDir); + } + if (publishConfig.betaTypes) { + await writeReleaseStageEntrypoint(pkg, 'beta', targetDir ?? packageDir); + } +} + +// Reverts the changes made by productionPack when called without a targetDir. +export async function revertProductionPack(packageDir: string) { + // postpack isn't called by yarn right now, so it needs to be called manually + try { + await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true }); + + // Check if we're shipping types for other release stages, clean up in that case + const pkg = await fs.readJson(PKG_PATH); + if (pkg.publishConfig?.alphaTypes) { + await fs.remove(resolvePath(packageDir, 'alpha')); + } + if (pkg.publishConfig?.betaTypes) { + await fs.remove(resolvePath(packageDir, 'beta')); + } + + // Remove any extra entrypoint backwards compatibility directories + const entryPoints = readEntryPoints(pkg); + for (const entryPoint of entryPoints) { + if (entryPoint.mount !== '.') { + await fs.remove(resolvePath(packageDir, entryPoint.name)); + } + } + } catch (error) { + console.warn( + `Failed to restore package.json, ${error}. ` + + 'Your package will be fine but you may have ended up with some garbage in the repo.', + ); + } +} + +function resolveEntrypoint(pkg: any, name: string) { + const targetEntry = pkg.publishConfig[name] || pkg[name]; + return targetEntry && joinPath('..', targetEntry); +} + +// Writes e.g. alpha/package.json +async function writeReleaseStageEntrypoint( + pkg: ExtendedPackageJSON, + stage: 'alpha' | 'beta', + targetDir: string, +) { + await fs.ensureDir(resolvePath(targetDir, stage)); + await fs.writeJson( + resolvePath(targetDir, stage, PKG_PATH), + { + name: pkg.name, + version: pkg.version, + main: resolveEntrypoint(pkg, 'main'), + module: resolveEntrypoint(pkg, 'module'), + browser: resolveEntrypoint(pkg, 'browser'), + types: joinPath('..', pkg.publishConfig![`${stage}Types`]!), + }, + { encoding: 'utf8', spaces: 2 }, + ); +} + +const EXPORT_MAP = { + import: '.esm.js', + require: '.cjs.js', + types: '.d.ts', +}; + +/** + * Rewrites the exports field in package.json to point to dist files, as + * well as creating backwards compatibility entry points for importers + * that don't support exports. + */ +async function writeExportsEntryPoints( + pkg: ExtendedPackageJSON, + targetDir: string, +) { + const distFiles = await fs.readdir(resolvePath(targetDir, 'dist')); + const outputExports = {} as Record>; + + const entryPoints = readEntryPoints(pkg); + for (const entryPoint of entryPoints) { + const exp = {} as Record; + for (const [key, ext] of Object.entries(EXPORT_MAP)) { + const name = `${entryPoint.name}${ext}`; + if (distFiles.includes(name)) { + exp[key] = `./${joinPath(`dist`, name)}`; + } + } + exp.default = exp.require ?? exp.import; + + // This creates a directory with a lone package.json for backwards compatibility + if (entryPoint.mount === '.') { + if (exp.default) { + pkg.main = exp.default; + } + if (exp.import) { + pkg.module = exp.import; + } + if (exp.types) { + pkg.types = exp.types; + } + } else { + const entryPointDir = resolvePath(targetDir, entryPoint.name); + await fs.ensureDir(entryPointDir); + await fs.writeJson( + resolvePath(entryPointDir, PKG_PATH), + { + name: pkg.name, + version: pkg.version, + ...(exp.default ? { main: joinPath('..', exp.default) } : {}), + ...(exp.import ? { module: joinPath('..', exp.import) } : {}), + ...(exp.types ? { types: joinPath('..', exp.types) } : {}), + }, + { encoding: 'utf8', spaces: 2 }, + ); + } + + if (Object.keys(exp).length > 0) { + outputExports[entryPoint.mount] = exp; + } + } + + pkg.exports = outputExports; +}