fix(desktop): find the uploader the user installed, wherever they installed it (#5518) by Jocs · Pull Request #5519 · marktext/marktext · GitHub
Skip to content

fix(desktop): find the uploader the user installed, wherever they installed it (#5518) - #5519

Merged
Jocs merged 12 commits into
developfrom
fix/picgo-login-shell-path-5518
Sep 23, 2026
Merged

Jocs merged 12 commits into
developfrom
fix/picgo-login-shell-path-5518

Conversation

@Jocs

@Jocs Jocs commented Sep 23, 2026

Copy link
Copy Markdown
Member

Closes #5518.

The problem

A GUI-launched app inherits launchd's PATH (/etc/paths + /etc/paths.d/*), not the login shell's. MarkText's only compensation was a hardcoded handful of Homebrew/npm bin dirs, so a picgo installed with pnpm, volta, bun, or a node managed by nvm/fnm/asdf was invisible: Preferences → Image → Uploader said "your system does not have picgo installed" and every upload rejected with PicGo command not found in PATH — while picgo --version worked in a terminal, and launching the very same build from a terminal made both work.

Measured on the reporter's machine (picgo 2.0.3 via pnpm add -g, at ~/Library/pnpm/picgo):

$ launchctl getenv PATH        # empty → GUI apps get /etc/paths + /etc/paths.d/*
picgo NOT FOUND in that PATH
missing /opt/homebrew/bin/picgo
missing /usr/local/bin/picgo
missing /usr/bin/picgo
missing ~/.npm-global/bin/picgo
missing ~/.npm/bin/picgo
missing /usr/local/lib/node_modules/.bin/picgo

Every candidate the app knew about missed, and ~/Library/pnpm appears on the login-shell PATH and nowhere else.

The fix

Two layers, because neither alone is enough:

  1. patchEnvPath learns the user-level bin dirs that sit at a fixed path — pnpm's home (~/Library/pnpm, ~/.local/share/pnpm), ~/.local/bin, volta, bun, yarn, the configured npm prefix. Synchronous, so it is in place before anything looks for a command.
  2. ensureShellEnvPath asks the login shell for its PATH once per run — the only way to reach an install under a version number no static list can guess (nvm/fnm/asdf). It is started while the first window opens and awaited by whoever needs a user-installed command, so nothing blocks on it. A shell that fails, times out (5s), or cannot run commands (nologin) leaves PATH as it was.

The printenv + marker shape is deliberate: a startup file is free to print a banner first, and echo $PATH would come back space-separated under fish.

Detection and upload also stop keeping their own candidate lists. cmd.ts had five macOS-only paths and none at all on Linux; uploader.ts had a different seven. Both now call one resolveCommandPath, so a green check can no longer mean an upload that fails.

Windows is untouched by design — its GUI apps start with the user's own environment, so extraPathDirs is empty there and no shell is spawned.

Tests

  • test/unit/specs/shell-env-path.spec.ts — the import adds a dir only the login shell knows, keeps the existing order, runs the shell once however many callers ask, survives a banner-printing startup file, and leaves PATH alone when the shell fails, is a nologin, or reports a relative dir.
  • test/unit/specs/picgo-discovery.spec.ts — with a real picgo stand-in reachable only through a fake login shell, mt::cmd::exists answers true and mt::uploader::upload completes. Both failed before this change with exactly the reported symptoms (false, PicGo command not found in PATH).
  • test/unit/specs/patch-env-path.spec.ts — the new fallback dirs per platform.
  • test/e2e/issue-5518-picgo-shell-path.spec.ts — the real packaged app launched with a Dock-like PATH, asked through the real preload bridge. Verified red on develop and green here.

Verified on the reporter's machine as well: the built app launched with the reconstructed launchd PATH now resolves the real picgo (2.0.3) and runs it, where the old candidate list returned null.

🤖 Generated with Claude Code

Jocs and others added 2 commits September 23, 2026 09:04
…talled it (#5518)

A GUI-launched app inherits launchd's PATH, not the login shell's, and the
only compensation was a hardcoded handful of Homebrew/npm bin dirs. A picgo
installed with pnpm, volta, bun or a node managed by nvm/fnm/asdf was therefore
invisible: the uploader panel reported "your system does not have picgo
installed" and every upload rejected with "PicGo command not found in PATH",
while `picgo --version` worked in a terminal and launching the same build from
a terminal made both work.

Two layers now answer that. `patchEnvPath` gains the user-level bin dirs that
sit at a fixed path (pnpm's home, ~/.local/bin, volta, bun, yarn, the npm
prefix), and `ensureShellEnvPath` asks the login shell once per run for its
PATH, which is the only way to reach an install under a version number no list
can guess. The import is started while the first window opens and awaited by
whoever needs a user-installed command.

The detection check and the upload also stop keeping their own candidate lists
— cmd.ts had five macOS-only paths and none at all on Linux, uploader.ts had a
different seven — so a green check can no longer mean an upload that fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d off Windows path spelling

The e2e overrode PATH wholesale, which a CI runner need not survive; the temp
bin dir it creates is unreachable anyway. The unit spec left HOME pointing at
the developer's own home, where a real picgo lives in a fallback dir — had the
shell import regressed, the upload case would have handed a file to their
image host. And the new extraPathDirs expectations spelled the dirs with
forward slashes, which a Windows runner does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Jocs and others added 2 commits September 23, 2026 09:32
Its three common values — ~/.npm-global, /usr/local and /opt/homebrew — are
all dirs the lists it is concatenated onto already carry, so the result held
'/usr/local/bin' twice on a default npm setup. And npm leaves a prefix written
as ~/… unexpanded, which was then appended to PATH as a relative dir the
process cannot look in; the login-shell import already filtered those out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fallback dirs were probed with pathExistsSync, which is true for a
directory bearing the command's name and for a file without the executable
bit. A PATH lookup answers "no such command" for both, so the check could
pass and the spawn then fail with EISDIR/EACCES — the very disagreement
between the check and the upload that this branch set out to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Jocs Jocs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Self-review of the final state at 80465f4f. Flagging one thing up front for anyone else reading: gh pr diff currently serves a stale patch for this PR that predates the isRunnable executability check, so review notes written against that output are out of date.

Thirteen inline comments below, roughly in severity order: two that undercut the stated goal of the PR, four robustness holes in the new shell import (two of them confirmed by reproduction, not just by reading), one input-validation gap, two design/dead-code items, and three test issues.

One finding with no line to attach to

packages/desktop/src/main/utils/pandoc.ts is not in this diff, and that is the problem. The PR says it also serves #2751, but pandoc.exists() is synchronous and never awaits ensureShellEnvPath(), so pandoc discovery becomes time-dependent rather than fixed.

env.ts:76 fires ensureShellEnvPath() in the background at startup; it appends the login shell's dirs to process.env.PATH roughly half a second later. pandoc.exists() — called from menu/actions/file.ts:580, :793, :801 — does commandExists.sync('pandoc') with no await. A user whose pandoc lives under asdf or nvm (~/.asdf/shims/pandoc) who hits File → Import, or Export → Convert with Pandoc, inside that window gets "pandoc not found"; the identical action a second later succeeds.

envPath.ts:126 states the rule in its own doc comment — "Anything that looks for a user-installed command should await this first" — which this call site structurally cannot satisfy while it stays sync. There is currently no non-racy path for pandoc at all, so either pandoc.exists() needs an async variant, or the #2751 claim should come off the description.

Suggested order of work

envPath.ts:47 (PATH precedence) and the dropped /usr/local/lib/node_modules/.bin first, since they change behaviour users will hit; then the four shell-import robustness items; then a decision on who owns the bin-dir list. Of the test issues, picgo-discovery.spec.ts:68 is the urgent one — it has real side effects outside the test process.

One further hypothesis was tested during review and disproven, so it is not listed here.

// values. It can also be a literal `~/…` that nothing expanded, which is not
// a dir this process can look in.
const npmPrefix = env.npm_config_prefix ? [path.join(env.npm_config_prefix, 'bin')] : []
const dirs = [...system, ...userBinDirs(platform, env), ...npmPrefix]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Blocker — both PATH merges append, so the static guesses win over the user's real order.

extraPathDirs concatenates [...system, ...userBinDirs, ...npmPrefix] here, and appendToEnvPath only ever pushes, so /opt/homebrew/bin and /usr/local/bin always precede the user-level dirs — and the login shell's authoritative answer is appended even later (line 117). The result is that we resolve a different binary than the user's terminal does, which is the opposite of what this PR sets out to do.

Concretely: someone once ran npm i -g picgo under a Homebrew node (leaving a stale /opt/homebrew/bin/picgo whose node is gone), then reinstalled with pnpm add -g picgo into ~/Library/pnpm. Their login shell puts ~/Library/pnpm first, so picgo --version works in a terminal. Here, PATH ends up …:/opt/homebrew/bin:…:~/Library/pnpm:…, command -v picgo returns the stale shim, detection shows a green check, and every upload fails with an opaque node error.

The shell-imported dirs are the most authoritative thing we have and should be prepended; the static guesses are the least authoritative and should come last.

const userBinDirs = (platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] => {
const home = env.HOME
if (!home) return []
return [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Regression — /usr/local/lib/node_modules/.bin is gone.

Both of the candidate lists this PR deletes (in cmd.ts and uploader.ts) checked exactly this path via fs.pathExistsSync. It is absent from SYSTEM_BIN_DIRS / extraPathDirs.

For a user who installed with sudo npm i -g on a layout that materialises /usr/local/lib/node_modules/.bin/picgo, and whose login shell does not carry that dir: on develop, mt::cmd::exists('picgo') returned true and resolvePicgoBinary() returned that absolute path. After this PR neither commandExists.sync (dir isn't on PATH) nor the new dir scan covers it, so the panel says "not installed" and uploads reject with PicGo command not found in PATH.

Coverage shrinking is worth avoiding in a PR whose goal is to widen it.

const child = execFile(
shell,
['-ilc', `echo ${PATH_MARKER}; printenv PATH`],
{ timeout: SHELL_TIMEOUT, encoding: 'utf8' },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug (reproduced) — execFile has a timeout but no maxBuffer, so a chatty rc file silently discards the entire PATH import.

Verified by reproduction: with a startup file emitting ~1.2 MB, execFile errors with stdout maxBuffer length exceeded, stdout is truncated at exactly 1048576 bytes, and because echo MARKER; printenv PATH runs after everything the rc files print, the marker is gone. The backwards scan then yields marker === -1 and readLoginShellPath resolves [] — with no log line. A long motd, a fortune/figlet banner, or a verbose tool bootstrap is enough.

Two fixes needed here:

  1. Pass an explicit maxBuffer (and log when it is hit).
  2. The if (error && !stdout) guard on line 94 lets a partially truncated run be parsed, which can append a half-written directory to PATH. It should bail on any error unless the output is provably complete (see the closing-sentinel comment below).

}
}
if (marker < 0) return resolve([])
const reported = lines.slice(marker + 1).find((line) => line.trim().length > 0) ?? ''

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug — the marker parser takes the first non-empty line after the marker, with no closing sentinel.

Line 91 requests an interactive shell (-i), which means per-command hooks run: powerlevel10k's instant prompt, iTerm2/VS Code shell integration, a user precmd/preexec that prints anything. That output lands between echo __MARKTEXT_PATH__ and printenv PATH, so lines.slice(marker + 1).find(non-empty) returns the hook's line, split(':').filter(isAbsolute) yields [] (or one nonsense dir), and the import silently does nothing.

The sting is that this fails for exactly the users whose shell setup is elaborate enough to need the fix in the first place.

Printing a second marker after printenv PATH and taking what's strictly between the two makes this unambiguous, and also gives line 94 a reliable completeness check.

* Anything that looks for a user-installed command should await this first.
*/
export const ensureShellEnvPath = (): Promise<void> => {
shellEnvPath ??= importShellEnvPath()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Bug — failures are memoised, so one bad launch disables command discovery for the whole session.

shellEnvPath ??= importShellEnvPath() caches the promise regardless of outcome. If the 5s SHELL_TIMEOUT is hit because the machine was busy at launch (cold start, Spotlight indexing, slow nvm/conda init), or the rc file was momentarily broken, or the maxBuffer case above fired, the resolved-empty promise is returned forever.

The preferences panel re-polls mt::cmd::exists every 30s (uploader/index.vue, scheduleNextDetection) and can never recover: the user stares at "your system does not have picgo installed" for the entire session, when a restart would fix it.

Resetting shellEnvPath = null on a failed or empty result lets a later caller retry.


// Windows GUI apps start with the user's own PATH, so a miss there is a real
// miss; `extraPathDirs` is empty for it anyway.
for (const dir of extraPathDirs(process.platform, process.env)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cleanup — this fallback scan is unreachable in the running app.

setupEnvironment calls patchEnvPath() (env.ts:72), which appends exactly extraPathDirs(process.platform, process.env) to process.env.PATH. commandExists.sync on line 33 then runs command -v <name> in a child that inherits that PATH, and command -v accepts precisely the executable regular files that the new isRunnable accepts. So once line 33 has returned nothing, lines 41-44 cannot return non-null — in the main process they never fire. They only do work in specs that skip patchEnvPath (picgo-discovery.spec.ts, resolve-command.spec.ts).

The cost is two mechanisms owning the same directory list, plus a stat of 12-13 dirs on every negative answer — on a handler the preferences panel polls three times per 30s cycle.

Worth picking one owner: either patchEnvPath owns the dirs and resolveCommandPath is purely a PATH lookup, or resolveCommandPath owns the search and patchEnvPath stops mutating PATH.

Comment thread packages/desktop/src/main/app/env.ts Outdated
// Started here rather than on first use so the half-second the login shell
// takes is spent while the window is still opening; whoever needs it awaits
// this same run.
ensureShellEnvPath().catch(() => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Design — this eagerly runs the user's interactive login shell on every launch, before --safe is even parsed.

Both consumers already await ensureShellEnvPath() (cmd.ts:8, uploader.ts:155), so the eager start buys nothing for correctness — only pre-warming. What it costs is executing $SHELL -ilc with the user's full interactive+login startup path on every single launch: ssh-agent/gpg-agent spawns, direnv hooks evaluated against cwd (/ for a Dock launch), history-file writes, ~/.bash_logout on exit, plus the orphaned background job noted in envPath.ts.

It also runs before safeMode = !!args['--safe'] is computed on line 86 — so --safe, whose whole purpose is to avoid user configuration, cannot suppress it.

Dropping the eager call, or gating it on safeMode and on the uploader/pandoc features actually being reachable, keeps the fix without the unconditional side effect.

// A developer running this has a real picgo under their own HOME, which the
// static fallback dirs would find — and then the upload test would hand a
// file to their real image host. The stand-in must be the only one reachable.
process.env.HOME = tmpDir

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Test hazard — this can execute the developer's real picgo and upload the fixture to their live image host.

The comment just above states the intent: "The stand-in must be the only one reachable." HOME=tmpDir removes the ~/... dirs, but the SYSTEM_BIN_DIRS half of extraPathDirs (/opt/homebrew/bin, /usr/local/bin) is HOME-independent and is still scanned by resolveCommandPath's fallback.

So whenever the fake login shell doesn't deliver — a runner where /bin/sh can't exec the temp script, a sandbox that blocks child spawns, either of the maxBuffer/marker failures above — commandExists.sync('picgo') returns false and the loop finds the developer's Homebrew picgo instead. The "uploads with the very same picgo the check found" test then execFiles a real picgo with a real PNG against whatever remote they have configured.

Pointing the spec at a name no machine can plausibly have — as the e2e spec already does with mt-e2e-uploader-5518 — removes the hazard outright.

{ mode: 0o755 }
)

// PATH is left as the launcher gave it: binDir is a fresh temp dir, so the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Test gap — the app under test inherits the runner's full PATH, so the reported scenario is never exercised.

helpers.ts:76-79 copies every entry of process.env into the child env and then Object.assigns options.env over it. The app therefore receives the developer's login-shell PATH (~/Library/pnpm, nvm shims and all) with only SHELL replaced. The comment here — "PATH is left as the launcher gave it" — is true only in the trivial sense that the launcher in CI is a terminal.

The regression #5518 actually describes is a reduced PATH containing little more than the /etc/paths entries, and that is not reproduced. A future change that re-breaks discovery under a minimal PATH while still working under a rich one passes this suite.

Passing an explicit minimal PATH alongside SHELL in options.env would make the test match its own comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Test bug — this writes the literal string 'undefined' when PATH was unset.

originalPath is string | undefined, and plain assignment coerces undefined to 'undefined' rather than unsetting the variable. A run where PATH was absent leaves every later spec in that worker with PATH='undefined', failing all command lookups.

The adjacent if (originalHome === undefined) delete process.env.HOME is the intended pattern — PATH should use it too. Same line appears at picgo-discovery.spec.ts:78.

Jocs and others added 8 commits September 23, 2026 10:39
picgo-discovery re-created uploader-temp-file's harness almost line for line,
the fake login shell was written three times, and setPlatform and the
undefined-vs-assign env restore twice each. They move into test/unit/, which
the spec glob does not collect. The duplicate "command nobody installed"
assertion goes with them — resolve-command covers it directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The empty-list guard was written at both call sites rather than in
appendToEnvPath, where it belongs and where the win32 byte-identity assertion
actually needs it. The marker scan hand-rolled a reverse loop and a mutable
index for what split does in one line. And the memo carried a null the ??=
never distinguished from undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontract

Callers had to remember to await ensureShellEnvPath() before asking, with
nothing but a doc line to say so — which is the shape the bug had in the first
place: two call sites each remembering to consult the same list, until one
stopped. resolveCommand now awaits it itself, and only once PATH as it stands
has already missed, so the common case still costs nothing. uploadByCli awaits
it too: the script is the user's own and may call tools only their shell can
reach.

Three dead branches go with it. commandExists.sync never throws (it catches
internally and returns false), `where` already expands PATHEXT so the extra
`.exe` probe could only ever be a guaranteed-miss child process, and fs-extra
was imported for three plain fs calls. One real hole surfaced while removing
them: commandExists.sync('') answers true, and that name arrives from the
renderer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rches

The name arrives over IPC from the renderer and was path.joined into each
fallback dir unchecked, so '../../bin/sh' resolved to a real executable
outside every dir searched — while the docstring promises the answer sits
inside one of them. command-exists guards its own shell call this way; the
fallback loop had no equivalent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolveCommand awaits the import itself now, so the eager kick bought only
pre-warming — while costing an interactive login shell on every start, for
every user, most of whom never touch an uploader. Measured here: 650ms with
-i against 21ms without, and routinely seconds on an oh-my-zsh/p10k setup.

It also ran before --safe was parsed, so the one flag whose purpose is to
avoid user configuration could not suppress it. pandoc keeps what it had:
patchEnvPath is synchronous and still runs at startup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rvive real startup files

Six things the self-review turned up, all in how the login shell is read and
merged:

- Both merges appended, so static guesses outranked the shell's own order. A
  stale /opt/homebrew/bin/picgo shadowing a working ~/Library/pnpm/picgo gave
  a green check and an upload that failed. The shell's answer is the most
  authoritative thing we have and now goes in front.
- /usr/local/lib/node_modules/.bin was carried by both lists this branch
  deleted and by neither that replaced them — coverage shrank in a change
  meant to widen it.
- Keying the user dirs off the system table left FreeBSD/OpenBSD/SunOS with no
  fallback at all while still paying for the shell spawn.
- One marker let a per-command hook (p10k's instant prompt, shell integration)
  print between it and the PATH and be parsed as the PATH. Fencing the value
  between two markers also proves the output was not cut short.
- No maxBuffer meant a chatty rc file silently discarded the whole import, and
  a partial read could append half a directory.
- The timeout signalled the shell alone, so anything its rc backgrounded
  outlived the app, once per launch. spawn with detached lets the timeout take
  the group; seeing the closing marker finishes early instead of waiting the
  job out.

And a failed read is no longer memoised: a shell that timed out because the
machine was busy at launch would otherwise have stranded discovery for the
whole session, while the preferences panel re-asked every 30s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The e2e let launchElectron copy the runner's whole environment and overrode
only SHELL, so the app ran with the developer's login-shell PATH and the
reduced-PATH situation the issue describes was never reproduced — a change
that re-broke discovery under a minimal PATH would have passed.

picgo-discovery pointed HOME at a temp dir to keep the developer's own picgo
out of reach, but the system half of the fallback dirs ignores HOME: whenever
the fake shell did not deliver, the upload case would hand a real PNG to
whatever image host they have configured.

And three specs restored PATH by plain assignment, which writes the string
'undefined' when it was unset and fails every later lookup in that worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The #5518 work ended up narrating every decision — scenario walkthroughs,
restatements of the line below, JSDoc that described the implementation. That
buries the code it was meant to help with. What stays is the issue numbers and
the reasons that are not visible from the code: why printenv rather than
echo $PATH, why the value is fenced, why the shell gets its own process group,
why HOME alone does not isolate the upload spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs merged commit 051d95d into develop Sep 23, 2026
11 checks passed
@Jocs
Jocs deleted the fix/picgo-login-shell-path-5518 branch September 23, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] picgo installed but not detected and uploads fail: a GUI-launched app never sees the login-shell PATH

1 participant