An upstream rename lands the new commit's package.json under the old
dependency key: `@dsh-external/dsh-visualize`'s directory now holds a
package named `@nagi-ovo/dsh-visualize`. pnpm installs it and exits 0, and
nothing in the update route looked at the name — but DSH Desktop composes a
profile only if each dependency directory holds the package it is named for,
so the next start failed at profile-composition with "profile package
identity is invalid".
The update now reads that name after the install and treats a mismatch as a
failed update: it rolls back through the same path the other post-install
checks use, and the error names the new package, since the way forward —
reinstalling under the new name — is not something the market can do
without also rewriting the profile's bundle list. The response carries
`renamedTo` so the client can offer it.
Only a mismatch the update introduced counts. A directory that already held
a differently named package before the update (an npm alias, a legacy
install) is left to whatever it was; judging it is not this run's business.
The allowlist required `.git` on a clone-URL key, so a self-hosted remote
installed as `git+https://host/owner/repo` had its key derived correctly and
then dropped on the way out — the same silent hole #665 closed, one spelling
over (@liuwenji007).
The suffix is now optional rather than appended. pnpm keys a remote exactly
as it was spelled: measured on 12.4.1 against a remote without `.git`, the
key as spelled authorizes the build and the key with `.git` appended does
not, so "normalizing" would have written an entry pnpm never reads. The same
spelling pinned with `#<sha>` is what 11.8.0 matches.
Also from the review: `codeloadAllowBuildsKey`'s doc comment had ended up
above `pinnedGitAllowBuildsKey`, leaving each function documented by the
other's text; the `@returns` of `gitAllowBuildsKey` and the approval route's
comment still said "github".
Approving build scripts for a plugin installed from gitlab.com, bitbucket.org
or a self-hosted remote wrote the bare package name — the one key pnpm never
matches (#68/#69) — so the "allow build scripts and retry" button could not
work there, and failed exactly as it had before. The gap predates #637 but
was unreachable through it: those installs used to be replaced by a
same-named npm package on update, so nobody got as far as the build layer.
Measured per host and per pnpm major, each cell in its own store because
pnpm's side-effects cache otherwise answers for the previous run:
pnpm 11.8.0 pnpm 12.4.1
bare name no no
name@git+https://host/o/r.git no yes
name@<host archive>#<sha> yes yes
11.8.0 is what DSH Desktop bundles, so both forms are needed — the same pair,
for the same reason, that #285 established for GitHub. The pinned form is the
download the host serves and pnpm names in its own error: bitbucket's
`/get/<sha>.tar.gz`, gitlab's `/-/archive/<sha>/<repo>-<sha>.tar.gz`, and for
a plain remote the clone URL pinned with `#<sha>`. Off GitHub the commit comes
from the remote's own ref advertisement — the one the update check already
reads — because there is no api.github.com to ask.
`setAllowBuilds` had to widen with it: its allowlist is what stops a caller
writing arbitrary text into a file pnpm parses, and it recognised GitHub
spellings only, so the new keys would have been dropped on the way out. It
now matches by SHAPE — a clone URL of any host, or one of the three archive
downloads, each anchored to its host — and refuses a path segment that starts
with a dot, which is how `..` would have entered a shape that looks like a
repo. That is a policy change: a key naming another host is now written. These
keys are derived from the profile's own manifest and the curated catalog, so
the allowlist was never a host trust boundary; refusing them only stopped
non-GitHub plugins from ever authorizing a build.
Not covered, deliberately: `ssh://` remotes and `git+http://` still get no
key. pnpm 9/10 gate builds with `onlyBuiltDependencies` rather than the
`allowBuilds` map this writes (measured on 10.34.5), so their spelling is not
this key's business.
pnpm rewrites an install from these hosts into its own shorthand —
`pnpm add git+https://gitlab.com/o/r.git` leaves `"o-r": "gitlab:o/r"` in
package.json — and `isGitHostedSpec` knew only `github:`. The update route
therefore sent `name@latest`, which is #525 again from a different
direction: silent replacement by a same-named registry package where one
exists, "package not found" where it does not.
The shorthands are a table now (github / gitlab / bitbucket), measured in
both directions, one real repository per host. The negative half is
measured too: `gist:` and `sourcehut:` are NOT git sources on pnpm 12.4.1 —
one is looked up on the registry, the other refused as an invalid package
name — so listing them would route a registry install down the git path.
Reading the installed commit is a second table. Every major the market
supports resolves these hosts to an archive tarball with the commit in the
URL and writes no `type: git` entry, but not to the same URL: 9 and 10
fetch GitLab through its REST API, 11 and 12 take the project archive.
Both shapes are read, and the key is now `host/owner/repo` — including the
port — because `owner/repo` alone lets one host's commit answer for a
plugin installed from another.
The update target is the shorthand itself, not a rewrite to
`git+https://host/owner/repo.git`. Both install, but pnpm writes the
shorthand back into the manifest either way, so a rewrite would leave the
target that was sent and the spec that is read back disagreeing — and
`reresolveInPlace` needs them byte-identical to re-resolve a floating spec
in place.
Two failures that the newly reachable git path would otherwise inherit:
the update check now asks the remote about the ref the install names
rather than HEAD, or a `#branch` install would offer an update forever
(#446), and a commit pin is no longer stripped off a spec that carries a
`path:` selector, which installed the repository root under the plugin's
name.
The capability endpoint answered `runtime: 'desktop'` and
`managedBy: 'desktop-host'` whenever the market held an explicit profile
directory, because until now only a DSH Desktop shell supplied one.
#639 changed that: the market reads the launcher's `profileContext`, so
every profile the dsh launcher starts now carries its own directory,
ordinary `dsh` runs included. Those runs started reporting themselves as
a desktop runtime whose restart is managed by a desktop host, which is
true of neither.
Carry the fact itself. `desktopHost` is set where a Desktop shell really
serves the process — the branch that has `desktopProfiles` and
`desktopPnpm` — and the two capability bits read that instead of
inferring it from a path. The bits are `beta`, and no client in this
repository consumes them yet, so nothing depended on the old meaning.
The installed list shows every dependency of the profile manifest, and
pnpm's auto-install-peers writes a plugin's peers there as direct
dependencies. A native binding a plugin needs therefore appears in the
list looking exactly like a plugin the user chose, and verifyActivation
answers `inert` for it, which the UI renders as "installed, not active".
The reporter went looking for a broken plugin and found two Univer
bindings that dsh-univer-office had brought in.
Say whose library it is instead. The installed route reads, once for the
whole list, which installed package declares which other one — as a
dependency or a peer dependency — and attaches that owner to an
activation result that is `inert` and whose package declares no dsh
surface of its own. The client renders those as "<owner> 的依赖库" with
no warning dot.
Three things deliberately keep their old answer. A package nobody
declares stays "installed, not active", because a plugin whose manifest
really did lose its dsh field must not be relabelled into silence. A
package that declares a dsh surface stays a plugin even when another
plugin depends on it: `inert` also covers a plugin that simply is not
wired into the running composition, so the state alone cannot decide
this. And a package that is missing from node_modules keeps saying so.
Ownership is resolved in sorted order, so two plugins declaring the same
library give the same answer whatever the manifest's key order is.
Co-authored-by: fkysly <fkysly@gmail.com>
An update of a plugin installed from a git URL that is not a github:
shortcut could never be rolled back to the previous build: the update
route derived the pre-update identity through repoOfTarget,
githubCommitOfTarget and githubTargetAtCommit, which know only github:
shortcuts and codeload tarballs, so beforeCommit stayed null and the
plan said the previous GitHub commit could not be verified. The identity
was there all along: the update check reads it for exactly these
installs with gitCommitOfTarget (a #<sha> pin in the URL) and
readGitResolutionCommit (pnpm's `resolution: {commit, repo}`, #525).
Today this leaves a hard-failed update with the manifest and lock
restored but the files untouched, behind a misleading message; once
#562's update replaces the files, a failed update would leave the new
build behind.
Give the rollback the same reads, through one gitIdentityCommit that
capture, verification and stale detection all share. It answers from
whichever shape the host wrote: a `type: git` resolution for a plain
remote, and the codeload tarball for a GitHub one, because
`git+https://github.com/o/r.git` has no repo key yet resolves to a
tarball — reading only the git shape reported a rollback that really
happened as unverified, and left the repaired lock behind a manifest
that disagreed with it. The exact rollback target is the remote as
spelled, pinned to that commit (gitTargetAtCommit), which pnpm
re-resolves to exactly that commit; a bare https remote is returned with
the git+ prefix, since pnpm 11 — what DSH Desktop bundles — downloads
the unprefixed form as a tarball, and the scp-like git@host:owner/repo
spelling is refused because pnpm writes it as a `link:` dependency named
`git` rather than cloning it (measured on 9.15.4 and 12.4.1). A `path:`
selector still cannot ride along with a commit, so those sources keep
the "cannot express" answer, now worded for git rather than GitHub.
readGitResolutionCommit gained the same subpath awareness: two packages
of one monorepo resolve from the same remote and differ only by pnpm's
`path:` selector, so the spec's selector has to match, and a spec with
no selector gets no answer when several siblings share the remote rather
than the first sibling's commit.
Self-hosted remotes are what this fixes — gitee, Gitea, self-hosted
GitLab, raw git+https. gitlab.com and bitbucket.org are not: pnpm
rewrites those to `gitlab:`/`bitbucket:` shorthands and records a hosted
archive tarball that neither reader can see.
FakeDsh records what real pnpm records for each shape — a git resolution
for a plain remote, a codeload tarball for github.com — and serves a
#<sha> pin from byCommit the way it already does for github:.
A plugin installed as github:owner/repo (or #branch, #semver:, #path:)
never moved through the market's update: the add target was
byte-identical to the specifier already in the manifest, so pnpm
answered 'Lockfile is up to date, resolution step is skipped' and the
run ended STALE. Only npm sources changed the specifier (name@version)
and therefore resolved.
When the git target equals the installed specifier, run
'pnpm update <name>' — pnpm re-resolves inside that specifier, which is
what a mutable spec wants. Targets that differ (a dropped commit pin, a
rebuilt codeload shortcut, npm pins, restores) keep 'add' because there
the new target is the change. 'update' gets the ndjson reporter like the
other mutating commands.
FakeDsh now models 'update <name>' as an add of the manifest's current
spec, so re-resolution and every add-side fault flag apply. The five
git-update specs that asserted 'add <target>' encoded pnpm's non-behavior
and now assert the specifier survives in the manifest plus the verb; a
new case pins the floating-spec path end to end.
The restart helper is spawned detached, so on Windows it has no console.
When it then starts `powershell -WindowStyle Hidden` for the replacement,
Windows gives that console program a new, visible console — the "Windows
PowerShell" window users saw after one-click restart — and the
replacement lives in it: close the window and the server goes with it.
`-WindowStyle Hidden` cannot help; it governs the window PowerShell would
create, not the console the spawn handed it.
Spawn the replacement with windowsHide (CREATE_NO_WINDOW): the console
still exists, so the host's own console children inherit it instead of
popping windows (#40), it is just never shown. Measured by the reporter
with a console-less launcher: one PseudoConsoleWindow without the flag,
none with it.
withHoistRecovery retries with --config.fetchTimeout=600000 after a
download hits pnpm's 60-second per-request limit, and with
--config.auto-install-peers=false after a peer the host provides 404s on
npm. pnpm 12 ignores both flags on the command line, in either spelling,
without a word (fetchTimeout measured on 12.2.1, 12.3.0 and 12.4.1;
auto-install-peers on 12.4.1, where the peer is auto-installed with the
flag present), so both retries ran exactly like the first attempt.
PNPM_CONFIG_FETCH_TIMEOUT and PNPM_CONFIG_AUTO_INSTALL_PEERS are honoured
by 11.8, 11.21 and 12.4 alike, workspace-configured profiles included.
runDshPlugin now repeats every --config.<key>=<value> override in the
argv as PNPM_CONFIG_<KEY> for that run, and sets nothing otherwise, so a
user's own values are untouched on every other run. The flags stay:
pnpm 11 and earlier read them, and they cost nothing on the versions
that do not. The Desktop runtime hands its host argv only, so this
covers the CLI runner. The compat matrix gains pnpm 12 and pins the
ignored flag and the honoured variable.
The unit lane never reaches the network: every request a spec cares
about is answered by a stub on the global fetch. A proxy exported in the
developer's environment defeats that — marketFetch goes through undici
with a proxy agent whenever http(s)_proxy is set, so the stub never sees
the request — and on such a machine 63 of the suite's tests failed for
reasons unrelated to the code under test, while the #148 proxy spec read
the machine's lowercase https_proxy in place of the value it had set.
A setup file drops the proxy variables before any spec runs; specs that
want a proxy set one themselves. The compat and web lanes are left
alone: they drive real pnpm and a real browser and may need the proxy
to reach anything.
The market resolved its profile as `config.profile ?? argvProfile() ??
'web'`, with a separate branch for shells that provide `desktopProfiles`.
The official desktop host is neither: it starts a profile through the
launcher's node entry, so there is no `--profile` on this process's argv,
and it provides no `desktopProfiles` service. Both sources came back
empty and the market settled on `web` — so the installed list showed the
web profile's plugins, and every install, update and uninstall wrote
into that profile's node_modules and lockfile while the user was looking
at the desktop one.
The launcher already publishes what was booted. `profileContext` is
provided on the host context before any config-tree entry mounts, and
carries the profile's `name` and its `dir`. Read it between the explicit
configuration and the argv guess: an operator's `profile:` in cordis.yml
still wins, a shell that provides `desktopProfiles` keeps its own branch
untouched, and a host that publishes neither still falls back to the
flag and then to `web`.
The directory is taken with the name rather than derived from it,
because the launcher owns where a profile lives and a derived path would
disagree with it for any profile that is not in the default place. It
rides along only when the name came from the launcher too, never with a
profile the operator named instead. A name that could not be used as a
directory segment is refused rather than joined into a path.
A fresh install handed pnpm the bare package name, and pnpm's
fresh-release hold answers that silently: a release younger than
minimumReleaseAge is skipped for the newest mature one, exit 0, so the
user got one release behind and the ^0.x written into the profile could
never float to the next minor.
An exact target does not get that treatment. On a profile that leaves
minimumReleaseAge at pnpm's default, pnpm installs the named version
and records it in minimumReleaseAgeExclude (measured on 11.8.0, 11.21.0
and 12.4.1), no bypass involved. So a fresh install now asks the
registry for latest and, when the answer is a version, sends
name@x.y.z.
Where the profile sets minimumReleaseAge explicitly, the exact young
target is refused with NO_MATURE_MATCHING_VERSION. The update route
answers that with the one-shot bypass (#496/#531) because the young
package is already installed there; on a fresh install it is not, and
the bypass would be what installs it over the user's policy. So
withHoistRecovery can now be told to keep the release age
(releaseAgeBypass: false), the pinned add uses that, and a refused pin
goes back to the bare name once, which is what pnpm's hold was going to
install anyway, with a log line naming the version that was held back.
Two more cases keep the bare name so nothing that used to install is
refused: a registry that cannot be read or answers a non-version keeps
the old argv, and a profile whose own registry is a mirror behind the
one that answered latest (NO_MATCHING_VERSION for this package, or a
404 for its own tarball) retries bare once. A failure that names a
dependency instead is left to its own diagnosis. On pnpm 12 the report
wraps at 80 columns and a dsh-* name breaks at its hyphen, so the
classifier often cannot name the package; an unnamed failure counts as
this package's.
FakeDsh models the hold for fresh installs in both shapes (default
policy installs the exact young version, explicit policy refuses it
without the bypass); the test bed answers the registry's /latest from
the fake registry so no install test reaches the network.
From pnpm 12.3.0, the native CLI, --config.minimumReleaseAge=0 is
silently ignored on the command line, so the one-shot bypass in
withHoistRecovery ran without the bypass and the retry failed exactly
like the first attempt. The .npmrc spelling,
--config.minimum-release-age=0, is honoured by pnpm 10, 11 and 12
alike, so use that. The compat matrix gains pnpm 12 and pins both the
recovery and the ignored camelCase form.
This is specific to that key: --config.fetch-timeout is ignored by the
native CLI in both spellings, so FETCH_TIMEOUT_OVERRIDE is left alone
here and tracked in #615.
When pnpm cannot rename the staged build over a package whose files the
running host holds open, the update route reinstalled the previous build
to recover — the same rename against the same open handles, which fails
the same way after pnpm's own minutes of retries — and then reported
that the profile might be broken and should be inspected before
restarting.
Treat that failure like a run that never started: leave pnpm out of it,
put package.json (which the host may have rewritten before pnpm ran) and
pnpm-lock.yaml (which pnpm rewrites before it links) back, and check
that the previous build still has a loadable entry rather than assuming
so, since pnpm clears what it can of the target directory before
retrying the rename. The response then carries a short answer of its
own — the client keeps only the tail of stderr — saying what happened
and what to do: quit the host completely and update again.
pnpm 12's native CLI reports the same refused swap without an ERR_PNPM_
code ("failed to remove existing directory … prior to swap"); the
classifier now recognises that wording too.
spawnEnv sets CI=true, which pnpm reads and which the comment there says is
for TTY hangs. git does not read CI. It has its own switch, and it was not
set.
The gap stays closed while every install takes the codeload tarball path
accelerate.ts describes. A `github:owner/repo#path:/sub` spec does not:
pnpm resolves it with its git fetcher and shells out to git, HTTPS first.
git's credential prompt opens the controlling terminal rather than stdin,
so from a spawned child the question is asked where nobody can see it. The
reporter caught git.exe alive for eight minutes having burned 0.05s of CPU,
ended only by the fifteen-minute install timeout.
A default, not an override. A value the caller set wins, and blank counts
as unset because git cannot parse an empty GIT_TERMINAL_PROMPT either.
Credential helpers and GIT_ASKPASS are untouched and still answer first;
this closes only the terminal fallback, which is the one branch that cannot
work from a spawned child.
Scope is narrower than the issue title. The ssh half is deliberately left
out: it needs GIT_SSH_COMMAND, which overrides core.sshCommand and GIT_SSH
-- the two ordinary ways to choose an identity -- and whose BatchMode=yes
disables SSH_ASKPASS, breaking key-passphrase installs that work today.
That is a policy call for the maintainer, not something to slip into a bug
fix. Note also that runDshPlugin spawns detached on POSIX, so that subtree
has no controlling terminal and the prompt already dies instantly there;
the reported hang needs Windows, where the spawn is not detached.
findCatalogEntryForLocal walks the whole catalog at least twice per call,
and both client callers run once per rendered card. The reporter profiled
~300ms per repaint at 24 cards against a 3,627-entry catalog where a
version-pinned dependency paid 1.1ms; locally that shape measured ~38ms,
and ~1.4s at 96 cards with eight local dependencies. The version-pinned
branch already had looseMatchCountCache from #262, this one did not.
Same shape as that memo: a WeakMap on the catalog array identity, so a
refetched catalog gets a fresh map and the old one is collectable.
The inner key carries the evidence, not just the name. Installing a plugin
hands the next render a fresh identities array while the catalog array is
unchanged, so a name-only key would answer the post-install question with
the pre-install result — the same-named-fork confusion #485 asked this
matcher to stop making, reintroduced as a cache bug. Identities and hints
stay separate fields in that key rather than one blob, because they are not
interchangeable: an identity is searched against the whole catalog, a hint
only disambiguates rows that already matched by name.
The key is JSON rather than a joined string, because [] and [''] join to
the same thing and they are opposite questions: an empty-but-present
identity list has size 1, enters the evidence branch, and refuses to guess,
while an absent one falls through to the unique-name match. Joining let
whichever ran first answer for both.
catalogEntryForInstalled goes through the same function, so the Installed
tab was paying the same cost per row; it is cached too. Misses are cached
as null, since establishing one costs the same full scan as a hit — and a
checkout you are developing is usually not in the catalog at all.
The top-of-page 'Update all (N)' button only rendered for N >= 2, so with
exactly one plugin pending the header offered 'ignore all update
reminders' but no way to update — the user had to scroll the Installed
list for the row button. doUpdateAll already handles a single item, so
the threshold is now >= 1. Component spec covers the one-plugin case.
The desktop host installs plugins as generations: a `link:` into
`.generations/live/<pkg>+<version>+<hash>` that it reconciles against its
own desired.json at startup. `checkUpdates` read every `link:` as a
developer's checkout and answered "no update", so nothing the host
installed — the market included — ever reported a newer release.
A `link:` under `.generations/live/` now resolves to its catalog entry the
way a `file:` package does (#429), and the row names the newer release
without offering it: the host would put the generation straight back, so
an update applied here would only appear to work. `updateAvailable` stays
false, the row shows the version instead of a button, and the settings
card says where to update from. The versioned API answers the same way.