feat(cli): add deno link and deno unlink subcommands#34359
Conversation
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
left a comment
There was a problem hiding this comment.
entry_dir == resolve_link_path(&config_dir, arg)resolves unlink path arguments relative to thedeno.jsondirectory, butlink()resolves the same user path relative toinitial_cwd. From a subdirectory under a workspace root,deno link ../pkgstores the root-relativepkg, whiledeno unlink ../pkgresolves against the root config dir and misses the entry. Resolve unlink path args fromcli_options().initial_cwd()too, and add a nested-cwd spec so path semantics match.
…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.
|
Self-review note on Two options:
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.
|
Self-review: a few non-blocking gaps worth a follow-up. None affect the pass/fail behavior of the happy paths (covered by the
None of these block merge; flagging for cleanup. |
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
left a comment
There was a problem hiding this comment.
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 WindowsC:\...paths, making
theis_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.
The
"links"array indeno.jsonhas been the user-facing way to use alocal JSR package in place of a registry version, but until now there
was no CLI to manipulate it. Users had to hand-edit
deno.jsonandremember the exact field name. This adds first-class commands so the
workflow matches what people coming from
npm linkorbun linkexpect.
deno link <path>validates that the path is a directory containing adeno.jsonwith a JSR-style"name"field, appends the relative pathto
"links"(creating the array when absent, preserving formatting viathe existing CST machinery), and runs install. A linked package is
importable by its bare name just like a workspace member, so no
importsentry is wired up. Re-linking the same path is idempotent.deno unlink <path-or-name>removes an entry by literal path, byresolved path, or by the linked package's JSR name read from the
target's
deno.json. Path arguments resolve relative to the currentworking directory, the same way
linkresolves them, sodeno unlink ../pkgfinds an entry created bydeno link ../pkgfrom the samedirectory regardless of where the
deno.jsonlives. The field isremoved entirely when the array becomes empty. When no argument matches
any linked entry,
unlinkexits non-zero rather than reporting success,so a typo'd name or path is detectable from scripts and CI.
unlinknever creates a
deno.json: if there isn't one, there are no linkedpackages to remove.
As a discoverability fix,
deno add ./local-diranddeno install ./local-dirnow bail with a "Did you meandeno link ./local-dir?"hint when the spec resolves to an existing directory whose
deno.jsondeclares a
"name", so thenpm link-style workflow doesn't dead-endat a "not found in jsr or npm" error. The hint fires only when the
directory is actually linkable, matching what
linkrequires.