Plugin install registries
插件安装的注册表
The install dialog handed the spec to `pnpm add` with no registry of its own, so pnpm resolved packages from whatever its configuration named, which for a person who never wrote an `.npmrc` is `registry.npmjs.org`. On a network that cannot reach it, every install failed with a network error after pnpm's own retries, which take over a minute for a registry that does not answer; the check before the install failed the
English
Problem
The install dialog handed the spec to pnpm add with no registry of its own, so pnpm resolved packages from whatever its configuration named, which for a person who never wrote an .npmrc is registry.npmjs.org. On a network that cannot reach it, every install failed with a network error after pnpm's own retries, which take over a minute for a registry that does not answer; the check before the install failed the same way, and nothing offered a mirror. A mirror is the ordinary answer in mainland China, but pnpm's --registry is a CLI flag the dialog could not pass, and an .npmrc is not something the dialog's audience edits.
Decision
The manager asks a plan of registries, not one. PluginManager.Config gains registry, the registry asked first (absent: null, the one pnpm's own configuration names), and fallbackRegistries, the registries asked after it, defaulting to npmmirror. registryPlan resolves one operation's plan: the requested registry, else the configured first, then the rest of the configured set. A requested registry outside that set is asked alone: a private registry never falls through to a public one, where a name that exists on both would install someone else's package. Registries are compared in pnpm's form, lower-case host with a trailing slash.
pnpm's own registry is public only once read as such. null names whatever the machine's .npmrc chain says, which may be a company registry. Before each plan the manager reads it through pnpm config get registry in the profile directory, and null joins the configured set only while that is npm's own registry or one of the configured fallbacks; otherwise it is asked alone, whether requested as null or by its URL, and a public registry asked instead never falls back into it. A registry pnpm already names is asked once rather than twice. Reading fails closed: an unreadable answer leaves null alone. registries() returns the reading as resolved, so the dialog labels the option by the host pnpm names when that is not npm's own.
Only a failure another registry can change sends the operation on. attributeFailure lays network and timeout, and not-found and no-matching-version, at the registry, because a mirror lags the registry it copies and a package published minutes ago is not there yet; every other kind stops at once. A git spec and a tarball URL carry the host pnpm fetches them from, which no registry stands in for: a failure whose error line names that host is laid at the spec's host and stops, while a failure naming the registry, from which the spec's dependencies come, moves on; a warning line that merely links to the host does not count. The attribution reaches the caller as ChangeResult.failedAt, so the dialog words the failure and offers another registry from the Host's reading rather than its own guess. The classifier gains npm's FETCH_ERROR as a network failure; pnpm's ERR_PNPM_NO_OFFLINE_META is an offline cache miss no registry changes and stays unclassified.
The check decides which registry is alive; the install starts there. inspect runs pnpm view with --registry and --config.fetch-retries=0, so a registry that does not answer is reported within inspectTimeoutMs — pnpm's own schedule waits ten seconds and then a minute between retries — and the next one is asked; a lookup killed at that bound is refused as a network problem like a refused connection. pnpm prints a --json refusal as { error: { code, message } } on stdout with nothing on stderr, so the classifier reads both. The accepted answer names the registry that answered; the client passes it to installBundle, which starts there rather than at a registry the check already found dead. The install keeps pnpm's retry settings: it downloads tarballs, which a shorter timeout would cut on a slow but working network, and it moves on to the next registry only for a failure mid-run. pnpm-workspace.yaml is not written for this: fetchRetries and fetchTimeout there would bound the install's downloads as well as the check, and --config.fetch-timeout passed on the command line crashes pnpm 11.8, which reads it as a string.
Every attempt is visible. installBundle announces installing once per registry with the attempt's registry, position, and the plan's length, restores the profile files between attempts and stops before starting a run on a signal the person already aborted, streams each run under its own job id with --registry on the command line, and reports the registries asked beside the last run's result. A refused check names the registries it asked too. The dialog offers the configured set with pnpm's own registry, titled with the host it names, and a typed address, starts from the registry the Host asks first until a choice is remembered in the browser's localStorage, says which registry is asked now while the Host moves on, badges each run's registry, names every registry asked when none could be reached, and names the spec's own host when the Host laid the failure there. It does not describe the plan: the order asked is the Host's, shown as it happens. A typed address is checked against the same REGISTRY_URL the Host validates with, published through the package's ./registry entry for the browser.
Alternatives considered
Write the chosen registry into the profile's .npmrc or pnpm-workspace.yaml. Rejected: the choice belongs to one install, not to every later pnpm command in the profile, and a file pnpm reads on every run would have to be rewritten and restored around each attempt.
Keep the last choice in Host settings. Rejected for now: a settings namespace, its controller write path, and its catalog entry buy a choice shared across browsers and with the CLI, which dsh plugin add --registry already takes on its command line; the dialog's choice is passed to each call explicitly, so the Host holds no state for it and the browser remembers it.
Retry through every registry on any failure. Rejected: a blocked build script, a full disk, or a package that declares no bundle fails the same way everywhere, and a git host that cannot be reached is not reached through any registry; retrying those costs the person a wait and hides the cause.
Fall back to the public registries after a private one. Rejected: dependency confusion. A name that a private registry serves and a public one also holds would install the public package when the private registry is down. This holds for a private registry the machine's .npmrc names as much as for one typed into the dialog, which is why pnpm's own registry is read before it may join the plan.
Treat pnpm's own registry as public without reading it. Rejected: the shipped configuration would then send every .npmrc-configured company registry's failures on to npmmirror. Reading it costs one pnpm config get registry per plan, about 170 ms.
Let the dialog describe the plan. Rejected: a copy of the membership rule drifted at once — a typed mirror URL without its trailing slash read as a lone registry in the dialog while the Host normalized it into the set — and a sentence about fallbacks under the options was noise to the person choosing one. The dialog shows the order only as the Host asks it.
Bound the install's fetches like the check's. Rejected: the check makes one small request and its liveness answer is what the plan needs; the install's tarballs on a slow but working connection need pnpm's defaults, and the registry the check found alive is the one the install starts at.
Consequences
Config gains registry and fallbackRegistries; inspect takes InspectOptions before its signal and answers the registry that answered, the spec's own host, and the registries a refusal asked; InstallBundleOptions, ChangeResult (with registries and failedAt), PluginInstallProgress, and the plugin_manager tool carry the registry; registries() joins the Remote and answers resolved. viewProfilePackage takes a registry and never lets pnpm retry; readProfileRegistry asks pnpm what its configuration names. The package publishes ./registry — registryPlan, normalizeRegistry, REGISTRY_URL — as a browser-safe entry the client bundle inlines; the dialog uses REGISTRY_URL. Every plan spends one pnpm process on the reading; a machine whose pnpm cannot be read gets no fallback.
Testing
packages/boot/plugin-manager/tests/registry.spec.ts pins the plan, including a private and an unknown pnpm registry, and the attribution; install-spec.spec.ts pins the hosts the spec forms carry and the classifier's patterns; manager.spec.ts asks the registries in turn from the check and the install, reads a refusal printed on stdout, restores the files between attempts, stops at a git host and at a registry outside the configured set, reports a stop between attempts as cancelled, and refuses a registry that is not an http(s) URL at load; operations.spec.ts pins the lookup's arguments and the reading of pnpm's registry. packages/client/ui-plugin-manager/tests cover the store's choice, its memory across controllers, the attempts the Host announces, the return to the spec with the options open, and the page's picker, attempt copy, run badges, and failure sentences.
中文
Problem
安装对话框把 spec 交给 pnpm add 时不指定注册表,pnpm 就从它自身配置指定的地方解析包;对于从未写过 .npmrc 的人,那就是 registry.npmjs.org。在连不上它的网络里,每次安装都在 pnpm 自身重试之后以网络错误告终——对不应答的注册表,这要一分多钟;安装前的检查也以同样方式失败,而没有任何地方提供镜像。镜像是中国大陆的常规答案,但 pnpm 的 --registry 是对话框传不了的命令行参数,而 .npmrc 不是对话框的受众会去编辑的东西。
Decision
管理器询问的是一个注册表计划,不是一个注册表。 PluginManager.Config 新增 registry——首先询问的注册表(缺省为 null,即 pnpm 自身配置指定的那个)和 fallbackRegistries——随后依次询问的注册表,默认为 npmmirror。registryPlan 解出一次操作的计划:请求的注册表,否则配置的第一个,然后是配置集合中的其余。请求的注册表不在这个集合里时只问它自己:私有源永远不会落到公共源,否则两边都有的名字会装上别人的包。注册表按 pnpm 的形式比较:小写主机名加尾部斜杠。
pnpm 自身的注册表只有读出来是公共的才算公共。 null 指向本机 .npmrc 链说的任何地方,可能是公司注册表。每次做计划前管理器在 profile 目录里用 pnpm config get registry 读出它,只有它是 npm 官方源或某个配置的备选源时 null 才进入配置集合;否则不论以 null 还是以它的 URL 请求都只问它自己,改问公共源时也绝不回落到它。pnpm 已经指向的注册表只问一次而不是两次。读取失败时保守处理:读不出来就让 null 只问自己。registries() 把读出的结果作为 resolved 返回,所以 pnpm 指向的不是 npm 官方源时,对话框按其主机名标注该选项。
只有换一个注册表能改变的失败才让操作继续。 attributeFailure 把 network、timeout 归于注册表,把 not-found、no-matching-version 也归于注册表,因为镜像落后于它复制的注册表,几分钟前刚发布的包还没到那里;其他种类立即停止。git spec 和 tarball URL 携带 pnpm 拉取它们的 host,没有注册表能替代它:错误行点名那台主机的失败归于 spec 自身主机并停止,而点名注册表(spec 的依赖来自那里)的失败则继续;仅仅链接到该主机的警告行不算。归因以 ChangeResult.failedAt 交给调用方,所以对话框按 Host 的判断而不是自己的猜测来措辞并决定是否提供换源。分类器新增 npm 的 FETCH_ERROR 作为网络失败;pnpm 的 ERR_PNPM_NO_OFFLINE_META 是离线缓存缺失,换注册表改变不了,保持未分类。
检查决定哪个注册表活着;安装从那里开始。 inspect 以 --registry 和 --config.fetch-retries=0 运行 pnpm view,所以不应答的注册表会在 inspectTimeoutMs 内报告——pnpm 自身的节奏是重试之间先等十秒再等一分钟——然后询问下一个;在这个上限被杀掉的查询与连接被拒一样作为 network 问题拒绝。pnpm 把 --json 的拒绝以 { error: { code, message } } 打印在 stdout 而 stderr 为空,所以分类器两者都读。通过的答复带上作答的 registry;客户端把它传给 installBundle,安装从那里开始,而不是从检查已经发现死掉的注册表开始。安装保留 pnpm 的重试设置:它要下载 tarball,更短的超时会在慢但可用的网络上掐断下载,且它只在运行中途失败时才转问下一个注册表。为此不写 pnpm-workspace.yaml:那里的 fetchRetries 与 fetchTimeout 会同时约束安装的下载和检查,而命令行传入的 --config.fetch-timeout 会让 pnpm 11.8 崩溃,因为它被当作字符串读取。
每次尝试都可见。 installBundle 每问一个注册表通告一次 installing,带上这次的注册表、序号和计划长度,两次尝试之间恢复 profile 文件,并在用户已经取消时不再以失效的信号启动下一次运行,每次运行以自己的 job id 流式输出且命令行带 --registry,并在最后一次运行的结果旁报告问过的 registries。被拒绝的检查同样列出它问过的 registries。对话框提供配置集合、pnpm 自身的注册表(标题带上它指向的主机名)和手动输入的地址,在浏览器 localStorage 记住选择之前从 Host 首先询问的注册表开始,在 Host 转问时说明现在问的是哪个注册表,给每次运行标上注册表,所有注册表都连不上时逐一列出,而 Host 把失败归于 spec 自身主机时则点名那台主机。它不描述计划:询问顺序是 Host 的,发生时才显示。手动输入的地址用 Host 校验所用的同一个 REGISTRY_URL 检查,该常量经包的 ./registry 入口发布给浏览器。
Alternatives considered
把所选注册表写进 profile 的 .npmrc 或 pnpm-workspace.yaml。 否决:这个选择属于一次安装,不属于 profile 里之后的每条 pnpm 命令,而 pnpm 每次运行都读的文件得在每次尝试前后改写再恢复。
把上次选择存进 Host 设置。 暂时否决:一个设置命名空间、它的控制器写入路径和目录条目换来的是跨浏览器并与 CLI 共享的选择,而 dsh plugin add --registry 已经在命令行接受它;对话框的选择显式传给每次调用,Host 不为它保存状态,由浏览器记住。
任何失败都换遍所有注册表。 否决:被拦下的构建脚本、写满的磁盘、不声明组合包的包在哪里都同样失败,连不上的 git 主机经由任何注册表也连不上;重试这些只让人白等,还掩盖原因。
私有源之后落到公共源。 否决:依赖混淆。私有源提供而公共源也有的名字,会在私有源宕机时装上公共源的包。这对本机 .npmrc 指定的私有源和对话框里手填的私有源同样成立,所以 pnpm 自身的注册表要先读出来才可能进入计划。
不读取就把 pnpm 自身的注册表当作公共源。 否决:那样出厂配置会把每个 .npmrc 配置了公司注册表的用户的失败转到 npmmirror。读取的代价是每次计划一个 pnpm config get registry 进程,约 170 ms。
让对话框描述计划。 否决:成员规则的副本立刻就漂移了——手填没有尾斜杠的镜像 URL 在对话框里读作单独的注册表,而 Host 归一化后把它并入集合——而且选项下方一句关于备选的说明对正在选源的人只是噪音。对话框只在 Host 询问时按实际顺序显示。
像检查一样限制安装的请求。 否决:检查只发一个小请求,它的存活答复正是计划所需;安装在慢但可用的连接上下载 tarball 需要 pnpm 的默认值,而安装从检查发现活着的注册表开始。
Consequences
Config 新增 registry 与 fallbackRegistries;inspect 在 signal 之前接受 InspectOptions,并答复作答的注册表、spec 自身的主机和拒绝时问过的注册表;InstallBundleOptions、ChangeResult(含 registries 与 failedAt)、PluginInstallProgress 和 plugin_manager 工具携带注册表;registries() 加入 Remote 并答复 resolved。viewProfilePackage 接受 registry 且从不让 pnpm 重试;readProfileRegistry 向 pnpm 询问其配置指向哪里。包把 ./registry——registryPlan、normalizeRegistry、REGISTRY_URL——作为浏览器安全的入口发布,客户端 bundle 内联它;对话框使用 REGISTRY_URL。每次计划花费一个 pnpm 进程做读取;pnpm 读不出来的机器没有备选。
Testing
packages/boot/plugin-manager/tests/registry.spec.ts 钉住计划(含私有与未知的 pnpm 注册表)与归因;install-spec.spec.ts 钉住各 spec 形式携带的主机和分类器的模式;manager.spec.ts 从检查和安装依次询问注册表、读取打印在 stdout 的拒绝、在尝试之间恢复文件、在 git 主机和配置集合之外的注册表处停下、把两次尝试之间的取消报告为已取消,并在加载时拒绝不是 http(s) URL 的注册表;operations.spec.ts 钉住查询的参数和 pnpm 注册表的读取。packages/client/ui-plugin-manager/tests 覆盖 store 的选择、跨控制器的记忆、Host 通告的尝试、带着展开选项回到 spec,以及页面的选择器、尝试文案、运行标记和失败句子。