diff --git a/desktop/src/core/dockerPath.test.ts b/desktop/src/core/dockerPath.test.ts new file mode 100644 index 00000000..0e73b4a7 --- /dev/null +++ b/desktop/src/core/dockerPath.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { dockerSearchPath, DOCKER_BIN_DIRS } from './dockerPath' + +describe('dockerSearchPath', () => { + it('prepends the docker bin dirs to the inherited PATH', () => { + const out = dockerSearchPath('/usr/bin:/bin') + expect(out.split(':').slice(0, DOCKER_BIN_DIRS.length)).toEqual(DOCKER_BIN_DIRS) + expect(out.endsWith('/usr/bin:/bin')).toBe(true) + }) + + it('de-duplicates dirs already present in the inherited PATH', () => { + const out = dockerSearchPath('/usr/local/bin:/usr/bin') + expect(out.split(':').filter((d) => d === '/usr/local/bin')).toHaveLength(1) + }) + + it('handles an empty/undefined inherited PATH (GUI-launch minimal env)', () => { + expect(dockerSearchPath('')).toBe(DOCKER_BIN_DIRS.join(':')) + expect(dockerSearchPath()).toBe(DOCKER_BIN_DIRS.join(':')) + }) + + it('drops empty segments', () => { + expect(dockerSearchPath('::/usr/bin:').split(':')).not.toContain('') + }) +}) diff --git a/desktop/src/core/dockerPath.ts b/desktop/src/core/dockerPath.ts new file mode 100644 index 00000000..d0d1aae1 --- /dev/null +++ b/desktop/src/core/dockerPath.ts @@ -0,0 +1,28 @@ +// A macOS app launched from Finder/Applications inherits a minimal PATH +// (/usr/bin:/bin:/usr/sbin:/sbin) that omits the directories where the `docker` +// CLI actually lives — so a bare `spawn('docker')` fails with ENOENT even when +// Docker Desktop is installed. We prepend the known docker bin dirs to PATH. + +/** Standard locations the `docker` CLI is installed to on macOS. */ +export const DOCKER_BIN_DIRS = [ + '/usr/local/bin', // Docker Desktop CLI symlink (Intel + Apple Silicon) + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/Applications/Docker.app/Contents/Resources/bin' // Docker Desktop bundled CLI +] + +/** + * Merge a base PATH with the known docker bin dirs. Extra dirs come first so a + * Finder-launched app can find `docker`; entries are de-duplicated, order preserved. + * Pure: the caller passes process.env.PATH. + */ +export function dockerSearchPath(currentPath = '', extraDirs: string[] = DOCKER_BIN_DIRS): string { + const seen = new Set() + const out: string[] = [] + for (const dir of [...extraDirs, ...currentPath.split(':')]) { + if (dir && !seen.has(dir)) { + seen.add(dir) + out.push(dir) + } + } + return out.join(':') +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 839dba6e..42d8043a 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -70,12 +70,15 @@ ipcMain.handle('wizard:complete', async (_e, input: WizardInput) => { inference: input.inference, adminEmail: input.adminEmail } - saveConfig(cfg) + // Write the .env (needed before startStack) but DON'T persist the config blob yet — + // only mark the wizard complete after the stack is healthy and the admin exists, so a + // failed first run re-shows the wizard instead of stranding a half-configured install. writeEnvFile(cfg) const b = base() await startStack(b, process.env) await waitHealthy(b) await runAdminFixture(b, input.adminEmail, input.adminPassword) + saveConfig(cfg) return { ok: true } } catch (err) { return { ok: false, error: String(err) } diff --git a/desktop/src/main/paths.ts b/desktop/src/main/paths.ts index b14480b1..1e92208f 100644 --- a/desktop/src/main/paths.ts +++ b/desktop/src/main/paths.ts @@ -19,4 +19,7 @@ export const composeFilePath = (): string => ? join(process.resourcesPath, 'docker-compose.release.yml') : join(app.getAppPath(), '..', 'docker-compose.release.yml') -export const PROJECT_NAME = 'donna' +// Distinct from the build-from-source / raw-lq-ai dev stacks (which use project "donna") +// so the launcher gets its OWN isolated volumes and never collides on volumes/ports. +// `-p` overrides the compose file's top-level `name:`. +export const PROJECT_NAME = 'donna-desktop' diff --git a/desktop/src/main/runner.ts b/desktop/src/main/runner.ts index a3465084..2faefb04 100644 --- a/desktop/src/main/runner.ts +++ b/desktop/src/main/runner.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { dockerSearchPath } from '../core/dockerPath' export interface RunResult { code: number @@ -6,10 +7,19 @@ export interface RunResult { stderr: string } +/** + * Env for spawning `docker`. A Finder-launched macOS app inherits a minimal PATH that + * omits /usr/local/bin (where Docker Desktop's CLI lives), so a bare spawn ENOENTs even + * with Docker installed. Augment PATH with the known docker bin dirs. + */ +function dockerSpawnEnv(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...process.env, ...extra, PATH: dockerSearchPath(process.env.PATH) } +} + /** Run `docker ` to completion, capturing output. Never throws on non-zero. */ export function runDocker(args: string[], env?: NodeJS.ProcessEnv): Promise { return new Promise((resolve) => { - const child = spawn('docker', args, { env: { ...process.env, ...env } }) + const child = spawn('docker', args, { env: dockerSpawnEnv(env) }) let stdout = '' let stderr = '' child.stdout.on('data', (d) => (stdout += d.toString())) @@ -21,9 +31,12 @@ export function runDocker(args: string[], env?: NodeJS.ProcessEnv): Promise` lines to a callback (for `logs -f`). Returns a kill fn. */ export function streamDocker(args: string[], onLine: (line: string) => void): () => void { - const child = spawn('docker', args) + const child = spawn('docker', args, { env: dockerSpawnEnv() }) const pump = (buf: Buffer) => buf.toString().split('\n').forEach((l) => l && onLine(l)) child.stdout.on('data', pump) child.stderr.on('data', pump) + // Best-effort: a spawn failure (e.g. docker not found, or the stack not up yet) must + // NOT crash the main process — the engine/stack state is reported via runDocker instead. + child.on('error', () => {}) return () => child.kill() }