From d0c223bdf9f56a362aef8d02d9a569057fbcab8e Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 15 Aug 2026 15:54:10 -0500 Subject: [PATCH] fix(wizard): write PATH to .profile/.zshenv, never .bashrc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../mosaic/__tests__/platform/detect.test.ts | 74 +++++++++++++++++++ packages/mosaic/src/platform/detect.ts | 14 ++-- 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 packages/mosaic/__tests__/platform/detect.test.ts diff --git a/packages/mosaic/__tests__/platform/detect.test.ts b/packages/mosaic/__tests__/platform/detect.test.ts new file mode 100644 index 00000000..540f0e8a --- /dev/null +++ b/packages/mosaic/__tests__/platform/detect.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/mosaic/src/platform/detect.ts b/packages/mosaic/src/platform/detect.ts index 59f48173..4abb3129 100644 --- a/packages/mosaic/src/platform/detect.ts +++ b/packages/mosaic/src/platform/detect.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, platform } from 'node:os'; @@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null { const shell = detectShell(); 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': { const zdotdir = process.env['ZDOTDIR'] ?? home; - return join(zdotdir, '.zshrc'); + return join(zdotdir, '.zshenv'); } - case 'bash': { - const bashrc = join(home, '.bashrc'); - if (existsSync(bashrc)) return bashrc; + case 'bash': return join(home, '.profile'); - } case 'fish': return join(home, '.config', 'fish', 'config.fish'); default: