fix(launcher): load macOS login shell PATH for GUI launches

This commit is contained in:
2026-09-24 01:10:15 -07:00
parent f7230db90c
commit da5074761f
5 changed files with 153 additions and 178 deletions
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyLoginShellPath, mergePathValues, readLoginShellPath } from './login-shell-path';
const wrap = (value: string) =>
`motd noise\n__SUBMINER_LOGIN_PATH__${value}\n__SUBMINER_LOGIN_PATH__`;
test('readLoginShellPath extracts PATH between markers, ignoring rc-file output', async () => {
let invoked: { shell: string; args: string[] } | null = null;
const value = await readLoginShellPath({
env: { SHELL: '/bin/zsh' },
runShell: async (shell, args) => {
invoked = { shell, args };
return wrap('/Users/me/.local/bin:/usr/bin');
},
});
assert.equal(value, '/Users/me/.local/bin:/usr/bin');
assert.equal(invoked!.shell, '/bin/zsh');
assert.equal(invoked!.args[0], '-ilc');
});
test('readLoginShellPath returns null when markers are missing', async () => {
const value = await readLoginShellPath({ env: {}, shell: '/bin/sh', runShell: async () => '' });
assert.equal(value, null);
});
test('mergePathValues puts login entries first and keeps process-only entries', () => {
assert.equal(
mergePathValues('/Users/me/.local/bin:/usr/bin:/bin', '/usr/bin:/bin:/usr/sbin:/sbin'),
'/Users/me/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
);
});
test('applyLoginShellPath updates env.PATH so launcher detection sees shell dirs', async () => {
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin' };
const applied = await applyLoginShellPath({
env,
shell: '/bin/zsh',
runShell: async () => wrap('/Users/me/.local/bin:/usr/bin'),
});
assert.equal(applied, true);
assert.equal(env.PATH, '/Users/me/.local/bin:/usr/bin:/bin');
});
+69
View File
@@ -0,0 +1,69 @@
import { execFile } from 'node:child_process';
import os from 'node:os';
const MARKER = '__SUBMINER_LOGIN_PATH__';
const DEFAULT_TIMEOUT_MS = 5000;
export type RunShell = (shell: string, args: string[], timeoutMs: number) => Promise<string>;
type LoginShellPathOptions = {
env: NodeJS.ProcessEnv;
shell?: string;
runShell?: RunShell;
timeoutMs?: number;
};
const runShellDefault: RunShell = (shell, args, timeoutMs) =>
new Promise((resolve, reject) => {
const child = execFile(
shell,
args,
{
timeout: timeoutMs,
encoding: 'utf8',
env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' },
},
(error, stdout) => (error ? reject(error) : resolve(stdout)),
);
child.stdin?.end();
});
function defaultShell(env: NodeJS.ProcessEnv): string {
if (env.SHELL) return env.SHELL;
try {
return os.userInfo().shell || '/bin/zsh';
} catch {
return '/bin/zsh';
}
}
/**
* Reads PATH as the user's interactive login shell sees it. macOS GUI apps inherit
* launchd's minimal PATH, so dirs added in ~/.zshrc and friends are otherwise invisible.
*/
export async function readLoginShellPath(options: LoginShellPathOptions): Promise<string | null> {
const shell = options.shell ?? defaultShell(options.env);
const run = options.runShell ?? runShellDefault;
// printenv keeps this shell-agnostic (fish exposes $PATH as a list).
const command = `printf '%s' '${MARKER}'; /usr/bin/printenv PATH; printf '%s' '${MARKER}'`;
const stdout = await run(shell, ['-ilc', command], options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const value = stdout.split(MARKER)[1]?.trim();
return value || null;
}
/** Puts login-shell entries first (terminal precedence), keeping any process-only entries after. */
export function mergePathValues(loginPath: string, currentPath: string | undefined): string {
const merged: string[] = [];
for (const entry of [...loginPath.split(':'), ...(currentPath ?? '').split(':')]) {
if (entry && !merged.includes(entry)) merged.push(entry);
}
return merged.join(':');
}
/** Merges the login-shell PATH into `options.env.PATH`. Resolves false when it couldn't be read. */
export async function applyLoginShellPath(options: LoginShellPathOptions): Promise<boolean> {
const loginPath = await readLoginShellPath(options);
if (!loginPath) return false;
options.env.PATH = mergePathValues(loginPath, options.env.PATH);
return true;
}