fix(wizard): write PATH to .profile/.zshenv, never .bashrc
ci/woodpecker/pr/ci Pipeline was successful

getShellProfilePath() preferred ~/.bashrc when it existed, and ~/.zshrc for
zsh. setupPath() in stages/finalize.ts appends the PATH export to whatever
it returns. Debian's default ~/.bashrc opens with

    case $- in *i*) ;; *) return;; esac

so a line appended to the bottom of it never runs for 'bash -lc', for
systemd units, for 'ssh host cmd', or for any agent seat — precisely the
consumers that need the CLI. An install could print its summary and exit 0
while leaving 'mosaic: command not found'. .zshrc has the same problem:
zsh only reads it for interactive shells.

Now ~/.profile, which login shells read and which Debian's copy sources
.bashrc from for interactive shells, so one line covers both. For zsh the
always-sourced file is .zshenv. fish and PowerShell are unchanged.

__tests__/platform/detect.test.ts pins it, including a case asserting that
no shell resolves to an interactive-only rc file. Falsified by inverting
the fix: 5 failed / 1 passed; restored 6/6. Full package suite unchanged at
17 files / 4 tests failing, matching clean origin/next.
This commit is contained in:
2026-08-15 15:54:10 -05:00
parent cc0d24d5c4
commit d0c223bdf9
2 changed files with 82 additions and 6 deletions
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// homedir/platform are read at call time, so they can be stubbed per case.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => '/home/tester',
platform: () => mockPlatform,
};
});
let mockPlatform: NodeJS.Platform = 'linux';
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
describe('getShellProfilePath', () => {
const originalShell = process.env['SHELL'];
const originalZdotdir = process.env['ZDOTDIR'];
beforeEach(() => {
mockPlatform = 'linux';
delete process.env['ZDOTDIR'];
});
afterEach(() => {
if (originalShell === undefined) delete process.env['SHELL'];
else process.env['SHELL'] = originalShell;
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
else process.env['ZDOTDIR'] = originalZdotdir;
});
// The regression this guards: setupPath() in stages/finalize.ts appends the
// PATH export to whatever this returns. A line written to ~/.bashrc is
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
// default .bashrc returns early for non-interactive shells — so an install
// reported success and left `mosaic: command not found`. Same for .zshrc,
// which zsh only reads for interactive shells.
it('never targets an interactive-only rc file', () => {
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
process.env['SHELL'] = shell;
const profile = getShellProfilePath();
expect(profile).not.toMatch(/\.bashrc$/);
expect(profile).not.toMatch(/\.zshrc$/);
}
});
it('uses ~/.profile for bash', () => {
process.env['SHELL'] = '/bin/bash';
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('uses ~/.zshenv for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
});
it('honours ZDOTDIR for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
process.env['ZDOTDIR'] = '/custom/zdot';
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
});
it('falls back to ~/.profile for an unknown shell', () => {
process.env['SHELL'] = '/bin/somethingelse';
expect(detectShell()).toBe('unknown');
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('still routes fish to its own config', () => {
process.env['SHELL'] = '/usr/bin/fish';
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
});
});
+8 -6
View File
@@ -1,4 +1,3 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir, platform } from 'node:os'; import { homedir, platform } from 'node:os';
@@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null {
const shell = detectShell(); const shell = detectShell();
switch (shell) { switch (shell) {
// Both of these deliberately avoid the interactive-only rc files.
// Debian's default .bashrc returns early for non-interactive shells, so a
// PATH line appended to it never runs for `bash -lc`, systemd units, or
// agent seats — an install could report success and still leave `mosaic`
// unreachable. .profile is read by login shells and sources .bashrc for
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
case 'zsh': { case 'zsh': {
const zdotdir = process.env['ZDOTDIR'] ?? home; const zdotdir = process.env['ZDOTDIR'] ?? home;
return join(zdotdir, '.zshrc'); return join(zdotdir, '.zshenv');
} }
case 'bash': { case 'bash':
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile'); return join(home, '.profile');
}
case 'fish': case 'fish':
return join(home, '.config', 'fish', 'config.fish'); return join(home, '.config', 'fish', 'config.fish');
default: default: