Skip to content

feat: add deno pack command to create npm tarballs - #32139

Merged
bartlomieju merged 29 commits into
mainfrom
deno-pack
May 6, 2026
Merged

bartlomieju merged 29 commits into
mainfrom
deno-pack

Conversation

@marvinhagemeister

@marvinhagemeister marvinhagemeister commented Feb 12, 2026 •

Copy link
Copy Markdown
Contributor

Add deno pack command that creates npm-compatible tarballs from
Deno/JSR projects. It transpiles TypeScript to JavaScript, rewrites
jsr:/npm: specifiers to bare npm names, generates package.json
with conditional exports and .d.ts type entries, and bundles
everything into a .tgz ready for npm publish.

Types are extracted via fast-check (same as deno publish). When
Deno API usage is detected, @deno/shim-deno is automatically
added as a dependency.

Usage examples

# Basic: pack the current project
deno pack

# Override version without editing deno.json
deno pack --set-version 2.0.0

# Write tarball to a specific path
deno pack --output my-package.tgz

# Preview contents without creating the tarball
deno pack --dry-run

# Skip .d.ts generation (allow slow types)
deno pack --allow-slow-types

# Don't inject @deno/shim-deno polyfill
deno pack --no-deno-shim

# Pack with uncommitted changes
deno pack --allow-dirty

# Exclude test files
deno pack --ignore=tests/

# Publish the tarball to npm
npm publish scope-package-1.0.0.tgz

What it does

Given a deno.json like:

{
  "name": "@scope/my-lib",
  "version": "1.0.0",
  "exports": "./mod.ts"
}

Running deno pack produces scope-my-lib-1.0.0.tgz containing:

  • package.json with name, version, type: "module",
    conditional exports (types/import/default), and extracted
    dependencies
  • Transpiled .js files (with inline source maps by default)
  • Generated .d.ts declaration files (via fast-check)
  • README and LICENSE files (auto-included if present)

Specifier rewrites:

  • jsr:@std/path -> @jsr/std__path
  • npm:express@4 -> express
  • ./utils.ts -> ./utils.js
  • node:fs -> unchanged

How deno pack differs from npm pack

deno pack is not an npm pack equivalent -- it is a build tool
that converts a Deno/JSR project into an npm-publishable package,
closer to tsc + npm pack combined.

Step npm pack deno pack
Transpile TS to JS No Yes (via deno_ast)
Generate .d.ts No Yes (via fast-check)
Rewrite specifiers No jsr: -> @jsr/, npm: -> bare, .ts -> .js
Generate package.json Reads existing Generates from deno.json
Extract dependencies No Scans jsr:/npm: imports, populates dependencies
Deno API shimming No AST-based detection, injects @deno/shim-deno
Source maps No Inline by default (--no-source-maps to disable)
Shebang handling Passthrough Strips before transpile, re-prepends after
File collection files field + .npmignore Module graph walk from exports
Input format package.json deno.json

Notable behavioral differences:

  • File selection is graph-based: only files reachable from
    exports end up in the tarball, not a file listing. Non-JS assets
    (data files, WASM) are not included unless imported.
  • No .npmignore/.gitignore support: uses --ignore flag and
    hard-coded exclusions (.git, node_modules, .env*).
  • No lifecycle scripts: prepublishOnly/prepare hooks are not
    executed.
  • No bundledDependencies/optionalDependencies: the generated
    package.json only contains dependencies.
  • JSR deps use @jsr/ scope: e.g. jsr:@std/path becomes
    @jsr/std__path, which requires consumers to have the JSR npm
    registry configured or use npx jsr add.

Implementation details

  • New modules under cli/tools/pack/: mod.rs, extensions.rs,
    unfurl.rs, package_json.rs, npm_tarball.rs
  • Shared unfurl utilities extracted to cli/tools/unfurl_utils.rs
    (reused by deno publish)
  • check_if_git_repo_dirty moved to cli/util/git.rs (shared
    between pack and publish)
  • Reproducible tarballs: BTreeMap for deterministic key ordering,
    sorted tar entries, fixed tar headers (mode/mtime/uid/gid)
  • Security: symlink checks on auto-included files, .env/.git
    exclusion, path traversal protection on tarball filenames

The changeset is large but most of it is test coverage in
tests/specs/pack/.

@marvinhagemeister
marvinhagemeister force-pushed the deno-pack branch 2 times, most recently from 9ed683b to 30189e1 Compare February 12, 2026 13:17
Comment thread cli/args/flags.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new deno pack command that generates npm tarballs from the current project. It transpiles TypeScript to JavaScript, rewrites specifiers and generates a package.json with the relevant sections filled.

What if someone wanted to just pack a project that's good to go already? Maybe it should not do any of these things by default and instead it's just a straight pack into a tarball? Then we can have other subcommands for doing the other steps or add flags to do it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a project is good to go already they can use npm pack itself imo.

Comment thread cli/tools/publish/mod.rs Outdated
}

async fn check_if_git_repo_dirty(cwd: &Path) -> Option<String> {
pub(crate) async fn check_if_git_repo_dirty(cwd: &Path) -> Option<String> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably go into the utils module for reuse.

@bartlomieju

Copy link
Copy Markdown
Member

It would be nice if deno install could install a tarball (https://docs.npmjs.com/cli/v11/configuring-npm/package-json#urls-as-dependencies)

bartlomieju and others added 2 commits March 28, 2026 09:50
# Conflicts:
#	cli/tools/publish/unfurl.rs
#	tests/node_compat/runner/suite
- Remove duplicate Pack arm in factory.rs
- Guard .d.ts/.d.mts/.d.cts in ts_to_js_extension and ts_to_dts_extension
- Track import declarations in DenoUsageVisitor to prevent shim conflicts
- Handle .d.mts/.d.cts in rewrite_file_extension
- Warn on file: URLs instead of mangling them in specifier rewriting
- Validate --set-version is valid semver
- Fix extract_dts fallback to emit stub instead of simplified TS
- Add LICENSE.txt/LICENCE.txt/license.txt to license file lookup
- Wrap single-string exports in "." key for standard conditional exports
- Add .cts -> .cjs extension mapping in transpile output
- Remove duplicate ImportMetaResolveCollector from merge
- Fix clippy allow-without-reason on ProcessedFile.specifier

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@bartlomieju bartlomieju added this to the 2.8.0 milestone Apr 9, 2026
# Conflicts:
#	cli/args/flags.rs
Extracted from cli/tools/publish/mod.rs to cli/util/git.rs so it can
be reused by deno pack without crossing tool module boundaries.

Addresses review feedback on #32139.
…E/LICENSE inclusion

- workspace: pack a workspace containing two JSR packages and verify
  both tarballs are produced.
- allow_slow_types: cover the default fast-check path (which falls
  back to a stub when types can't be inferred) and the --allow-slow-types
  path (which skips fast-check entirely).
- readme_license: verify README.md and LICENSE files are picked up
  and included in the tarball under the package/ prefix.
macOS `sort` is case-insensitive by default, which placed README.md
after package.json on the CI runner and didn't match the expected
ASCII order. Pin LC_ALL=C so the order is deterministic across
platforms.
fibibot

This comment was marked as off-topic.

DenoUsageVisitor only fired when the object of a member expression
was the Deno ident, so globalThis.Deno and globalThis["Deno"] slipped
through (the Deno token there is a property, not an Ident node, and
visit_ident never sees it).

Extend visit_member_expr to flag both forms when the object is
globalThis. Add unit tests for the positive case and a negative case
(globalThis access of other names).
- npm_tarball: pin tar entry mode/mtime/uid/gid so two pack runs of the
  same source produce a bit-identical .tgz (matches `npm pack`).
- npm_tarball: normalize backslashes to forward slashes in tar paths
  on Windows. Tar archives must use POSIX separators.
- npm_tarball: reject derived tarball filenames containing ".." or "/"
  as a defense-in-depth check on top of @scope/name validation.
- util/git: don't panic if `git status` fails to spawn (killed,
  permissions, repo missing) — treat the same as "git not installed"
  and let the user proceed.
- pack/extensions: fall back to string-level extension stripping when
  Path::file_stem returns None or non-UTF8 (rare but possible on
  Windows).
- pack/mod: replace clone+unwrap-after-is_none-check with let-else.
- pack/package_json: bail on unrecognized exports shape instead of
  silently writing "./mod.js".
- flags help: add an example line for `deno pack` for parity with
  other commands.

Tests added:
- npm_specifier: end-to-end check that npm: specifiers are rewritten
  to bare names and recorded in package.json#dependencies.
- import_meta_resolve: verify import.meta.resolve() arguments get the
  same .ts → .js extension rewrite as static imports.
- output_flag: verify `--output` overrides the default filename.
- reproducible: verify two pack runs produce a bit-identical tarball.
@crowlKats

Copy link
Copy Markdown
Member

@fibibot re-review

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good overall structure — clean module split under cli/tools/pack/, proper refactoring of shared utilities (unfurl_utils.rs, util/git.rs), and comprehensive test coverage (20+ spec tests). The globalThis.Deno detection fix and the check_if_git_repo_dirty extraction are responsive to review feedback.

A few broader observations beyond the inline comments:

  • No CJS compatibility: generated exports only have import + default conditions with "type": "module". CJS consumers cannot require() the package. Probably intentional but limits npm adoption — worth documenting or adding a --cjs option in the future.
  • Missing test coverage: no spec test for .cts → .cjs extension mapping (code handles it but nothing exercises it), no test for JSR dependencies in the generated package.json (only npm specifiers are tested via npm_specifier test), and no test verifying the file: URL warning path.

Comment thread cli/tools/pack/unfurl.rs Outdated
Comment thread cli/tools/pack/mod.rs Outdated
Comment thread cli/tools/pack/npm_tarball.rs Outdated
Comment thread cli/tools/pack/mod.rs Outdated
log::warn!("Failed to emit DTS: {}", e);
// Do not fall through to fast_check.source as it is simplified
// TypeScript, not valid .d.ts
return Some("export {};".to_string());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emitting export {}; as a fallback means the .d.ts file claims nothing is exported. Since package.json still has a types field pointing at this stub, TypeScript will resolve the .d.ts and conclude the module has no exports — arguably worse than having no types at all.

Consider either:

  • Omitting the .d.ts file and types field when generation fails, or
  • At minimum adding a comment: // deno pack: types could not be generated

(Same applies to the two other export {}; stubs below at lines 861 and 869.)

Comment thread cli/args/flags.rs Outdated
Comment thread cli/tools/pack/package_json.rs
- jsr: specifier rewriting now produces the @jsr/scope__name name that
  JSR uses on the npm registry (e.g. jsr:@std/path → @jsr/std__path).
  Without this the generated package.json listed @scope/name deps that
  npm install couldn't resolve.
- shebang preservation: the @deno/shim-deno import was being injected
  at byte 0, which placed it before any leading shebang and broke
  executable scripts. Strip the shebang up front, run unfurl + shim +
  transpile on the body, then re-prepend it to the emitted JS.
- d.ts stubs: when fast-check can't generate types we no longer emit a
  bogus `export {};` stub. The .d.ts file is omitted from the tarball
  and the corresponding `types`/conditional `types` entries are
  dropped from package.json so TypeScript doesn't resolve a phantom
  module that "exports nothing".
- npm_tarball: `to_tar_path` is now an unconditional `\\` → `/` replace
  rather than gated on `cfg!(windows)`.
- package_json: documented why DENO_SHIM_VERSION is pinned and how to
  bump it.
- flags: `--allow-slow-types` help text now accurately describes that
  it omits .d.ts files (rather than just "skipping generation").

Tests:
- shebang: end-to-end check that the shebang is on line 1 and the
  shim import on line 2 of the emitted .js.
- unfurl: jsr_name_to_npm unit test plus updated expectations on
  test_rewrite_jsr_specifier and test_extract_jsr_dependency.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four observations from my prior review and bartek's are addressed in the new commits. Walked the deltas:

Things I flagged, now resolved

  1. globalThis.Deno / globalThis["Deno"] detection — DenoUsageVisitor gains visit_member_expr that handles both MemberProp::Ident ("Deno" identifier) and MemberProp::Computed (string-literal key). Two unit tests added: test_detect_global_this_deno covers both forms; test_no_false_positive_global_this_other confirms globalThis.fetch / globalThis["console"] don't trigger. Targeted and tight. ✓
  2. --allow-dirty / check_if_git_repo_dirty robustness — replaces .expect("Failed to execute command") with Ok(output) = ... else return None. Now treats git-unavailable, git-killed, missing-repo, and permission failures uniformly: silently no working-tree signal, no panic. Matches the existing "git not installed" path. ✓
  3. dsherret's flags.rs:1 thread — the "what if someone wanted to just pack a project that's good to go already?" question. Looks like the answer landed as documentation-by-omission rather than a flag; bartek's review didn't push back, so calling that resolved.

Things bartek flagged, now resolved

  1. .cts → .cjs mapping coverage — covered indirectly by the new output_flag and npm_specifier fixtures (which exercise extension rewriting paths) plus the unit-test additions in extensions.rs.
  2. JSR dependencies in generated package.json — the npm_specifier fixture asserts the full output shape, and jsr_name_to_npm (@scope/name → @jsr/scope__name) now has both unit tests and end-to-end coverage. Important fix: the prior code rewrote jsr:@std/path as bare @std/path in package.json, which npm install doesn't resolve; now it produces @jsr/std__path matching JSR's npm compatibility scheme. This was a real correctness issue, not just polish.

New hardening worth calling out

The "round 2" commit (7e25693) brought several improvements that go beyond what was flagged:

  • Reproducible tarballs: every tar entry now has fixed mode 0o644, mtime=0, uid=0, gid=0. New reproducible spec fixture asserts bit-identical output across runs. Matches npm pack's reproducibility guarantees and is a meaningful win for CI/release pipelines that want deterministic artifacts.
  • to_tar_path normalizes \ → / for cross-platform tar entries. Unconditional, no-op on POSIX. ✓
  • compute_output_path UTF-8 fallback — handles Windows non-UTF8 paths via string-level extension stripping rather than unwrap()-panicking.
  • Defense-in-depth tarball name validation rejects derived names containing .. or /. Even though validate_package_name already rejects these upstream, the second check is cheap and protects against future regressions in name validation.
  • extract_dts no longer emits "export {};" stubs — when fast-check can't produce a .d.ts, the file is now omitted from the tarball and the corresponding types field is dropped from package.json. The previous stub-based approach made TypeScript conclude the module had no exports; the new behavior (no types field) lets downstream consumers fall through to .js resolution. dts_available_set + has_dts thread this through convert_exports and extract_main_and_types.
  • Shebang preservation (split_shebang) strips #!/usr/bin/env -S deno run -A before SWC parsing (which rejects shebangs in modules), then re-prepends to the emitted JS. Test fixture tests/specs/pack/shebang/ covers it.
  • Unsupported exports shape now bail!s rather than silently falling back to "./mod.js" — prevents the silent-wrong-tarball failure mode.

CI

134 pass, 1 skipping, 0 fail. Across all platforms and shards.

Verdict

Substance and shape are clean. The remaining open thread (CJS support — bartek noted: "no CJS compatibility, generated exports only have import + default") is appropriately scoped as a follow-up. Holding to COMMENT since this is a new public CLI subcommand (deno pack + 8 flags), and that's the kind of user-facing surface where I don't autonomously approve. The substance walk is otherwise green.

@lunadogbot lunadogbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through the cli/tools/pack module + the spec tests. The core feature is in good shape and the new reproducible test is exactly the right idea, but there's a logical bug it doesn't catch and a couple of related concerns I'd want addressed before this merges.

Logical bugs

1. Determinism regresses for any package with ≥ 2 dependencies.

cli/tools/pack/package_json.rs:59, 81, 89-93 builds the dependencies map as HashMap<String, String>, and serde_json::to_string_pretty walks HashMap in random hash-iteration order. So the generated package.json (and therefore the tarball bytes) differ between runs whenever dependencies has more than one entry. The new tests/specs/pack/reproducible/ fixture has zero deps, so it never trips this. Same hazard in ProcessedFile::dependencies (cli/tools/pack/mod.rs:347) which is merged into the same map.

Fix: switch to BTreeMap<String, String> everywhere the value flows into serde_json::to_string_pretty. And please beef up the reproducible spec to a fixture with both a jsr: and npm: dep, plus a multi-file graph, so the next regression of this kind gets caught.

2. extract_main_and_types drops the .ts → .js rewrite on the conditional-export branch.

cli/tools/pack/package_json.rs:228-233. The string-export branch above applies ts_to_js_extension, but the object-export branch returns obj.get("import") verbatim — so a deno.json with "exports": {"import": "./foo.ts"} ends up with package.json.main = "./foo.ts", which npm consumers then can't resolve. Apply the same extension rewrite on both branches.

3. Tar entry order isn't stable across runs.

cli/tools/pack/mod.rs:262-279 writes entries in graph.modules() iteration order. If deno_graph's underlying iteration is HashMap-backed (worth confirming), entry order is not deterministic and the tarball bytes still drift even after fixing (1). Sort collected_paths by relative_path before processing.

Likely safe-but-worth-a-look

4. README/LICENSE follow symlinks. cli/tools/pack/mod.rs:299, 322 does path.exists() followed by std::fs::read(&path). A LICENSE symlink pointing at ~/.ssh/id_rsa would be packaged. Low likelihood, but worth a symlink_metadata check + bail! on non-regular file (and same treatment for any future "auto-include this file by name" entries).

5. No default exclusion of .git, node_modules, dotfiles, or .gitignore rules. cli/tools/pack/mod.rs:251-282 filters by file: scheme + starts_with(package_dir) + file_patterns.matches_path. In practice ordinary npm:/jsr: deps don't leak (they're not file:-scheme), but local file-protocol imports + workspace symlinks under package_dir can sweep up unintended things. deno publish historically respects .gitignore; pack diverging silently is going to bite someone. At minimum hard-exclude .git/, node_modules/, and .env* like npm pack's default ignores.

6. Misleading comment in npm_tarball.rs:62-63 claims validate_package_name is the "upstream" defense. Nothing in cli/tools/pack/ actually invokes it; the ..// check at lines 64-71 is the only safeguard. Update the comment, or invoke the real validator from deno_semver.

Minor

  • rewrite_specifier doesn't handle data: / blob: URLs (cli/tools/pack/unfurl.rs:319-385); they fall through unchanged. npm consumers will fail at install time with confusing errors. Probably worth a warning at pack time.
  • cli/util/git.rs runs git status --porcelain with no timeout. On a network-mounted or hung repo, pack hangs.

The HashMap → BTreeMap fix (#1) is the one that I think should block — it directly contradicts the PR's marquee reproducible test passing.

crowlKats added a commit that referenced this pull request May 1, 2026
PR against the `deno-pack` branch (#32139). Targets the latest review's
`CHANGES_REQUESTED` items plus a couple of related safety hardenings.

## What changed

### Determinism (the headline issue)

- **HashMap → BTreeMap** for dependency maps in
`cli/tools/pack/{package_json.rs, mod.rs, unfurl.rs}`.
`serde_json::to_string_pretty` walks `BTreeMap` in sorted key order, so
`package.json` (and the tarball bytes) are now identical across runs
even with multiple deps. The existing `reproducible` fixture had zero
deps and never tripped the hazard.
- **Sort `collected_paths` by `relative_path`** in
`collect_graph_modules` before emitting tar entries.
`deno_graph::ModuleGraph::modules()` iteration order is not guaranteed
across runs, so without this the tar bytes still drifted even after
fixing the dependency map.
- **Beef up `tests/specs/pack/reproducible/`** to a two-npm-dep,
two-file fixture and add a byte-identical-tarball assertion across two
`pack` runs. This catches the BTreeMap regression and any future
iteration-order regression in `graph.modules()`.

### `extract_main_and_types` correctness

- The string-export branch already applied `ts_to_js_extension`; the
object-export branch returned `obj.get("import")` verbatim. So `{
"exports": { "import": "./foo.ts" } }` ended up with `package.json.main
= "./foo.ts"` — unresolvable from the published tarball. Now both
branches rewrite to the `.js` form.

### Auto-included file safety

- README/LICENSE collection used `path.exists() + std::fs::read(&path)`.
A `LICENSE` symlink pointing at `~/.ssh/id_rsa` would have been packed.
Now uses `symlink_metadata` and `bail!`s on non-regular files, with a
small `read_auto_included_file` helper.
- Hard-exclude `.git/`, `node_modules/`, and `.env*` under the package
directory, matching `npm pack`'s default ignores. Local `file:` imports
under `package_dir` could otherwise sweep these in.

### Doc fix

- The "upstream defense" comment in `npm_tarball.rs` claimed
`validate_package_name` was the upstream check; nothing in
`cli/tools/pack/` actually invokes it. Comment now describes the real
upstream (the `@scope/name` shape check in `pack`) and frames the local
check as a hard last-mile boundary.

## Tests

- `cargo test -p specs_tests specs::pack::` — 31 passed (includes the
beefed-up `reproducible` test).
- `cargo test --bin deno tools::pack` — passes.
- `tools/format.js` and `tools/lint.js` clean.

## Notes

This branch also includes a merge of `main` to resolve the `Pack` /
`Why` / `transpile` conflicts in `cli/factory.rs` and
`cli/tools/mod.rs`.

Open follow-ups (intentionally out of scope here):

- `data:` / `blob:` URL warning in `rewrite_specifier` — separate
concern, low impact.
- `git status --porcelain` timeout — separate from this round.
- CJS compatibility for the generated package — flagged as future work.

---------

Signed-off-by: Divy Srivastava <[email protected]>
Signed-off-by: Leo Kettmeir <[email protected]>
Co-authored-by: em <[email protected]>
Co-authored-by: divybot <[email protected]>
Co-authored-by: Divy Srivastava <[email protected]>
Co-authored-by: nathanwhitbot <[email protected]>
Co-authored-by: Nathan Whitaker <[email protected]>
Co-authored-by: Bartek Iwańczuk <[email protected]>
Co-authored-by: Daniil Sivak <[email protected]>
Co-authored-by: Divy Srivastava <[email protected]>
Co-authored-by: Fibi <[email protected]>
Co-authored-by: web-dev0521 <[email protected]>
Co-authored-by: Leo Kettmeir <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Nathan Whitaker <[email protected]>
Co-authored-by: Leo Zaki <[email protected]>
Co-authored-by: Hunnyboy1217 <[email protected]>
crowlKats and others added 2 commits May 1, 2026 18:50
# Conflicts:
#	cli/factory.rs
#	cli/tools/mod.rs
- HashMap → BTreeMap for dependency maps so package.json (and tarball
  bytes) stay reproducible with 2+ deps. The existing reproducible
  fixture had zero deps and never tripped the hazard.
- Sort collected_paths by relative_path before tarball emission so
  graph.modules() iteration order can no longer drift the tar bytes.
- extract_main_and_types: rewrite the source extension on the
  conditional-export branch too, so `{ "import": "./foo.ts" }` ends up
  as `package.json.main = "./foo.js"` rather than the unresolvable
  `.ts` source path.
- Auto-included README/LICENSE: read via symlink_metadata + regular-file
  check so a malicious LICENSE → ~/.ssh/id_rsa symlink can't be packed.
- Hard-exclude .git/, node_modules/, and .env* under the package dir,
  matching `npm pack`'s default ignores. Local file: imports under the
  package directory could otherwise sweep these in.
- Beef up the reproducible spec: two npm deps + two-file graph, plus a
  byte-identical assertion across two pack runs.
- Clarify the misleading "upstream defense" comment in npm_tarball.rs.
@bartlomieju

Copy link
Copy Markdown
Member

Review notes

The code is solid overall -- well-structured with good test coverage,
proper reproducibility (BTreeMap, sorted paths, fixed tar headers),
security considerations (symlink check, .env/.git exclusion,
tarball name validation), and clean separation of concerns.

A few minor items I noticed:

  1. git status doesn't check exit code (cli/util/git.rs) --
    if git status runs but exits non-zero (e.g. not a git repo),
    stdout is still read and could be empty, returning None (clean).
    Should check output.status.success().

  2. Shim import + text changes ordering (cli/tools/pack/mod.rs)
    -- the shim import is pushed as a TextChange at 0..0.
    deno_ast::apply_text_changes sorts by range start, so if unfurl
    also produces a 0..0 change (unlikely but possible with a leading
    import rewrite), the relative order between them could be
    nondeterministic. In practice this works because import order
    rarely matters, but it's fragile.

  3. compute_output_path double-extension edge case
    (cli/tools/pack/extensions.rs) -- for files like mod.d.ts,
    Path::file_stem() returns mod.d, which would produce
    mod.d.js. The unfurl specifier rewriting guards .d.ts but
    compute_output_path doesn't. In practice .d.ts files aren't
    collected from the module graph (they're generated separately), so
    this likely never triggers -- but could bite if that assumption
    changes.

  4. --output path not validated for traversal
    (cli/tools/pack/npm_tarball.rs) -- when the user passes
    --output, it's used as-is via PathBuf::from(path). The
    auto-generated name has traversal checks, but the user-provided
    path doesn't. Probably fine since users control this flag directly,
    but worth noting for consistency.

None of these are blockers.

- git.rs: check exit code of `git status --porcelain` so a non-zero
  exit (e.g. not a git repo) is treated the same as "git not found"
  instead of silently reporting a clean tree.
- mod.rs: insert shim import at front of text_changes vec so it
  sorts before any unfurl change also targeting position 0, making
  the ordering deterministic under apply_text_changes.
- extensions.rs: guard .d.ts/.d.mts/.d.cts in compute_output_path
  so declaration files pass through unchanged instead of being
  mangled by file_stem() (e.g. mod.d.ts -> mod.d.js).
- npm_tarball.rs: warn when user-provided --output path contains
  '..' components for consistency with the auto-generated name
  validation.
@bartlomieju
bartlomieju merged commit 0b92830 into main May 6, 2026
269 of 271 checks passed
@bartlomieju
bartlomieju deleted the deno-pack branch May 6, 2026 08:23
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
Add `deno pack` command that creates npm-compatible tarballs from
Deno/JSR projects. It transpiles TypeScript to JavaScript, rewrites
`jsr:`/`npm:` specifiers to bare npm names, generates `package.json`
with conditional exports and `.d.ts` type entries, and bundles
everything into a `.tgz` ready for `npm publish`.

Types are extracted via fast-check (same as `deno publish`). When
Deno API usage is detected, `@deno/shim-deno` is automatically
added as a dependency.

## Usage examples

```sh
# Basic: pack the current project
deno pack

# Override version without editing deno.json
deno pack --set-version 2.0.0

# Write tarball to a specific path
deno pack --output my-package.tgz

# Preview contents without creating the tarball
deno pack --dry-run

# Skip .d.ts generation (allow slow types)
deno pack --allow-slow-types

# Don't inject @deno/shim-deno polyfill
deno pack --no-deno-shim

# Pack with uncommitted changes
deno pack --allow-dirty

# Exclude test files
deno pack --ignore=tests/

# Publish the tarball to npm
npm publish scope-package-1.0.0.tgz
```

## What it does

Given a `deno.json` like:

```jsonc
{
  "name": "@scope/my-lib",
  "version": "1.0.0",
  "exports": "./mod.ts"
}
```

Running `deno pack` produces `scope-my-lib-1.0.0.tgz` containing:

- `package.json` with `name`, `version`, `type: "module"`,
  conditional `exports` (types/import/default), and extracted
  `dependencies`
- Transpiled `.js` files (with inline source maps by default)
- Generated `.d.ts` declaration files (via fast-check)
- README and LICENSE files (auto-included if present)

Specifier rewrites:
- `jsr:@std/path` -> `@jsr/std__path`
- `npm:express@4` -> `express`
- `./utils.ts` -> `./utils.js`
- `node:fs` -> unchanged

## How `deno pack` differs from `npm pack`

`deno pack` is not an `npm pack` equivalent -- it is a build tool
that converts a Deno/JSR project into an npm-publishable package,
closer to `tsc` + `npm pack` combined.
Notable behavioral differences:

- **File selection is graph-based**: only files reachable from
  `exports` end up in the tarball, not a file listing. Non-JS assets
  (data files, WASM) are not included unless imported.
- **No `.npmignore`/`.gitignore` support**: uses `--ignore` flag and
  hard-coded exclusions (`.git`, `node_modules`, `.env*`).
- **No lifecycle scripts**: `prepublishOnly`/`prepare` hooks are not
  executed.
- **No `bundledDependencies`/`optionalDependencies`**: the generated
  `package.json` only contains `dependencies`.
- **JSR deps use `@jsr/` scope**: e.g. `jsr:@std/path` becomes
  `@jsr/std__path`, which requires consumers to have the JSR npm
  registry configured or use `npx jsr add`.

## Implementation details

- New modules under `cli/tools/pack/`: `mod.rs`, `extensions.rs`,
  `unfurl.rs`, `package_json.rs`, `npm_tarball.rs`
- Shared unfurl utilities extracted to `cli/tools/unfurl_utils.rs`
  (reused by `deno publish`)
- `check_if_git_repo_dirty` moved to `cli/util/git.rs` (shared
  between pack and publish)
- Reproducible tarballs: `BTreeMap` for deterministic key ordering,
  sorted tar entries, fixed tar headers (mode/mtime/uid/gid)
- Security: symlink checks on auto-included files, `.env`/`.git`
  exclusion, path traversal protection on tarball filenames

The changeset is large but most of it is test coverage in
`tests/specs/pack/`.

---------

Co-authored-by: Bartek Iwańczuk <[email protected]>
Co-authored-by: Leo Kettmeir <[email protected]>
Co-authored-by: crowlbot <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants