diff --git a/docs/configuration.md b/docs/configuration.md index bc6ec73..aa0cc84 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,16 +95,31 @@ Select which scripts to omit from the output manifest `scripts` field. For example if the build script interferes with your deployment target, but you want to preserve all of the other scripts, set it to `["build"]`. -By default, all scripts are omitted, and the [pickFromScripts](#pickfromscripts) -configuration overrules this configuration. +By default, all scripts are omitted. For the target manifest the +[pickFromScripts](#pickfromscripts) configuration overrules this one, but +`omitFromScripts` still applies to internal dependencies when both are set. ::: info Scripts in internal dependencies -The `pickFromScripts` and `omitFromScripts` options only apply to the target -package manifest. Internal dependency manifests preserve their scripts by -default (e.g. for `postinstall` hooks like Prisma client generation), with one -exception: the `prepare` script is always stripped from internal dependencies -because it runs during `pnpm install` and typically depends on devDependency -binaries that are not available in the isolated output. +Internal dependency manifests preserve their scripts by default (e.g. for +`postinstall` hooks like Prisma client generation), with one exception. The +`prepare` script is always stripped from internal dependencies because it runs +during `pnpm install` and typically depends on devDependency binaries that are +not available in the isolated output. + +`omitFromScripts` applies to internal dependency manifests as well, on top of +that `prepare` strip. This is the way to remove a script the default cannot +anticipate. A `preinstall` with `npx only-allow pnpm` in every workspace +package, for example, fails the install when you deploy the output with npm, so +`omitFromScripts: ["preinstall"]` removes it everywhere. + +`pickFromScripts` is target-only. It does not turn into an allowlist for +internal dependencies, which would silently drop their `postinstall` hooks. + +Both options rewrite the manifests in the output. They do not affect the packing +step that collects each internal package, and `npm pack` and `pnpm pack` run +`prepare`, `prepack` and `postpack` from the package's original manifest. Listing +one of those three names does not stop it running during isolation, so a +`prepack` that fails still fails the isolate. ::: ### omitPackageManager diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index ff71017..d594404 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -8,6 +8,8 @@ import { isolate } from "./isolate"; describe("isolate integration", () => { const temporaryDirectories: string[] = []; + /** Each case shells out to `npm pack`, which outlasts vitest's 5s default */ + const ISOLATE_TEST_TIMEOUT = 30_000; const leftPadPatchContents = "diff --git a/index.js b/index.js\n"; const leftPadPatchHash = "2692094a267de7e28825147fd6cb2ebde098a4e68c25dfa3976ac806f4a1a784"; @@ -71,6 +73,146 @@ describe("isolate integration", () => { ).resolves.toMatchObject({ name: "functions-node", version: "1.0.0" }); }); + /** + * An npm workspace whose target and internal package both carry lifecycle + * scripts, so a test can assert which ones survive into the output. + */ + async function createScriptsWorkspace() { + const workspaceRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "isolate-omit-internal-scripts-test-"), + ); + temporaryDirectories.push(workspaceRoot); + const targetPackageDir = path.join(workspaceRoot, "packages", "functions"); + const sharedPackageDir = path.join(workspaceRoot, "packages", "shared"); + await fs.ensureDir(targetPackageDir); + await fs.ensureDir(sharedPackageDir); + await fs.writeJson(path.join(workspaceRoot, "package.json"), { + name: "workspace", + version: "1.0.0", + private: true, + workspaces: ["packages/*"], + }); + await fs.writeJson(path.join(workspaceRoot, "package-lock.json"), { + name: "workspace", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "workspace", + version: "1.0.0", + workspaces: ["packages/*"], + }, + "node_modules/shared": { + resolved: "packages/shared", + link: true, + }, + "packages/functions": { + name: "functions", + version: "1.0.0", + dependencies: { shared: "1.0.0" }, + }, + "packages/shared": { + name: "shared", + version: "1.0.0", + }, + }, + }); + await fs.writeJson(path.join(targetPackageDir, "package.json"), { + name: "functions", + version: "1.0.0", + main: "./index.js", + files: ["index.js"], + scripts: { + preinstall: "echo target-preinstall", + build: "echo target-build", + test: "echo target-test", + }, + dependencies: { shared: "1.0.0" }, + }); + await fs.writeFile(path.join(targetPackageDir, "index.js"), "export {};\n"); + await fs.writeJson(path.join(sharedPackageDir, "package.json"), { + name: "shared", + version: "1.0.0", + main: "./index.js", + files: ["index.js"], + scripts: { + preinstall: "echo shared-preinstall", + prepare: "echo shared-prepare", + postinstall: "echo shared-postinstall", + build: "echo shared-build", + }, + }); + await fs.writeFile(path.join(sharedPackageDir, "index.js"), "export {};\n"); + + return targetPackageDir; + } + + it( + "omits configured scripts from internal package manifests", + async () => { + const targetPackageDir = await createScriptsWorkspace(); + + const isolateDir = await isolate({ + targetPackagePath: targetPackageDir, + buildDirName: ".", + workspaceRoot: "../..", + omitFromScripts: ["preinstall"], + }); + + const sharedManifest = await fs.readJson( + path.join(isolateDir, "packages", "shared", "package.json"), + ); + const targetManifest = await fs.readJson( + path.join(isolateDir, "package.json"), + ); + + expect(sharedManifest.scripts).toEqual({ + postinstall: "echo shared-postinstall", + build: "echo shared-build", + }); + expect(targetManifest.scripts).toEqual({ + build: "echo target-build", + test: "echo target-test", + }); + }, + ISOLATE_TEST_TIMEOUT, + ); + + it( + "applies omitFromScripts to internal packages when pickFromScripts is also set", + async () => { + const targetPackageDir = await createScriptsWorkspace(); + + const isolateDir = await isolate({ + targetPackagePath: targetPackageDir, + buildDirName: ".", + workspaceRoot: "../..", + pickFromScripts: ["build"], + omitFromScripts: ["preinstall"], + }); + + const targetManifest = await fs.readJson( + path.join(isolateDir, "package.json"), + ); + const sharedManifest = await fs.readJson( + path.join(isolateDir, "packages", "shared", "package.json"), + ); + + /** + * `pickFromScripts` is target-only, so it drops the target's `test` + * script that omission alone keeps, while `omitFromScripts` still has to + * reach the internal package. + */ + expect(targetManifest.scripts).toEqual({ build: "echo target-build" }); + expect(sharedManifest.scripts).toEqual({ + postinstall: "echo shared-postinstall", + build: "echo shared-build", + }); + }, + ISOLATE_TEST_TIMEOUT, + ); + it.each([10, 11])( "writes pnpm %i patch settings to the lockfile and workspace configuration", async (majorVersion) => { diff --git a/src/isolate.ts b/src/isolate.ts index 88e7c0f..57e1a97 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -277,6 +277,7 @@ export function createIsolator(initialConfig?: IsolateConfig) { packagesRegistry, isolateDir, forceNpm: config.forceNpm, + omitFromScripts: config.omitFromScripts, workspaceRootDir, }); diff --git a/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts b/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts index cbe5e9b..8b28d60 100644 --- a/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts +++ b/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts @@ -148,6 +148,73 @@ describe("adaptInternalPackageManifests", () => { }); }); + it("should omit the configured scripts alongside prepare", async () => { + const manifest: PackageManifest = { + name: "@repo/common", + version: "1.0.0", + scripts: { + preinstall: "npx only-allow pnpm", + prepare: "tsdown", + postinstall: "prisma generate", + build: "tsdown", + }, + }; + + const packagesRegistry = createRegistry({ + "@repo/common": { + rootRelativeDir: "packages/common", + manifest, + }, + }); + + await adaptInternalPackageManifests({ + internalPackageNames: ["@repo/common"], + packagesRegistry, + isolateDir: "/output", + forceNpm: true, + omitFromScripts: ["preinstall", "build"], + workspaceRootDir: "/workspace", + }); + + const writtenManifest = writeManifest.mock.calls[0]![1]; + + expect(writtenManifest.scripts).toEqual({ + postinstall: "prisma generate", + }); + }); + + it("should leave scripts untouched when omitFromScripts lists an absent name", async () => { + const manifest: PackageManifest = { + name: "@repo/common", + version: "1.0.0", + scripts: { + postinstall: "prisma generate", + }, + }; + + const packagesRegistry = createRegistry({ + "@repo/common": { + rootRelativeDir: "packages/common", + manifest, + }, + }); + + await adaptInternalPackageManifests({ + internalPackageNames: ["@repo/common"], + packagesRegistry, + isolateDir: "/output", + forceNpm: false, + omitFromScripts: ["preinstall"], + workspaceRootDir: "/workspace", + }); + + const writtenManifest = writeManifest.mock.calls[0]![1]; + + expect(writtenManifest.scripts).toEqual({ + postinstall: "prisma generate", + }); + }); + it("should strip devDependencies from internal dependency manifests", async () => { const manifest: PackageManifest = { name: "@repo/shared", diff --git a/src/lib/manifest/helpers/adapt-internal-package-manifests.ts b/src/lib/manifest/helpers/adapt-internal-package-manifests.ts index 5c7d3ac..55b42fc 100644 --- a/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +++ b/src/lib/manifest/helpers/adapt-internal-package-manifests.ts @@ -17,12 +17,14 @@ export async function adaptInternalPackageManifests({ packagesRegistry, isolateDir, forceNpm, + omitFromScripts, workspaceRootDir, }: { internalPackageNames: string[]; packagesRegistry: PackagesRegistry; isolateDir: string; forceNpm: boolean; + omitFromScripts?: string[]; workspaceRootDir: string; }) { const packageManager = usePackageManager(); @@ -49,9 +51,16 @@ export async function adaptInternalPackageManifests({ * which are not available in the isolated output. Other lifecycle * scripts like `postinstall` are preserved because they handle runtime * setup (e.g. Prisma client generation). + * + * This rewrites the output manifest only. The packing step ran earlier + * against the original manifest, so `prepare`, `prepack` and `postpack` + * have already executed by the time we get here and cannot be omitted. */ if (strippedManifest.scripts) { - strippedManifest.scripts = omit(strippedManifest.scripts, ["prepare"]); + strippedManifest.scripts = omit(strippedManifest.scripts, [ + "prepare", + ...(omitFromScripts ?? []), + ]); } const preparedManifest = isPnpmWorkspaceOutput