fix(pm): print registry auto-select hint on stderr#2941
Draft
elrrrrrrr wants to merge 1 commit into
Draft
Conversation
`select_fastest_registry` wrote its two informational lines —
`Registry: <url> (<latency>ms)` and `Tip: ut config set registry <url>
--global` — through `println!`, putting them on fd 1 alongside the
command's actual data output. That contaminates the executor
passthrough case `pbpaste | utx prettier --parser babel | pbcopy`
(the wrapper banner gets written into the clipboard alongside
prettier's formatted source) and the same shape would also corrupt
any `$(ut <cmd>)` shell capture, `ut <cmd> > file.txt` redirection,
and `ut <cmd> --json | jq` consumer.
Route the two macros through `eprintln!` so the lines land on fd 2.
The hint stays visible to interactive terminals (a bare process has
both fd 1 and fd 2 wired to the controlling TTY) and to CI runners
(GitHub Actions, GitLab CI, Jenkins, Buildkite, and CircleCI all
capture stdout and stderr together into the build log, usually
interleaved by emission time), while pipe consumers, redirected
files, and shell-substitution captures see a clean stdout. This is
the same split that cargo uses for the "Compiling foo v0.1.0" /
"Finished release" status lines, git for "Cloning into '...'" and
"Receiving objects: 100% (12/12)", curl for its progress meter
(`curl url | jq .field` works without `--silent` for exactly this
reason), and OpenSSH for "Warning: Permanently added 'host' (RSA) to
the list of known hosts." (which is on stderr so that
`ssh remote 'cat /etc/issue' > local.txt` doesn't bake the warning
into local.txt). The Unix contract is "stdout is the value a machine
consumer is supposed to read, stderr is everything aimed at the
human or at a log aggregator," and the registry auto-pick banner
falls squarely on the second half of that line.
The sibling `println!(" Registry: {registry}");` at
`crates/pm/src/service/config.rs:53` — the line that the `ut config`
listing command emits when the user explicitly asks the tool to
display its current configuration — stays on stdout on purpose. That
one is the answer to a user-issued query and the canonical target of
`registry=\$(ut config get registry)` shell substitution in scripts
that read configuration, the stdout-side case of the same Unix
convention. Same reasoning as `git rev-parse HEAD`'s commit hash
going to stdout while the surrounding git's clone-progress chatter
goes to stderr.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request updates the select_fastest_registry function in crates/pm/src/util/registry.rs to use eprintln! instead of println! for displaying registry information and tips, ensuring informational output is directed to standard error. I have no feedback to provide as there were no review comments.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
select_fastest_registryatcrates/pm/src/util/registry.rs:43—the auto-pick branch of
init_registry(
crates/pm/src/util/user_config.rs:33-58) that fires when none ofthe CLI
--registryflag, the$UTOO_REGISTRYenvironmentvariable, and the merged global+local
~/.utoo/config.tomland.utoo.tomlconfig file (assembled incrates/pm/src/util/config_file.rs:34-64) supplies a registry URL— printed its two informational lines
via
println!, which placed them on fd 1. Anything thatconsumed the command's stdout therefore picked the banner up as
data. The visible regression that motivated this PR was the
executor passthrough round-trip on macOS,
where
utx's contract — mirroring the contract ofnpx,pnpm dlx,yarn dlx, andcorepack, which are allbyte-transparent wrappers over their spawned tool's stdout — is
that the formatted source bytes flow end-to-end from the clipboard
through prettier and back into the clipboard. With the wrapper's
own auto-pick banner on fd 1, the two "Registry: ..." and
"Tip: ..." lines were prepended to prettier's stdin (which then
made prettier emit a syntax error because "Registry:" isn't valid
JavaScript) and the result of the round-trip wasn't the formatted
code at all. The same shape of failure applies to
output=\$(ut <cmd>)shell-substitution capture,ut <cmd> > file.txtfile redirection, and anyut <cmd> --json | jqmachine-readable-output pipeline.This PR moves the two macros from
println!toeprintln!, puttingthe diagnostic pair on fd 2. The hint remains fully visible to
every human-facing observer of the process:
streams, both fd 1 and fd 2 are wired to the controlling TTY. A
bare
ut installin a terminal therefore shows the two lines onscreen exactly as before the change — the screen position and
the ANSI colouring (the existing
colored::Colorize.dimmed()/
.cyan()/.green()/.yellow()decorations incrates/pm/src/util/registry.rs:83-90) are unchanged.and CircleCI runners all capture both fd 1 and fd 2 of the
spawned process into the per-step log artifact, generally
interleaved by wall-clock emission time so the line ordering is
preserved. The record of which registry was auto-picked, what
the ping latency was, and the global-config command to make the
choice sticky is preserved in the CI artifact across this
change.
tail -f/journalctl/ systemd-journal: any wrap thatsplits the two streams into separately-labelled-but-equally-
-visible records (the journald case is the canonical example,
with the
_STREAMfield tagging each entry as stdout or stderr)shows the banner with a "stderr" tag instead of a "stdout" tag.
And the hint stops appearing in the contexts where it has no
business appearing in the first place. POSIX shell's
|pipelineoperator only rewires the producer's fd 1 to the consumer's fd 0
(the consumer's fd 1 and fd 2 are inherited from the surrounding
shell, and the producer's fd 2 is also inherited unchanged); the
>redirection operator only addresses fd 1, while2>addressesfd 2; the
\$(...)and backtick command-substitution forms onlycapture fd 1, with fd 2 going to the surrounding shell's stderr
unaffected; Node's
child_process.exec(cmd, callback)API splitsfd 1 and fd 2 of the spawned child into separately-named
stdoutand
stderrfields of the callback's result so the caller pickswhich one is "the data"; Rust's
std::process::Commandwith itsStdio::piped()setup behaves identically; Python'ssubprocessmodule's
subprocess.run(cmd, capture_output=True).stdoutis thefd-1-only field, with
.stderrthe fd-2-only field, etc. All ofthose see a clean fd-1 stream once the banner is moved to fd 2.
This is the convention every long-lived Unix command-line tool
follows. The canonical citations a reviewer might consult:
cargoprintsCompiling foo v0.1.0 (...),Compiling bar v2.3.4 (https://github.com/ex/bar),Finished release [optimized] target(s) in 12.34s,Running unittests, and theUpdating crates.io indexline to stderr. The reasoncargo metadata --format-version 1 | jq '.packages | length'works without any flag is that the JSON metadata goes on stdout
while every status line goes on stderr.
gitprintsCloning into 'foo'...,remote: Enumerating objects: 12, done.,Receiving objects: 100% (12/12),Resolving deltas: 100% (3/3), andAlready up to date.tostderr. The reason
sha=\$(git rev-parse HEAD)produces a\$shacontaining only the 40-character commit hash, and thereason
git log --format='%H %s' --reverse main..feature > log.txtproduces a
log.txtcontaining only commit-hash + subjectlines, is that every piece of progress chatter is on stderr
while the actual queried data goes on stdout.
curlprints its progress meter (%, transferred bytes,ETA, transfer rate) to stderr. The reason
curl -L https://api.github.com/repos/utooland/utoo | jq .nameworks out of the box, without the
--silentflag, is that theHTTP response body is on stdout and the meter is on stderr. The
-s/--silentflag's name itself is the giveaway: itsilences the stderr-side chatter that's already on stderr, it
doesn't redirect anything.
wgetwrites its download progress and theSaving to: 'foo.tar.gz'line to stderr; the-O --bound filecontents go to stdout.
makewritesmake[1]: Entering directory '/foo/bar'andmake[1]: Leaving directory '/foo/bar'to stderr so that aMakefile rule of the form
out.txt: in.txt; tool < \$< > \$@doesn't corrupt out.txtwith the entering-and-leaving banner of recursive sub-makes.
apt/apt-getwriteReading package lists... Done,Building dependency tree... Done, and per-packageSetting up libfoo (1.2.3-4) ...lines to stderr.opensshwritesWarning: Permanently added 'github.com' (ED25519) to the list of known hosts.to stderr — the line is famously stderr-boundfor a specific operational reason:
scp,rsyncoverssh,and bare-ssh-with-remote-command-and-local-stdout-capture
(
ssh remote 'cat /etc/issue' > local.txt) all rely on theone-time warning not landing in the data sink.
npm/pnpm/yarnall write their progressspinners, the
added 124 packages, audited 1500 packages in 3.2ssummary, thedeprecated some-package@1.2.3: this package has been renamed to ...warnings, and thenpm WARN ...advisory lines tostderr. The machine-readable counterparts —
npm view foo version,npm config get registry,npm pack --json,pnpm list --json,yarn config get registry— go on stdout. The asymmetry is the same as in cargoand git: data that a script is meant to consume goes on stdout,
everything else goes on stderr.
kubectlwritesWarning: this is a deprecated APIandthe various
error: ...lines to stderr, whilekubectl get pod foo -o yamlandkubectl get pod foo -o jsonpath='{.metadata.uid}'go onstdout. The kubernetes-client convention's split is documented
explicitly in the kubectl source's
cmd_utilpackage.dockerwritesStep 1/5 : FROM alpine:3.18and theSending build context to Docker daemon 2.048 kBandSuccessfully built abc123def456ablines fromdocker buildto stderr, while
docker inspect foo --format '{{.Id}}''s IDgoes on stdout.
rsyncwrites its file-by-file transfer log and the finalsent 12,345 bytes received 678 bytes 9.8K bytes/secsummaryto stderr, so that
rsync --list-onlyplus a custom--out-formatplus stdout-grepping plays nicely in scripts.tarwrites its--verboseper-file listing to stderrwhen the archive content itself is going to stdout (
-c -f -),and to stdout when the archive content is going to a file
(
-c -f foo.tar), which is the dynamic-target behaviour theGNU
tarinfo-page section "Verbose output" describes.Each of those tools made the same call for the same operational
reason: stdout is the data channel a downstream
|-consumer or>-file or\$()-substitution gets, and stderr is theout-of-band channel for everything the human running the command
(or the log aggregator reading the journal) needs to see but the
machine doesn't. The registry-auto-select banner — "we noticed
you have no
registryconfigured anywhere, we measured the twowell-known npm mirror endpoints, here is the one we picked and
the latency, and by the way the standard way to make the choice
durable is
ut config set registry <url> --global" — is atextbook example of the stderr side of that line. It's chatter
the tool emits on its own initiative, addressed to the operator,
about the environment the tool found itself running in. It is
not the answer to any user-issued query the way
ut config get registryis, and it is not the data output ofany user-requested subcommand the way
ut install's lockfilewrite or
utx prettier's wrapped-tool stdout is.Intentionally not changed
The sibling
println!incrates/pm/src/service/config.rs:53prints the current registry value as part of the
ut configlisting command's output — the table-of-current-settings the
user gets back when they ask the tool what its configuration
looks like. That output is the answer to a user query, and the
supported consumption pattern for "what registry is configured?"
in a shell script is
i.e., command-substitution on the tool's stdout. It belongs on
fd 1 by exactly the same Unix convention that places this PR's
auto-pick banner on fd 2: stdout is "the value the user asked
for," stderr is "the chatter the tool emitted alongside the
value." Same logic as the
git rev-parse HEAD/git cloneasymmetry — the hash you asked for goes to stdout, the clone's
"Receiving objects:" progress that no one asked for goes to
stderr — and the same logic as the
cargo metadata/cargo buildasymmetry: the JSON metadata you asked for goes tostdout, the "Compiling foo v0.1.0" status of the build no one
asked for goes to stderr. So the
service/config.rs:53linestays on
println!deliberately and is the explicitscope-boundary of this PR.
Follow-up, deliberately out of scope here
There is a deeper fix orthogonal to "which fd does the banner go
on" — the frequency with which the auto-pick banner appears.
The auto-pick path currently fires on every
utoo/ut/utxinvocation that has no
--registryflag, no$UTOO_REGISTRYenv var, and no
registry =key in the merged config; on afresh developer machine, that's every command. The natural
remedy is for the success branch of
select_fastest_registryto persist the picked URL into the global config file via the
existing
Config::set("registry", url, ConfigScope::Global)API in
crates/pm/src/util/config_file.rs:66-69immediatelyafter the ping result has been emitted. Once the global config
gains a
[values] registry = "<url>"line, the next invocationof any
utoobinary hits the "registry key in the mergedconfig" priority branch inside
init_registry(
crates/pm/src/util/user_config.rs:44-51) and theping-and-print path is skipped entirely. The Tip line's
suggested command,
is exactly the manual step that this auto-persist would do
implicitly — so after that change landed the Tip text would be
redundant (the action it's recommending would already have
happened) and could be dropped, leaving only the
"Registry: (ms)" line as a one-time-per-machine
informational note. Filing as a separate task so this PR
remains a one-file two-line behavioural change with a tight
scope.
A second small follow-up, also out of scope: the codebase
already has a TTY-awareness lazy static at
crates/pm/src/util/logger.rs:30,built on the standard library's
std::io::IsTerminaltraitintroduced in Rust 1.70. The same file at line 73 still has a
call into the deprecated third-party
attycrate'satty::is(atty::Stream::Stdout)from when the standard-librarytrait didn't exist. Migrating the lingering
atty::is(...)callto the modern
IsTerminalAPI would let theattycrate bedropped from the workspace's dependency graph, which is a small
hygiene win unrelated to the stream-of-the-banner question
this PR addresses but in the same neighbourhood. The
is_terminalcrate that theattycrate's README now points atas the recommended successor is itself superseded by the
standard library since 1.70, so the right destination is the
standard-library
IsTerminaltrait that the same file at line30 already uses. Filing as a separate hygiene task.
Test plan
cargo fmt -p utoo-pm— clean, no whitespace delta. TheRust formatter has nothing to do because
println!andeprintln!are the same visual width (eight printablecharacters each, ending in
!), so the column alignment ofthe comma-separated macro arguments and the parenthesis on
the call's opening line is unchanged.
cargo clippy --all-targets -- -D warnings --no-deps—the workspace-wide post-edit verification step listed in
CLAUDE.md's "Post-Edit Verification" section. The localrun inside this fresh Superconductor worktree fails before
reaching the
utoo-pmcrate, because thenext.jsgitsubmodule (which the supermodule's
.gitmodulesregistersand which
git submodule statusshows in its"configured-but-not-checked-out" state with a leading
--prefixed pinned commit hasha1f6c5c22b6ed2aea3d023a8f4a798f22c1daf65) isn't checkedout in a fresh worktree. The workspace's cargo manifest
resolution then fails on the path dependencies into
next.js/turbopack/crates/turbo-bincode,next.js/turbopack/crates/turbo-tasks, and the otherTurbopack crates that the
pack-api,pack-core,pack-cli,pack-napi, andpack-schemacrates in thisrepo's workspace path-depend into. The error surfaces as
The standard project-setup step
git submodule update --init --recursive, documented inCLAUDE.md's "Project Overview" section, fetches the pinned
Turbopack-bearing
utooland/next.jsrepo into thenext.js/directory and unblocks cargo's workspaceresolution. The repo's GitHub Actions workflow uses
actions/checkout@v4withsubmodules: recursive(theconventional submodule-aware checkout configuration), so
CI handles the submodule initialization automatically and
the full workspace clippy gate runs there end-to-end. The
submodule-checkout dependency is orthogonal to the
two-token change in this PR — clippy would fail in
identical fashion against an unmodified
nextbranch on abare worktree without the submodule.
Interactive sanity — in a working directory that
has no
registry =line in./.utoo.tomland whose\${XDG_CONFIG_HOME-\$HOME/.config}/.utoo.tomland\$HOME/.utoo/config.tomlglobal counterparts likewise lackthe key, and whose process environment has no
\$UTOO_REGISTRYset, run a bareut installin a TTY.Expected outcome: the
Registry: <url> (<Nms>ms)andTip: ut config set registry <url> --globalpair appears onthe controlling terminal in dim/cyan/green/yellow ANSI
colouring exactly as it did before this PR, because the
process's stderr is attached to the controlling TTY when
the shell isn't intercepting either of its streams. To
force this code path on a developer machine that already
has a sticky global registry, the temporary-rename trick is
to move the global config aside before the run:
mv ~/.utoo/config.toml ~/.utoo/config.toml.bak, observethe banner, then restore. The cleaner alternative is
running the binary against an isolated home:
HOME=\$(mktemp -d) ut installin a project directory thathas neither a
.utoo.tomlnor apackage-lock.json—though that depends on
ut install's interaction with theabsence of a lockfile, which is the lockfile-generation
path rather than the install-from-lockfile path.
The original failure mode — the screenshotted
regression that prompted this PR. Copy a JS snippet to the
macOS clipboard,
and then run the executor-passthrough round-trip,
and inspect the clipboard with another
pbpaste. Beforethis PR, the clipboard came back with the wrapper banner
prepended to whatever prettier had managed to produce
(which, depending on prettier's error-recovery behaviour
when fed a
Registry:line as the first token of theinput, was either an empty string with prettier-on-stderr
error noise or a partially-formatted prefix). After this
PR, the clipboard contains just
const x = 1;\n— thecanonical prettier formatting of the input snippet —
while the two hint lines appear on the terminal in
between the two ends of the pipeline, in colour. The
reason the terminal sees them: the middle stage of an
a | b | cpipeline has its stdin connected toa'sstdout, its stdout connected to
c's stdin, and itsstderr connected to whatever the surrounding shell wired
fd 2 to (the controlling terminal in the bare interactive
case). So
utx's stderr is the only one of its threestandard streams that's not consumed by a neighbouring
pipeline stage, which is exactly where the user is meant
to see the wrapper-emitted environment chatter.
Shell capture — run
in a shell where
utx tscresolves to TypeScript'scompiler binary, and inspect the byte content of
\$version. The expected output is exactly the linewritten by
tscon its own stdout (the bytesVersion 5.4.5\nor whatever the project's pinned TypeScriptversion is). Before this PR, with
ut's registry banner onstdout, the captured
\$versionhad a multi-line prefixcontaining the ANSI-coloured
Registry:andTip:linesahead of the tsc-version line, which would have broken any
script that did
if [[ "\$version" =~ ^Version\ 5\. ]]-style gating on theleading characters of the captured value.
JSON / structured-output consumer — if the
utsubcommand surface gains a machine-readable subcommand
(
ut pack --json,ut deps --json, or any sibling ofnpm view --json), the test for this PR's contract isthat
ut <sub> --json | jq .parses withoutparse error: Invalid numeric literal at line 1, column Nfailures. Today the relevant query subcommands
(
ut config get <key>, theut --versionflag, and theut list-cacheintrospection) all bypass the auto-pickpath entirely because they read configuration without
performing a registry-touching operation, so a literal
smoke test would need to be a registry-touching subcommand
with a JSON output mode — file as a follow-up if such a
subcommand is added.
CI build log on this PR — the
GitHub Actions log for the PR's checkout-and-test job
should still contain the
Registry:andTip:lines asit did on prior PRs that exercised the auto-pick branch.
The GitHub Actions runner captures both fd 1 and fd 2 of
the shell-launched commands into the per-step log
artifact, so the visibility property — "the human reading
the CI log later can see which registry was picked" —
carries over from the pre-change stdout-bound emission to
the post-change stderr-bound emission without observer
change. If the post-change CI log were missing the
lines, the runner's stderr capture would be misconfigured,
which would be a much bigger problem than this PR.
No regression test asserts on the captured-output
bytes of
select_fastest_registry. The only Rust testin the workspace that touches the function is
test_select_fastest_registryin the same file atcrates/pm/src/util/registry.rs:148-153, which isdeclared
It is
#[ignore]-gated because it makes live HTTP requeststo the two registry endpoints (
https://registry.npmmirror.com/-/pingand
https://registry.npmjs.org/-/pingper theping_registryhelper at lines 24-40) to measure thelatencies that the function picks the minimum of, so it's
opt-in for the
cargo test -- --ignoredinvocation thatthe project's CI runs as a separate suite (or doesn't, if
the network-touching ignored tests are excluded from CI on
hermeticity grounds — either way, the gate is whether the
returned
Stringequals one of the two well-knownregistry constants, and the gate has nothing to say about
what stream the function emitted its banner on). The
banner-text grep over the e2e shell scripts —
grep -rIn 'Registry:\|"Tip:' e2e crates/pm/src— findsno matches inside
e2e/utoo-pm.shor elsewhere outsidethe same Rust file that defines the banner strings.
Confirmed: nothing in the test surface is sensitive to
the stdout-vs-stderr choice for these two lines.
🤖 Generated with Claude Code