Skip to content

Commit 8243c5c

Browse files
CoderCococlaude
andauthored
feat(desktop-main): fix-path on boot to inherit login-shell PATH (#225)
Closes #148 ## Summary - Adds `fix-path ^4.0.0` as a runtime dependency of `@hyveon/desktop-main` - Creates `fix-path-bootstrap.ts` — a thin wrapper that calls `fix-path` on macOS/Linux and is a no-op on Windows; `process.platform` is read at call-time so tests can stub it cleanly - Calls `applyFixPath()` as the very first executable statement in `main.ts`, before the Electron-version guard and `bootstrap()`, ensuring PATH is fixed before any `child_process.spawn` for terraform/aws - Adds 3 unit tests in `fix-path-bootstrap.test.ts` (darwin calls fix-path, linux calls fix-path, win32 skips) and extends `main.test.ts` with a spy asserting `applyFixPath` is invoked during bootstrap (500 tests passing) ## Implementation notes - `fix-path` v4 is pure ESM, compatible with `desktop-main`'s `"type": "module"` and the ESNext module target in `tsconfig.base.json` - The indirection through `fix-path-bootstrap.ts` is required so `main.test.ts` can intercept the module boundary with `vi.mock` — a top-level ESM side-effect in `main.ts` itself would race the test setup - The issue acceptance criterion "integration test asserts `process.env.PATH` includes `/opt/homebrew/bin` on a fixture machine" is deferred to a future issue — the Electron e2e tier (Epic F, #135) does not exist yet. The unit tests here prove the wiring is correct; a real fixture test can be added once the Playwright Electron tier lands. ## Test plan - [ ] `npm run app:test` — 500 tests pass - [ ] `npm run app:lint` — 0 errors - [ ] On macOS with Homebrew: packaged app can find `terraform` on PATH (manual smoke test) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b491962 commit 8243c5c

6 files changed

Lines changed: 260 additions & 10 deletions

File tree

app/packages/desktop-main/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@nestjs/microservices": "^10.4.0",
2424
"@nestjs/platform-express": "^10.4.0",
2525
"express": "^4.19.2",
26+
"fix-path": "^4.0.0",
2627
"nestjs-electron-ipc-transport": "^1.0.2",
2728
"reflect-metadata": "^0.2.2",
2829
"rxjs": "^7.8.1",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
3+
/*
4+
* Hoist the mock spy so it is available when vi.mock() factory runs (vi.mock
5+
* calls are hoisted above regular declarations in compiled output).
6+
*/
7+
const { fixPathMock } = vi.hoisted(() => {
8+
/** Spy standing in for fix-path's default export (the shell-spawning function). */
9+
const fixPathMock = vi.fn();
10+
return { fixPathMock };
11+
});
12+
13+
/**
14+
* Mock the fix-path ESM module so no real login shell is ever spawned during
15+
* tests. Its default export is replaced with a vi.fn() we can assert on.
16+
*/
17+
vi.mock('fix-path', () => ({
18+
default: fixPathMock,
19+
}));
20+
21+
// Import the module under test AFTER mocks are registered.
22+
import { applyFixPath } from './fix-path-bootstrap.js';
23+
24+
describe('applyFixPath', () => {
25+
afterEach(() => {
26+
vi.unstubAllGlobals();
27+
vi.clearAllMocks();
28+
});
29+
30+
it('should call fix-path when platform is darwin', () => {
31+
vi.stubGlobal('process', { ...process, platform: 'darwin' });
32+
33+
applyFixPath();
34+
35+
expect(vi.mocked(fixPathMock)).toHaveBeenCalledOnce();
36+
});
37+
38+
it('should call fix-path when platform is linux', () => {
39+
vi.stubGlobal('process', { ...process, platform: 'linux' });
40+
41+
applyFixPath();
42+
43+
expect(vi.mocked(fixPathMock)).toHaveBeenCalledOnce();
44+
});
45+
46+
it('should NOT call fix-path when platform is win32', () => {
47+
vi.stubGlobal('process', { ...process, platform: 'win32' });
48+
49+
applyFixPath();
50+
51+
expect(vi.mocked(fixPathMock)).not.toHaveBeenCalled();
52+
});
53+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import fixPath from 'fix-path';
2+
3+
/**
4+
* Applies the fix-path workaround for GUI applications on macOS and Linux.
5+
*
6+
* When an application is launched from a graphical environment (e.g. a dock,
7+
* file manager, or Electron shell) rather than a terminal, the process does not
8+
* inherit the user's login shell PATH. This means executables that are only
9+
* available after sourcing `~/.profile`, `~/.bashrc`, `~/.zshrc`, etc. — such
10+
* as `node`, `aws`, or any tool installed via nvm/homebrew — will not be found.
11+
*
12+
* fix-path resolves this by spawning a login shell, reading its PATH, and
13+
* applying it to the current process before any child processes are started.
14+
*
15+
* This function is a no-op on Windows, where the problem does not apply.
16+
*/
17+
export function applyFixPath(): void {
18+
if (process.platform === 'win32') {
19+
return;
20+
}
21+
fixPath();
22+
}

app/packages/desktop-main/src/main.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
66
* vi.mock() factory functions run (vi.mock calls are hoisted to the top of the
77
* compiled output, above regular const/let declarations).
88
*/
9-
const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock } = vi.hoisted(() => {
9+
const { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock } = vi.hoisted(() => {
1010
/** Fake NestJS microservice app returned by `NestFactory.createMicroservice`. */
1111
const fakeApp = { listen: vi.fn().mockResolvedValue(undefined) };
1212
/** Spy constructor for ElectronIPCTransport — tracks `new` invocations. */
1313
const ElectronIPCTransportMock = vi.fn().mockImplementation(() => ({}));
1414
/** Spy for `NestFactory.createMicroservice`. */
1515
const createMicroserviceMock = vi.fn().mockResolvedValue(fakeApp);
16-
return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock };
16+
/** Spy for `applyFixPath` — verifies the fix-path bootstrap is called during startup. */
17+
const applyFixPathMock = vi.fn();
18+
return { ElectronIPCTransportMock, fakeApp, createMicroserviceMock, applyFixPathMock };
1719
});
1820

1921
vi.mock('nestjs-electron-ipc-transport', () => ({
@@ -35,6 +37,10 @@ vi.mock('./app.module.js', () => ({
3537
AppModule: class AppModule {},
3638
}));
3739

40+
vi.mock('./fix-path-bootstrap.js', () => ({
41+
applyFixPath: applyFixPathMock,
42+
}));
43+
3844
describe('main bootstrap', () => {
3945
beforeEach(() => {
4046
/*
@@ -45,6 +51,7 @@ describe('main bootstrap', () => {
4551
ElectronIPCTransportMock.mockImplementation(() => ({}));
4652
createMicroserviceMock.mockResolvedValue(fakeApp);
4753
fakeApp.listen.mockResolvedValue(undefined);
54+
applyFixPathMock.mockImplementation(() => undefined);
4855
// Simulate an Electron main-process environment so the module-level guard passes.
4956
vi.stubGlobal('process', { ...process, versions: { ...process.versions, electron: '36.0.0' } });
5057
});
@@ -92,4 +99,15 @@ describe('main bootstrap', () => {
9299
// listen() should have been called on the fake app.
93100
expect(fakeApp.listen).toHaveBeenCalledOnce();
94101
});
102+
103+
it('should call applyFixPath during bootstrap', async () => {
104+
vi.resetModules();
105+
await import('./main.js');
106+
107+
// Flush the event loop so the async bootstrap chain fully resolves.
108+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
109+
110+
// applyFixPath must be invoked exactly once before NestFactory.createMicroservice.
111+
expect(applyFixPathMock).toHaveBeenCalledTimes(1);
112+
});
95113
});

app/packages/desktop-main/src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { NestFactory } from '@nestjs/core';
33
import { MicroserviceOptions } from '@nestjs/microservices';
44
import { ElectronIPCTransport } from 'nestjs-electron-ipc-transport';
55
import { AppModule } from './app.module.js';
6+
import { applyFixPath } from './fix-path-bootstrap.js';
7+
8+
applyFixPath();
69

710
// ElectronIPCTransport requires ipcMain, which is only available inside an
811
// Electron main process. Fail fast with a readable message rather than a

0 commit comments

Comments
 (0)