Skip to content

feat(cli): add deno link and deno unlink subcommands#34359

Merged
bartlomieju merged 8 commits into
mainfrom
feat/deno-link
Jun 16, 2026
Merged

feat(cli): add deno link and deno unlink subcommands#34359
bartlomieju merged 8 commits into
mainfrom
feat/deno-link

Conversation

@bartlomieju

@bartlomieju bartlomieju commented May 25, 2026

Copy link
Copy Markdown
Member

The "links" array in deno.json has been the user-facing way to use a
local JSR package in place of a registry version, but until now there
was no CLI to manipulate it. Users had to hand-edit deno.json and
remember the exact field name. This adds first-class commands so the
workflow matches what people coming from npm link or bun link
expect.

deno link <path> validates that the path is a directory containing a
deno.json with a JSR-style "name" field, appends the relative path
to "links" (creating the array when absent, preserving formatting via
the existing CST machinery), and runs install. A linked package is
importable by its bare name just like a workspace member, so no
imports entry is wired up. Re-linking the same path is idempotent.

deno unlink <path-or-name> removes an entry by literal path, by
resolved path, or by the linked package's JSR name read from the
target's deno.json. Path arguments resolve relative to the current
working directory, the same way link resolves them, so deno unlink ../pkg finds an entry created by deno link ../pkg from the same
directory regardless of where the deno.json lives. The field is
removed entirely when the array becomes empty. When no argument matches
any linked entry, unlink exits non-zero rather than reporting success,
so a typo'd name or path is detectable from scripts and CI. unlink
never creates a deno.json: if there isn't one, there are no linked
packages to remove.

As a discoverability fix, deno add ./local-dir and deno install ./local-dir now bail with a "Did you mean deno link ./local-dir?"
hint when the spec resolves to an existing directory whose deno.json
declares a "name", so the npm link-style workflow doesn't dead-end
at a "not found in jsr or npm" error. The hint fires only when the
directory is actually linkable, matching what link requires.

The `"links"` array in deno.json has been the user-facing way to use a
local JSR package in place of a registry version, but there was no CLI
to manipulate it — users had to hand-edit deno.json and remember the
exact field name.

`deno link <path>` validates that the path is a directory containing a
deno.json, appends the relative path to `"links"` (creating the array
when absent), and runs install. Re-linking the same path is idempotent.

`deno unlink <path-or-name>` removes an entry by literal path, by
resolved path, or by the linked package's JSR name (read from the
target's deno.json). When the array becomes empty, the field is
removed entirely.

`deno add ./local-dir` / `deno install ./local-dir` now error with a
"Did you mean `deno link ./local-dir`?" hint when the spec resolves to
an existing directory with a deno.json, matching the workflow users
expect coming from `npm link` / `bun link`.

@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.

  1. entry_dir == resolve_link_path(&config_dir, arg) resolves unlink path arguments relative to the deno.json directory, but link() resolves the same user path relative to initial_cwd. From a subdirectory under a workspace root, deno link ../pkg stores the root-relative pkg, while deno unlink ../pkg resolves against the root config dir and misses the entry. Resolve unlink path args from cli_options().initial_cwd() too, and add a nested-cwd spec so path semantics match.

@bartlomieju bartlomieju added this to the 2.9.0 milestone Jun 1, 2026
…lution

After merging main, the "links" field became patch-only: it redirects a
jsr: dependency to a local copy but does not by itself make the package
importable by name. `deno link ./pkg` therefore left the package
unresolvable. Link now also adds an `imports` entry mapping the package
name to its `jsr:<name>` specifier (and requires the target deno.json to
declare a "name"), so a freshly linked package is usable without further
edits. `deno unlink` removes that entry too, but only when it still points
at the `jsr:<name>` specifier we created, leaving user-customized mappings
intact.

`unlink` now resolves path arguments relative to the cwd, matching how
`link` resolves them, so `deno unlink ../pkg` finds an entry created by
`deno link ../pkg` from the same subdirectory. Adds an unlink_nested_cwd
spec covering that case.

Also lists `link`/`unlink` in the top-level `deno --help` output.
@bartlomieju

Copy link
Copy Markdown
Member Author

Self-review note on link_hint_for_spec (cli/tools/pm/mod.rs): the function takes cmd_name: AddCommandName but discards it (let _ = cmd_name; // hint always recommends \deno link`, regardless of caller). The hint text is identical whether the call came from addorinstall`, so the parameter is dead weight at both the call site (line ~552) and the definition.

Two options:

  1. Drop the cmd_name parameter entirely and stop threading it through from add() — simplest.
  2. Actually use it, e.g. phrase the hint as "deno add ./x is for registry packages; did you mean deno link ./x?" so the message reflects which command the user actually ran.

I'd lean toward (1) unless we want the command-specific wording. Not a blocker; flagging for cleanup.

The link hint text is identical regardless of whether the call came from
`deno add` or `deno install`, so the threaded-through `cmd_name` was dead
weight (previously silenced with `let _ = cmd_name`). Drop the parameter;
`add` still uses `cmd_name` for its other registry-package hints.
@bartlomieju

Copy link
Copy Markdown
Member Author

Self-review: a few non-blocking gaps worth a follow-up. None affect the pass/fail behavior of the happy paths (covered by the link:: spec tests).

  1. No flag-parse unit tests for link_parse / unlink_parse in cli/args/flags.rs. Most subcommands have #[test] coverage asserting the parsed DenoSubcommand::Link(LinkFlags { .. }) / Unlink(..); these do not. Behavior is exercised by the spec tests, so this is a consistency gap rather than a correctness one.

  2. Two subtle behaviors are untested:

    • The unlink import-preservation guard (cli/tools/pm/mod.rs:1471-1488): a user-customized imports value (anything other than jsr:<name>) must survive unlink. Verified by hand but no spec test locks it in, so a future "simplify" that drops the value check would pass CI.
    • Linking a target whose deno.json has no "name" field bails (cli/tools/pm/mod.rs:1323), but there is no test for that error path.
  3. Windows absolute-path hint gap: link_hint_for_spec returns early on any spec containing : (cli/tools/pm/mod.rs:1196), so deno add C:\foo will not surface the "did you mean deno link?" hint on Windows. The Path::new(spec).is_absolute() check below it is effectively unreachable for C:\.... Cosmetic only.

None of these block merge; flagging for cleanup.

bartlomieju added a commit that referenced this pull request Jun 15, 2026
Linked packages declared via `"links"` in `deno.json` have not been
resolvable through a bare specifier since #34519, which gated them out
of bare-specifier resolution to fix #29436. Only a `jsr:` specifier or an
import map entry worked. That broke the workflow people relied on for
private packages: a private package is not published to any registry, so
it cannot be referenced as a real `jsr:`/`npm:` dependency, and
`"links"` was the only way to import it by name. The fallout is tracked in #34829
and #35214, and the connection to `deno link` (#34359) is described in
#35227.

The workarounds suggested for the new behavior all fall short. Importing
via `jsr:@org/foo` trips the `no-import-prefix` and
`no-unversioned-import` lints and is a supply-chain footgun, since `deno update` will then look
the private name up on jsr and a colliding public publish could be pulled
in. Adding an import map entry `"@org/foo": "jsr:@org/foo"` does not compose
transitively: when a linked package's own files import a sibling
(`@org/aaa` importing `@org/bbb`), the root import map does not apply
inside the linked package's scope, so every member would have to be duplicated
by hand. Using a real workspace requires the member to be nested under the
workspace root rather than a sibling or absolute path.

This change treats linked packages like workspace members for
bare-specifier resolution. Conceptually a link is just a workspace
member that lives outside the workspace tree, so the `!p.is_link` filter in the
bare-resolution loop is dropped. Workspace members are still listed
before links in `jsr_pkgs`, so they keep precedence when a name collides, and
the `jsr:`-prefixed and import map resolution paths are untouched, so the
override use case `"links"` was designed for keeps working exactly as
before.

This intentionally reverses the bare-resolution behavior that #34519
introduced for #29436. The original concern was that resolving a linked
package by bare name was surprising, but it is not silent: you opt in by
listing the path in `"links"`, and there is no private-registry
alternative for the affected users. The `link_bare_specifier` spec test and the two
resolver unit tests that locked in the rejection are updated to assert
resolution, and a new `link_transitive_sibling` spec test covers the
sibling-import case that an import map entry could not satisfy.

A follow-up can simplify `deno link` (#34359) to stop injecting the
`"@org/foo": "jsr:@org/foo"` import map entry, since bare imports now
work without it.

Closes #35227
Closes #35214
Since linked packages resolve by bare specifier (#35228), `deno link` no
longer needs to add an `"@org/foo": "jsr:@org/foo"` imports entry to make
the package importable by name, and `deno unlink` no longer needs to remove
it. Listing the path in "links" is sufficient.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review: deno link / deno unlink

Clean, well-scoped feature. Validation-before-mutation in link() is the
right shape (all paths checked before the array is touched), the CST
machinery preserves formatting, and the spec coverage hits the main
happy/error paths. The b10ecea60 simplification (dropping the jsr:
imports injection now that bare specifiers resolve) is a good cut. A few
things worth addressing before merge.

Findings

1. unlink of a non-matching arg exits 0 (cli/tools/pm/mod.rs:485-493).
When no arg matches, we log::warn! and fall through to "No packages were
unlinked" + Ok(()). So deno unlink @does/not-exist succeeds silently.
Suggest: if removed.is_empty(), bail! (exit 1) so a typo'd name is
detectable from scripts/CI. At minimum a deliberate decision — right now
it's easy to mistype and think it worked.

2. load_deno_config_for_link forces deno.json creation even on unlink
(cli/tools/pm/mod.rs:324-336).
The || true makes load_configs
materialize a config when none exists. Intended for link; for unlink in
a directory with no deno.json we build a config updater only to immediately
bail!("No \"links\" entries found"). It bails before commit(), so
nothing hits disk today, but the coupling is fragile. Consider a
non-creating load for unlink, or a comment noting the bail-before-commit
guarantee so a future commit() reorder doesn't write an empty deno.json on
a failed unlink.

3. Hint vs. link requirement mismatch (link_hint_for_spec:269 vs
link:374).
The hint fires when the dir merely contains a deno.json,
but link then rejects it if there's no "name". So deno add ./pkg-without-name -> "Did you mean deno link ./pkg?" -> deno link ./pkg -> "its deno.json has no name field". The guidance dead-ends. The
hint could check read_link_target_name(&abs).is_some() for consistency.

Test gap

The no-name bail path (cli/tools/pm/mod.rs:374) is a user-facing error
with no spec test. Cheap to add alongside the existing no_deno_json /
nonexistent_dir cases.

Already noted in self-review (agree, non-blocking)

  • spec.contains(':') early return swallows Windows C:\... paths, making
    the is_absolute() check below it unreachable for drive paths. Cosmetic.
  • No flag-parse unit tests for link_parse / unlink_parse — consistency
    gap with other subcommands; behavior is exercised by specs.

Of these, #1 (silent exit-0 on a failed unlink) is the one I'd actually
block on; the rest are cleanups.

- `deno unlink` now exits 1 when no argument matches any linked package,
  instead of warning and reporting success, so a typo'd name or path is
  detectable from scripts and CI.
- `unlink` no longer materializes a deno.json: it loads only an existing
  config and bails early when none is found, so a failed unlink can't
  leave an empty deno.json behind. Config creation stays scoped to `link`.
- The `deno add ./pkg` / `deno install ./pkg` "did you mean `deno link`?"
  hint now only fires when the target's deno.json declares a "name",
  matching what `link` actually requires, so the hint no longer dead-ends
  on a directory that `link` would reject.

Adds spec tests for the no-"name" link bail, the no-match unlink (exit 1),
and unlink with no deno.json.
@bartlomieju
bartlomieju merged commit d39e189 into main Jun 16, 2026
269 of 271 checks passed
@bartlomieju
bartlomieju deleted the feat/deno-link branch June 16, 2026 07:06
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.

2 participants