Skip to content

About

Pre-publish NPM package cleaner: strips common useless noise and crap from the package.json and tarball; prevents accidental publication of unwanted things and unresolved dependencies. No deps, one js file

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

@anizoptera/publish-clean

Cleans and helps to publish npm packages properly with a clean package.json (no unnecessary entries), verified exports, checks for unwanted files, unresolved workspace dependencies and missing entry points.

npm version Signed provenance CI Node >=22 Runtime deps License

TL;DR — pack once, clean and check that tarball, upload those bytes. It never repacks, so what was verified is exactly what the registry stores and what provenance signs.

pnpm pack
    │
    ▼
rewrite package.json inside the tarball
    │
    ▼
re-read it from disk and check it
    │
    ├─▶ unrepaired, and it leaks
    │   or breaks a consumer
    │   └─▶ exit 1
    │
    └─▶ repaired, or only reported
        └─▶ npm publish <that exact file>
pnpm exec publish-clean verify     # all checks, uploads nothing
pnpm exec publish-clean --dry-run  # checks, prints file list and manifest
pnpm exec publish-clean -- --provenance --access public   # check, then publish
What you get How
Broken packages stop here Declared entry points that resolve to nothing, exports conditions in an order that loses a consumer its target, require branches resolving to ESM, names two filesystems read as one file, bin without a shebang, workspace:/catalog: specs left unresolved.
Credential files can't ship A packed .env, .npmrc, .pem/.key/keystore or SSH key aborts the run. No flag waives it, and it is not stripped for you: a stripped secret has still leaked and still needs rotating. Matched by file name, so a credential hardcoded inside your source is not caught.
No dead weight devDependencies, files, packageManager, workspace and catalog config, and tool config blocks (jest, eslint, prettier, turbo, …) leave the published manifest. So does the whole scripts block, unless it holds an install or prepare hook consumers actually run. Packed files nothing references are reported.
Checked bytes = published bytes The manifest is rewritten inside pnpm's own tarball and that file is uploaded. A pipeline that repacks after checking signs bytes nothing verified.
Rules measured, not guessed Every rule and every tolerance was measured against thousands of installed published packages first. Equality alone on declared paths refused 373 of 5192; a fixed exports order refused 97 of 3674.
Nothing new to trust One JavaScript file, zero runtime dependencies. It runs on the path that handles your registry token, so a transitive package would be unaudited code next to a live credential.
Check without publishing verify gates pull requests and works on private: true packages. Exit 1 on failure, findings on stderr.

It edits nothing outside the tarball, and fixes only what the packed files settle without guessing. One run lists every defect instead of stopping at the first. What it repaired does not fail the run; what it could not repair does. Every judgement call has a flag to waive it. A leaked credential does not.

Why

Publishing is the one step you cannot take back. npm lets you unpublish within 72 hours only if no other package depends on yours. After that you also need fewer than 300 weekly downloads and a single maintainer. The version number is gone either way: npm's policy is that "once package@version has been used, you can never use it again". You fix a bad release by publishing another one.

The defects that reach consumers are the ones your own machine cannot show you:

Defect Your machine Consumer installs Consumer uses it
Two packed names differing only in letter case fine (case-sensitive disk) reports success file silently missing
bin file with no shebang fine (you run node file.js) fine exec format error
Package imports itself through a subpath exports hides fine (resolves by path in your repo) fine cannot resolve
./Utils.js imported as ./utils.js fine on macOS fine fails on Linux
types pointing at JavaScript with no .d.ts fine fine every type silently any
require branch resolving to an ES module fine (you use import) fine fails on older Node

Green the whole way down. Nothing in the normal chain fails, so the first report comes from a stranger, against a version you can no longer replace.

A packed secret is already leaked

The worst case is not a broken package. It is a .env or an .npmrc inside the tarball. By the time anything notices, the key exists outside your machine: it was written into an archive, and if the upload went through, into a registry that hands it to anyone who asks.

publish-clean refuses those outright. No flag waives it and --strict has nothing to add. It will not strip the file for you either, though that is what most people expect: a stripped secret has still leaked, and a clean-looking artifact is how it goes unrotated. The report says to rotate the credential first, then narrow files.

Dead weight ships forever

The second cost is invisible to the only person who can fix it. You pay nothing for a bloated package: you build it once. Everyone else pays, on every install, every CI run, every Docker layer. Those bytes sit in every node_modules that ever resolved your package, and they cannot be taken out of a version that is already published.

Nothing complains, because nothing is broken. A shipped __tests__ directory installs fine. A jest config block in package.json parses fine. Both are downloaded forever by people who will never use them.

So waste here is a defect with a rule id and an exit code, not a matter of taste. The one thing this tool will not do is drop a manifest field it does not recognise: breaking a stranger's build costs more than the bytes do.

Rules are measured, not guessed

A checker that rejects a working package is worse than no checker. Its mistake and a real defect look identical from the outside, and the only way to find out which you have is to publish anyway. That is the step this tool exists to protect.

So no rule here is written from the spec alone. Each is measured against thousands of installed packages first, and every tolerance exists because its absence refused a package that works. Matching declared paths to packed names by equality refused 373 of 5192 packages: vite, svelte, every @types/* package, every @aws-sdk client. Demanding a fixed order for exports conditions refused 97 of 3674. After the tolerances those measurements forced, 14 remain, every one a real defect. docs/exports.md carries the measurements and the specimens.

If it still refuses a package that works, that is a bug here. Report it.

What it does

publish-clean removes devDependencies, workspace settings and tool config from the published manifest. Consumer fields and lifecycle helpers stay. Unknown fields are reported for review and kept unless you choose to remove them.

It packs once with pnpm, cleans the manifest inside the tarball, checks that file, then uploads it with npm. Cleaning leaves your source files alone; your pack hooks still run and can change them.

Check, then publish:

pnpm exec publish-clean verify
pnpm exec publish-clean -- --access public --tag latest --provenance

verify runs every check and publishes nothing. It works on a private: true package, so a package that never goes to a registry can still be checked by the rules it would face.

Requires Node.js 22+ and pnpm, plus npm to publish. The publish command above needs CI provenance setup.

Install

Project Install Run
pnpm pnpm add -D @anizoptera/publish-clean pnpm exec publish-clean
Bun bun add -d @anizoptera/publish-clean bunx publish-clean
npm npm i -D @anizoptera/publish-clean npm exec publish-clean
Yarn yarn add -D @anizoptera/publish-clean yarn publish-clean

Both pnpm and npm must be on PATH to publish, including in Bun, npm and Yarn projects and CI. pnpm alone is enough to check one: verify and --dry-run stop before the upload, so they never start npm. pnpm and npm are separate requirements, not bundled dependencies.

The CLI is one JavaScript file with no runtime dependencies, and that is deliberate: it sits on the publish path and handles registry credentials, so any transitive package would be unaudited code next to a live token.

pnpm 12 installs its native binary from its own install script, so install it with build scripts allowed. Under Bun, which blocks them by default, list pnpm in trustedDependencies. Otherwise pnpm's command stays a placeholder that this tool cannot start. pnpm 11 needs nothing special.

For trusted publishing, use Node.js 22.14+ and npm 11.5.1+. Provenance requires a public package, a public source repository and a supported CI provider. See npm's requirements.

Running it under Bun

gzip bytes depend on the runtime and version executing the CLI. To run it under Bun, pass Bun the file path:

bun ./node_modules/.bin/publish-clean

The installed command has a Node shebang. Passing the file directly avoids relying on how a package runner selects an interpreter or changes the child tools' environment.

Keep the runtime and its version fixed when rebuilding a release artifact. Node and Bun can encode the same archive contents into different gzip bytes, which changes the artifact's hash.

Package managers

The CLI uses pnpm for workspace resolution and publishConfig overrides, and npm to upload the checked tarball. The packer comparison explains the trade-offs.

Starting through another package manager prints an advisory on stderr. It does not change the packer or stop publication, and there is no option to suppress it.

For workspace: and catalog: specs, pnpm needs the installed dependency in the packing package's own node_modules. Compatibility depends on your install layout.

Bun gives each package its own node_modules, so a Bun workspace packs as it stands: no pnpm-workspace.yaml, no switching package managers. Yarn hoists workspace dependencies to the root instead, so pnpm doesn't find them, and Yarn PnP writes no node_modules at all. Both need a pnpm-workspace.yaml and one pnpm install before packing works. A Yarn package with no workspace: or catalog: specs needs neither, because there's nothing to resolve.

Install the workspace before packing. An unresolved workspace dependency stops packing:

ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL

Install with the layout described above, then retry.

Pick your setup

Publishing a public package from CI

Configure an npm trusted publisher for your repository and workflow filename, with permission for direct npm publish. In the publish job, grant id-token: write so npm can authenticate through GitHub OIDC without an npm token. Run tests in a separate job and make the publish job depend on their success.

# Inside the publish job, after its required check jobs have passed:
permissions:
  contents: read
  id-token: write
steps:
  - uses: actions/checkout@v7
  - uses: pnpm/action-setup@v6
    # Set packageManager in package.json to select your pnpm version.
  - uses: actions/setup-node@v7
    with:
      node-version: "24"
      registry-url: https://registry.npmjs.org
  - run: pnpm install --frozen-lockfile
  - run: pnpm run build
  - run: pnpm exec publish-clean -- --access public --tag latest --provenance

Use --tag latest for stable releases and --tag next for prereleases. npm defaults to latest, so omitting the tag can give ordinary installs a prerelease.

For a new package name, follow the first-publish setup.

To block direct publication from your source directory, add this hook. Publishing a tarball does not run it:

{
  "scripts": {
    "prepublishOnly": "node -e \"console.error('Publish with publish-clean.'); process.exit(1)\""
  }
}

Checking every pull request

verify needs no registry, no credentials and no npm, so it runs in ordinary PR CI next to your tests:

- run: pnpm exec publish-clean verify

A packaging defect then shows up in the change that caused it, instead of on release day with everyone waiting. Most of what this tool finds is introduced by an edit to package.json or to the file layout, so the PR that made the edit is where the answer is cheapest.

--strict is worth considering here even if you do not use it when publishing: a warning that fails a PR costs a rerun, while the same warning at release costs a version number.

Keeping your existing release tool

If your release tool supports a custom upload command, use publish-clean there. For a preview check only, add:

{
  "scripts": {
    "prepublishOnly": "publish-clean verify"
  }
}

It checks a pnpm-produced preview and exits without publishing. Your release tool still creates and uploads its own artifact, which may differ: this gate does not validate those uploaded bytes or clean their manifest. To publish the checked bytes, configure that tool to invoke publish-clean for the upload instead.

Looking at what would be published

pnpm exec publish-clean --dry-run

Prints the checked file list and cleaned package.json. Add --tarball-out DIR to keep the tarball; otherwise temporary files are removed.

Publishing a restricted package

publish-clean -- --access restricted --tag latest

Omit provenance for restricted packages. Leave private: true unset: it prohibits publication, even to a private registry.

Adding it to a package that already exists

A package published for years will usually report several findings the first time. You do not have to clear them all before you can publish again.

Run verify first: it shows the whole list and cannot publish anything. Then decide per finding. The judgement calls have opt-outs, one per rule, so you can disagree with one and keep shipping: --allow-suspicious for development files you ship on purpose, allowUnreferenced for files nothing imports by design, --skip-file-check for a package with no files array.

What has no opt-out is a leaked credential, a packed node_modules or .git, and a defect that breaks a consumer. Those are the ones worth stopping for.

Publishing one package out of a monorepo

publish-clean packages/my-lib -- --access public --tag next

How it works

flowchart TD
  A[Your package directory] -->|pnpm pack| B[Tarball, file set chosen by pnpm]
  B --> C[Rewrite only package.json inside]
  C --> D[Final tarball]
  D --> E{Checks, all reading this tarball}
  E -->|any fails| F[Exit non-zero, publish nothing]
  E -->|all pass| G[npm publish this same tarball]
Loading

Exit code is 0 when nothing stopped the run, 1 when something did, and 130 or 143 when a SIGINT or SIGTERM cancelled it. A CI job can tell a real failure from a cancelled runner by that alone. Findings and the verdict go to stderr; --dry-run prints the file list and the cleaned manifest on stdout, so you can read one without the other.

verify and --dry-run validate the artifact, but skip publication preflight. Neither proves that registry access, credentials, provenance requirements or repository identity are correct. Pack hooks still run, including any network operations they perform.

Why it works this way

Why cleaning happens in the tarball

Cleaning stays out of the source tree so an interrupted publish cannot leave your package.json half-edited. Pack hooks still run in the source directory and may change it.

Why the tarball is edited instead of packed again

Uploading the checked tarball prevents npm from choosing files again. Cleaning removes files from its manifest because installation no longer needs that packing instruction. Repacking the cleaned directory could then fall back to ignore rules and drop files the first pack included.

Only package/package.json is replaced. Other archive entries retain their bytes, order and metadata, including pnpm's owner 0:0, fixed timestamps and file modes. The reader resolves USTAR, PAX and GNU long-name paths before checking files and rejects malformed or unsupported path metadata. A path too long for a plain tar name is stated by a header in front of the entry it names, so every guard judges the path the archive actually extracts to rather than the placeholder in the entry's own header.

pnpm pack runs pack hooks, including prepare and prepack. npm runs no package lifecycle scripts when uploading a tarball, so those hooks cannot change the checked artifact during upload.

What this buys for provenance

npm builds a provenance attestation from the SHA-512 of the file it uploads. A pipeline that checks one artifact and then repacks signs bytes that nothing verified, and the attestation still looks perfectly valid. Uploading the checked file is what makes the signature cover what the checks actually passed.

What it checks

Every finding prints as publish-clean [severity] rule-id at where. The id in brackets below is that id. Search this file for the one in your output.

Two rules cover the whole output:

  • [warning] never stops the run. Wasted bytes, nothing a consumer can trip over. --strict turns these into errors.
  • [error] stops the run, unless the finding says it was repaired. A repair fixed the published tarball, not your source, so it still prints as an error and you still have something to fix. It does not stop the publish, because the artifact going up is correct. --strict never changes that.

A leaked credential or a packed node_modules/.git always stops the run, and no flag waives it. Every run ends with a verdict line, so a pass is never silent.

Publication stops when:

  • the package is marked private: true
  • the working tree has uncommitted changes (--no-git-checks to allow it). A directory under no version control has no commit to differ from, so it warns and continues instead
  • the package has no non-empty files array (--skip-file-check to allow it)
  • the tarball contains a recognised test, CI, lockfile or tsconfig path (--allow-suspicious to allow it) [suspicious-file]
  • a filename matches the protected rules for environment files, npm credentials [secret-file], or Git internals and node_modules [internal-file]; these checks cannot be disabled
  • a dependency is still written as catalog:, workspace:, link: or portal:, or a local dependency points outside the shipped files [monorepo-only-spec]
  • a declared entry point cannot resolve against the shipped files; checks account for extension lookup, conditions and fallbacks. A declared path no consumer can resolve — an object browser, sideEffects, an internal # import, a * pattern matching no packed file — reports and continues instead [declared-path-inert], and --strict refuses it
  • rewriting the manifest changed anything else in the tarball
  • GitHub trusted publishing or provenance is enabled, but the repository in your manifest is not the repository the workflow is running in
  • a require condition resolves to an ES module [require-branch-is-esm]. Node can require one only from 20.19 and 22.12 onward, a consumer can switch that off, and top-level await fails on every version. Point require at a CommonJS build, or add a module-sync branch
  • a condition sits out of canonical order where reordering it would change what somebody resolves, and a consumer really loses its target: a runtime-specific build shadowed by a generic one. The tool will not guess here, so it reports and stops instead [exports-condition-order-unsafe]
  • two packed names differ only in letter case or Unicode form [packed-name-collision]. They collapse onto one path on macOS and Windows, which ignore both by default. Two files means one silently overwrites the other and the install still reports success; a file colliding with a directory means the install fails outright. Only a case-sensitive filesystem can produce the pair, which is why the author never sees it
  • a packed name Windows cannot create [packed-name-unportable]: a path component named after a DOS device (aux, con, nul, com1…, with or without an extension), a character such as : or ?, a trailing dot or space. Declare "os": ["!win32"] if the package genuinely does not run there and this stops applying. A path component over 255 bytes is refused regardless, because no filesystem accepts one
  • a bin file has no shebang [bin-no-shebang], or its shebang ends in CR [shebang-carriage-return]. An installer symlinks the file and the command is executed directly, so without that first line Linux and macOS fail with exec format error and npm has no interpreter for its Windows shim. A trailing CR is invisible in an editor and makes the kernel look for an interpreter literally named node\r. A bin file holding a NUL byte in its first 512 bytes is exempt: it is a compiled binary
  • a shipped file imports another by the wrong letter case [import-case-mismatch], so it works on the author's macOS and fails on a consumer's Linux
  • the package imports itself by name through a subpath its exports does not expose [self-import-not-exported], which resolves for nobody, and looks fine in your own repository, where it resolves by path
  • the tarball holds a file nothing in the package reaches [unreferenced-file]: no entry point, no import from a reached file, no script. Declare the ones that are deliberate: "publish-clean": { "allowUnreferenced": ["assets"] }, matched as a prefix, so naming a directory covers everything under it. This check only runs when the package has an exports field, because that is what makes unlisted paths unimportable. Without it every shipped file is reachable and none is dead.

A malformed registry URL [registry-not-a-url], or one carrying a password [registry-credentials], also stops the run. Treat such a password as compromised: it is in your package.json and was about to be published inside the tarball's manifest.

File guards match paths, not file contents. A credential hardcoded inside an otherwise allowed source file is published and nothing here reports it.

What it repairs

An exports map is order-sensitive: a consumer activates a whole set of conditions at once and takes the first key in that set, so the order you wrote picks the winner. publish-clean flattens the map into the resolution it produces for every possible consumer, and rewrites it only when the result is provably identical: dropping a node branch that repeats default [exports-inert-condition], collapsing {"default": "./x.js"} [exports-redundant-default], and putting keys that real resolvers require in a fixed order into it [exports-condition-order]. A branch no consumer reaches [exports-unreachable-branch], a consumer no branch serves [exports-unresolvable] and a condition name no known runtime or bundler activates [exports-unknown-condition] are reported, never guessed at. Every repair is reported, lands only in the published manifest, and never touches your source. --no-heal, or "publish-clean": { "heal": false }, reports without rewriting.

types is the one exception, and the only rewrite here that changes what somebody resolves. Only a type checker activates it, so nothing that runs your package can tell the difference:

  • a types branch pointing at JavaScript with no declaration file beside it is removed [types-branch-not-declarations]. It promised an API the package does not carry, and a checker was reading that JavaScript as your declarations and typing everything any.
  • declarations hidden behind a key that leads a checker nowhere are moved to the front [types-branch-unreachable], where every checker looks first.

Both print as errors — fix your source — and neither stops the publish, because the published artifact is correct. Under --no-heal neither rewrite is applied, and then both defects stop it.

Anything the archive cannot settle is left exactly as you wrote it: a types target the package does not ship is a missing file and keeps that report, and nothing is moved across a condition this tool does not recognise, since a private name may be meant for a consumer configured to take it.

Still reported and left alone: anything inside a fallback array [exports-fallback-array], because Bun resolves those differently from Node and Deno and no rewrite is safe for everyone, and any map too large to enumerate [exports-too-complex]. docs/exports.md has the measured condition sets behind these rules.

What the cleaned manifest keeps

The cleaner preserves consumer and registry fields: name, version, license, dependencies, peerDependencies and their meta, exports, main, module, types, bin, engines, os, cpu, sideEffects and publishConfig, among others.

It removes files after packing; installers extract the tarball without using that field.

It also removes devDependencies, workspaces, pnpm, packageManager, overrides, resolutions, and the config blocks belonging to test runners, linters, formatters, coverage tools, build systems and release tools. When preinstall, install, postinstall, prepare or uninstall exists, the complete scripts block survives: a lifecycle may call any helper script. Otherwise the development-only scripts block is removed.

Unknown fields are kept and reported:

publish-clean [warning] unrecognized-field at 1 manifest field
These manifest fields are not recognised and are retained as-is:
  "someToolConfig"
Strip the ones consumers do not read, and acknowledge the ones they do:
  "publish-clean": { "devFields": ["someToolConfig"] }
  "publish-clean": { "keepFields": ["someToolConfig"] }

Unknown fields stay because removing an unfamiliar field can break a consumer's build. Use devFields to remove a field you know is development-only, or keepFields to suppress its report. --strict promotes this warning to an error like any other. Inspect the result with --dry-run.

Options and config

publish-clean [options] [package-dir] [-- npm-publish-args]

Set project defaults in package.json. A CLI registry overrides the configured registry; boolean flags enable their setting. Pass per-release npm options, such as dist-tags, after --:

{
  "publish-clean": {
    "registry": "https://registry.npmjs.org",
    "skipFileCheck": false,
    "allowSuspicious": false,
    "noGitChecks": false,
    "devFields": ["customBuildOnlyField"],
    "keepFields": ["contributes"]
  }
}
Flag package.json What it does
verify [dir] - Run every check and publish nothing. Works on a private: true package.
--verify-only - The verify subcommand, for scripts that can only pass flags.
--strict - Treat warnings as errors. Never makes an already-applied repair fatal.
--no-heal heal: false Report repairable exports/imports defects without repairing them.
--dry-run - Validate a preview artifact and print its file list and cleaned package.json.
--guard-only - Deprecated alias for verify; fails on a private: true package.
--tarball-out DIR - Copy the final tarball into DIR before publishing.
--registry URL registry Set publishConfig.registry on the cleaned manifest, and publish to it.
--skip-file-check skipFileCheck Allow a manifest with no files array.
--allow-suspicious allowSuspicious Allow tests, CI config, lockfiles or tsconfig in the artifact.
--no-git-checks noGitChecks Allow publishing from a dirty working tree.
- devFields Extra manifest fields to strip.
- keepFields Fields that belong in the published package, so stop reporting them.
- allowUnreferenced Shipped paths nothing imports, on purpose. Matched as a prefix, so a directory name covers everything under it.
- validateArtifact Run your checks on the cleaned tarball before copying or publishing it.
-h, --help - Print usage, every flag, and the config keys.
-v, --version - Print the installed version.

Arguments after -- accept publication options only; --help lists them. Extra package operands, workspace selectors, unknown options and values starting with - are rejected so npm cannot substitute an unchecked package. For a filename starting with -, use ./. Pass the dist-tag explicitly: --tag latest for a normal public release.

--tarball-out saves the checked bytes in every mode, before any upload. You can inspect them, attach them to a release or attest them, even if publication fails.

registry sets both the general and package-scope destination in the cleaned manifest. Registry URLs must not contain usernames or passwords, including scoped destinations in publishConfig. Configure npm authentication in npm configuration instead.

skipFileCheck waives the manifest's files requirement. allowSuspicious permits the listed development files. Neither disables the protected filename checks.

Use noGitChecks to publish from a checkout whose working tree is dirty. A directory outside any Git repository does not need it: there is no commit there for a tree to differ from, so the check reports that it was skipped and the run continues.

devFields refuses known consumer fields such as exports, bin, engines and dependency maps to prevent accidental removal.

Use keepFields for consumer fields the tool does not recognise, such as a VS Code extension's contributes and publisher. It suppresses reports; it does not restore stripped fields.

Validate the final artifact

Use validateArtifact to check what users will install, such as package imports or type declarations. Add the command to your package.json:

{
  "publish-clean": {
    "validateArtifact": ["node", "scripts/check-artifact.mjs"]
  }
}

This runs node scripts/check-artifact.mjs /absolute/path/to/package.tgz from your package directory. Read the tarball path from process.argv.at(-1); configured arguments come before it.

The command runs once after built-in checks and before publication or a --tarball-out copy. It also runs in verify and --dry-run modes. The config is removed from the published manifest.

The first item must name an executable on PATH or by its path. Use node or bun to run scripts, including on Windows; .cmd shims are not supported. Arguments are passed literally, without a shell: no pipes, redirection or environment assignments. Relative paths start at your package directory.

Exit with code 0 to pass or a nonzero code to reject the package. A failed launch, failed check, or changed or deleted tarball stops publication and copying. Output is hidden on success and shown on failure, with a capture limit to bound memory use. Cancellation stops the command and its child processes before removing temporary files.

Your script runs with your permissions. Keep it read-only and wait for its child processes to finish; publish-clean does not sandbox it or protect other project files.

Check the supplied tarball. Packing again checks different bytes; calling publish-clean from the script runs the same hook again.

What it does not do

Use your release tool for versions, changelogs, tags and GitHub releases. Configure trusted publishing separately. Built-in checks verify declared paths. To test package imports or type declarations, supply a validation command.

Bundled node_modules are not allowed. pnpm may first reject bundleDependencies with:

Add "nodeLinker: hoisted" to pnpm-workspace.yaml or delete bundleDependencies

A hoisted layout can make pnpm pack them, but the resulting node_modules entries still fail this tool's file checks. Use another publication path if you need to ship them.

Related tools

clean-publish cleans a temporary copy of the source tree before publishing. publish-clean instead cleans a pnpm-produced tarball.

That preserves pnpm's file selection and workspace resolution, and lets the checks read the same bytes npm uploads.

Release tools such as Changesets, semantic-release, release-please, release-it and np handle release tasks. See integration for the difference between a preview check and publishing through this CLI.

publint and @arethetypeswrong/cli check package entry points and TypeScript compatibility. Run them alongside this tool. They only report: they do not clean the manifest, repair anything, or publish. They also cover what this tool deliberately leaves out of scope: type resolution and module-format analysis. Neither replaces the other, and no check here is skipped on the grounds that publint reports it too.

npm publish --dry-run previews npm's publication. It does not clean the manifest or apply this tool's protected filename rules.

npm trusted publishing authorises a CI workflow to publish. Provenance links a release to its source and build; it does not check package contents for you.

pkg-pr-new publishes preview builds per commit without publishing a version to npm.

Underlying behaviour is defined by npm-packlist, npm pack, npm publish, pnpm pack and pnpm publishConfig.

Contributing

bun install --frozen-lockfile
bun run check

CONTRIBUTING.md has the rest. Security problems go through private reporting, not public issues: see SECURITY.md.

License

Apache-2.0. Copyright 2026 Anizoptera and Art Shendrik.

About

Pre-publish NPM package cleaner: strips common useless noise and crap from the package.json and tarball; prevents accidental publication of unwanted things and unresolved dependencies. No deps, one js file

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages