Skip to content

Fix startup crash when the OS can't look up the current user's shell - #8496

Merged
craigmichaelmartin merged 5 commits into
mainfrom
guard-oclif-shell-detection
Sep 9, 2026
Merged

craigmichaelmartin merged 5 commits into
mainfrom
guard-oclif-shell-detection

Conversation

@craigmichaelmartin

@craigmichaelmartin craigmichaelmartin commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

WHY are these changes introduced?

Fixes the top unhandled CLI startup crash in Observe: shop/issues#75079 — A system error occurred: uv_os_get_passwd returned ENOMEM (not enough memory). P2, 113 events / 53 affected users over 7 days. For these users the CLI does not start at all — no command runs, and no analytics row is emitted.

Also fixes #8497.

WHAT is this pull request doing?

While loading, oclif resolves the active shell from process.env.SHELL ?? os.userInfo().shell. Node's os.userInfo() throws a SystemError whenever the OS can't resolve the current user, and oclif's call is unguarded — so the process dies inside the config.load() we invoke ourselves at packages/cli-kit/src/public/node/cli-launcher.ts:31.

Reported instances are Windows (%APPDATA%\npm global installs), where SHELL is normally unset so the syscall runs on every invocation. Worth noting libuv hardcodes the shell field to NULL on Windows, so that call can never return anything useful there — it can only succeed uselessly or crash.

This sets SHELL before handing off to oclif, but only when the lookup actually fails:

function setShellVariableWhenUserLookupFails(): void {
  if (process.env.SHELL !== undefined) return

  try {
    os.userInfo()
  } catch {
    process.env.SHELL = 'unknown'
  }
}

Why the environment variable rather than a guard. The obvious fix is to override oclif's _shell() on our ShopifyConfig subclass and catch the throw. That works on the pinned 4.8.3 and nothing else: 4.11 drops _shell from Config and moves the logic into a module-level getShell() (lib/util/os.js:37) that a subclass cannot reach. Both versions read the same process.env.SHELL ?? os.userInfo().shell, so seeding the variable is the one lever that covers both — and it does not depend on an undocumented protected member.

That also fixes #8497: the plugins topic resolves to @oclif/core 4.11.14, whose Command.run() re-loads its own Config. A subclass override could never reach it; the environment variable does.

Behaviour on healthy machines is unchanged — the probe only runs when SHELL is unset, and only a throwing lookup writes anything. On a failing machine, config.shell becomes 'unknown', which is what oclif's own fallback branch emits, and feeds one consumer: env_shell in packages/cli-kit/src/private/node/analytics.ts:101. Those users currently send no analytics at all, so this is strictly more data, not less.

Notes for the reviewer

The tests drive the real crash path. They spy on node:os's userInfo — the default import is the same module.exports object oclif's compiled CJS does a property lookup on — and unset SHELL, so config.load() runs oclif's actual detection. Mutation-verified three ways: dropping the call from load(), having the helper swallow the failure without setting SHELL, and setting SHELL unconditionally without probing. Each fails exactly the expected test.

Removal condition. Drop this once @oclif/core guards the call itself. No released version does — it is unguarded in 4.8.3, 4.11.14 and on main. The note lives on the helper.

Not added to environmentIssueMessages. Leaving this class of report unhandled in Observe is what will tell us if the workaround ever stops working or the failure moves to a different frame.

How to test your changes?

pnpm vitest run packages/cli-kit/src/public/node/custom-oclif-loader.test.ts

End to end, with the syscall forced to fail against a built CLI:

cat > /tmp/patch-os.cjs <<'JS'
const os = require('node:os')
os.userInfo = () => { throw new Error('A system error occurred: uv_os_get_passwd returned ENOMEM (not enough memory)') }
JS
env -u SHELL node --require /tmp/patch-os.cjs packages/cli/bin/run.js version
env -u SHELL node --require /tmp/patch-os.cjs packages/cli/bin/run.js plugins

Both crash on main and pass here.

Verified locally: tsc --noEmit -p packages/cli-kit, nx run cli-kit:build, nx run cli:build and bin/run-knip-ci.js all exit 0; eslint clean; full packages/cli-kit suite matches a stashed main baseline exactly (3 pre-existing failures in deprecations.test.ts and node-package-manager.test.ts). Two port-binding tests (tcp.test.ts, graphiql/server.test.ts) flake intermittently under load on my machine — confirmed unrelated by re-running this branch with the two new tests skipped, where they still flake at the same rate.

Measuring impact

  • Error monitoring (Observe)

🤖 Generated with Claude Code

oclif's Config.load() populates config.shell by reading
`process.env.SHELL ?? os.userInfo().shell`. Node's os.userInfo() throws a
SystemError whenever the OS passwd lookup fails, and oclif's call is
unguarded, so the CLI dies before it can run any command.

Override _shell() on ShopifyConfig to fall back to oclif's own 'unknown'
sentinel. The happy path is `return super._shell()`, so healthy
environments are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
Comment on lines +127 to +128
// SHELL short-circuits oclif's `process.env.SHELL ?? userInfo()`, so it has to be unset for the
// OS lookup — the call that crashes in production — to run at all.

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.

This comment seems immediately dated - the call doesn't crash in production anymore, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, it was stale the moment it merged. Cut down to what the next reader actually needs to know — why SHELL has to be unset for the test to reach the code under test:

// oclif and the guard both only reach os.userInfo() when SHELL is unset.

/**
* The SystemError Node throws from os.userInfo() when the OS can't resolve the current user.
*
* @returns An error shaped like the real failure reported by affected users.

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.

Again, something that should be a PR description/comment is leaking into the code itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair — dropped that docblock. The function name carries it, and the error string in the body is self-evident. Took the same pass over the source comments; the only prose left is the two things that aren't recoverable from the code: why we set SHELL at all, and when it can be deleted.

Comment on lines +73 to +75
* Note when bumping the oclif dependency: 4.11 drops `_shell` from `Config` and moves the logic into a
* module-level `getShell()`, so this override stops compiling. The call is still unguarded upstream, so
* reinstate the guard at whatever seam replaces it rather than dropping it.

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.

This seems like a problem waiting to happen, I don't think a guard does it. Can we research what 4.11 does instead and compensate for both, with a note to remove this temporary fix when upgrading?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — researched it, and you were right that a guard doesn't do it.

4.11 drops _shell from Config and moves the logic to a module-level getShell() in lib/util/os.js, which a subclass can't reach. But both versions read the same expression — process.env.SHELL ?? os.userInfo().shell — so setting SHELL short-circuits the failing syscall on either one. Switched to that and dropped the override entirely.

The probe only runs when SHELL is unset, and only a throwing lookup writes the variable, so healthy machines are untouched. Verified against both copies in the tree:

BEFORE   4.11.14 getShell() -> THREW: uv_os_get_passwd returned ENOMEM
AFTER    4.8.3 root config.load() -> OK, shell=unknown
         SHELL after load: "unknown"
         4.11.14 getShell() -> unknown
HEALTHY  SHELL=/bin/zsh -> getShell() = zsh, process.env.SHELL untouched

The removal note is now on the helper: drop it once oclif guards the call itself. There is no released version that does.

Unplanned upside: this also fixes the plugins topic, which builds its own 4.11 Config and was still crashing under the override. That was #8497 — now closed by this PR.

craigmichaelmartin and others added 4 commits September 8, 2026 15:57
Replace the _shell() override with a SHELL environment variable that is
set only when os.userInfo() actually fails. Both 4.8's Config._shell()
and 4.11's module-level getShell() read `process.env.SHELL ?? userInfo()`,
so seeding the variable short-circuits the failing syscall on either
version instead of relying on a member 4.11 removes.

Healthy machines are untouched: the probe only runs when SHELL is unset,
and only a throwing lookup writes the variable.

This also covers the `plugins` topic, which loads its own oclif 4.11
Config that a subclass override could never reach.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
Module-local helpers in cli-kit aren't documented (see os.ts), and the
tests in this file carry no comments, so drop the docblock and the
inline notes. What's left is the one thing the code can't say: why SHELL
is set at all, and when the workaround can go.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
4.8.3, 4.11.14, 4.14.0 and 5.0.0 all carry the same unguarded
userInfo() call, so naming two versions invites the question of whether
upgrading would remove the need for this.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
Implied by the fact we haven't upgraded.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Assisted-By: devx/fe71f4bc-5528-4d43-bb7c-41bc10b50658
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/cli-kit/dist/public/node/custom-oclif-loader.d.ts
@@ -17,6 +17,12 @@ export declare class ShopifyConfig extends Config {
      * @param loader - The lazy command loader function.
      */
     setLazyCommandLoader(loader: LazyCommandLoader): void;
+    /**
+     * Override load to protect oclif's shell detection from a failing OS user lookup.
+     *
+     * @returns A promise that resolves once the config is loaded.
+     */
+    load(): Promise<void>;
     /**
      * Override runCommand to use lazy loading when available.
      * Instead of calling cmd.load() which triggers loading ALL commands via index.js,

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

Thanks for the updates! Approving as it makes sense, but would upgrading oclif to 4.11 (we're already on 4.8.3) solve this already?

@craigmichaelmartin

Copy link
Copy Markdown
Contributor Author

Thanks! No unfortunately there is the same issue in all oclif 4.x and 5.x versions.

@craigmichaelmartin
craigmichaelmartin added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit d2c58a9 Sep 9, 2026
30 checks passed
@craigmichaelmartin
craigmichaelmartin deleted the guard-oclif-shell-detection branch September 9, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugins topic still crashes when the OS can't resolve the current user (residual of #8496)

2 participants