Compare commits

...

3 Commits

Author SHA1 Message Date
Nikhil Sonti
a423852471 fix: address review feedback for PR #802 2026-04-23 17:08:26 -07:00
Nikhil Sonti
5712515bec fix: note vm cache sync duration 2026-04-23 17:06:54 -07:00
Nikhil Sonti
092cfc90e0 feat: improve dev watch lima preflights 2026-04-23 17:02:04 -07:00
7 changed files with 191 additions and 16 deletions

View File

@@ -32,6 +32,8 @@ export interface LimaShellProcess {
exited: Promise<number>
}
const LIMA_VERBOSE_LOGGING = false
export class LimaCli {
constructor(private readonly cfg: LimaCliConfig) {}
@@ -159,15 +161,18 @@ export class LimaCli {
limaHome: this.cfg.limaHome,
})
const stderrLogger = LIMA_VERBOSE_LOGGING
? (line: string) => {
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
pid: proc.pid,
firstArg: args[0],
line,
})
}
: undefined
const [stdout, stderr, exitCode] = await Promise.all([
drainToString(proc.stdout),
drainToString(proc.stderr, (line) => {
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
pid: proc.pid,
firstArg: args[0],
line,
})
}),
drainToString(proc.stderr, stderrLogger),
proc.exited,
])
const durationMs = Date.now() - started

View File

@@ -4,14 +4,23 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { accessSync, constants, existsSync } from 'node:fs'
import { homedir, arch as osArch } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import {
delimiter,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
export const VM_NAME = 'browseros-vm'
export const GUEST_VM_STATE = '/mnt/browseros/vm'
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
const HOST_LIMACTL_BINARY = 'limactl'
export type Arch = 'arm64' | 'x64'
@@ -88,7 +97,7 @@ export function decompressedDiskPath(
}
export function resolveBundledLimactl(resourcesDir: string): string {
if (usesHostVmTools()) return 'limactl'
if (usesHostVmTools()) return resolveHostLimactl()
const candidate = join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl')
if (!existsSync(candidate)) {
@@ -99,6 +108,14 @@ export function resolveBundledLimactl(resourcesDir: string): string {
return candidate
}
function resolveHostLimactl(): string {
const resolved = findExecutableOnPath(HOST_LIMACTL_BINARY)
if (resolved) return resolved
throw new Error(
'Lima is not installed or limactl is not on PATH. Install with brew install lima.',
)
}
export function resolveBundledLimaTemplate(resourcesDir: string): string {
if (usesHostVmTools()) {
const sourceTemplate = findSourceLimaTemplate(resourcesDir)
@@ -120,6 +137,20 @@ function usesHostVmTools(): boolean {
)
}
function findExecutableOnPath(binary: string): string | null {
const pathEnv = process.env.PATH
if (!pathEnv) return null
for (const dir of pathEnv.split(delimiter)) {
if (!dir) continue
const candidate = join(dir, binary)
try {
accessSync(candidate, constants.X_OK)
return candidate
} catch {}
}
return null
}
function findSourceLimaTemplate(resourcesDir: string): string | null {
let current = resolve(resourcesDir)
while (true) {

View File

@@ -15,8 +15,10 @@ import {
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { logger } from '../../../src/lib/logger'
import { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
import { LimaCli } from '../../../src/lib/vm/lima-cli'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
@@ -104,6 +106,23 @@ describe('LimaCli', () => {
expect(error.stderr).toBe('cannot start')
})
it('does not log limactl stderr chunks by default', async () => {
const debug = spyOn(logger, 'debug').mockImplementation(() => {})
const limactlPath = await fakeLimactl(
{ start: { stderr: 'boot noise\n' } },
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.start('browseros-vm')
expect(
debug.mock.calls.some(
([message]) => message === VM_TELEMETRY_EVENTS.limaStderrChunk,
),
).toBe(false)
})
it('stops and deletes VMs', async () => {
const limactlPath = await fakeLimactl({ stop: {}, delete: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })

View File

@@ -4,7 +4,7 @@
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
@@ -28,6 +28,7 @@ import {
describe('VM paths', () => {
const originalNodeEnv = process.env.NODE_ENV
const originalPath = process.env.PATH
afterEach(() => {
if (originalNodeEnv === undefined) {
@@ -35,6 +36,11 @@ describe('VM paths', () => {
} else {
process.env.NODE_ENV = originalNodeEnv
}
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('uses production VM directories below .browseros', () => {
@@ -129,16 +135,44 @@ describe('VM paths', () => {
}
})
it('uses PATH limactl in development mode', () => {
it('uses PATH limactl in development mode', async () => {
process.env.NODE_ENV = 'development'
const binDir = await createFakeLimactlPath()
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe('limactl')
try {
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe(
join(binDir, 'limactl'),
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('uses PATH limactl in test mode', () => {
it('uses PATH limactl in test mode', async () => {
process.env.NODE_ENV = 'test'
const binDir = await createFakeLimactlPath()
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe('limactl')
try {
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe(
join(binDir, 'limactl'),
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('throws with a brew install hint when host limactl is missing', async () => {
process.env.NODE_ENV = 'development'
const binDir = await mkdtemp(join(tmpdir(), 'missing-host-limactl-'))
process.env.PATH = binDir
try {
expect(() => resolveBundledLimactl('/tmp/missing-dev-resources')).toThrow(
'brew install lima',
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('throws with a build-tools hint when bundled limactl is missing', () => {
@@ -185,3 +219,12 @@ describe('VM paths', () => {
}
})
})
async function createFakeLimactlPath(): Promise<string> {
const binDir = await mkdtemp(join(tmpdir(), 'host-limactl-'))
const limactlPath = join(binDir, 'limactl')
await writeFile(limactlPath, '#!/bin/sh\n')
await chmod(limactlPath, 0o755)
process.env.PATH = binDir
return binDir
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
@@ -42,6 +43,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
if err := ensureDevCachePresent(); err != nil {
return err
}
if err := ensureLimactlPresent(); err != nil {
return err
}
defaultPorts := proc.DefaultLocalPorts()
p := defaultPorts
@@ -191,7 +195,20 @@ func ensureDevCachePresent() error {
}
manifestPath := filepath.Join(home, ".browseros-dev", "cache", "vm", "manifest.json")
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
return fmt.Errorf("VM cache missing at %s; run bun run dev:setup once", manifestPath)
return fmt.Errorf("%s %s",
proc.ErrorColor.Sprint("VM cache is missing."),
proc.DimColor.Sprintf("Run %s once.", proc.BoldColor.Sprint("bun run dev:setup")),
)
}
return nil
}
func ensureLimactlPresent() error {
if _, err := exec.LookPath("limactl"); err != nil {
return fmt.Errorf("%s %s",
proc.ErrorColor.Sprint("Lima is not installed."),
proc.DimColor.Sprintf("Install with %s.", proc.BoldColor.Sprint("brew install lima")),
)
}
return nil
}

View File

@@ -0,0 +1,59 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureDevCachePresentMissingMessage(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
err := ensureDevCachePresent()
if err == nil {
t.Fatal("expected missing cache error")
}
msg := err.Error()
if !strings.Contains(msg, "VM cache is missing.") {
t.Fatalf("expected missing cache message, got %q", msg)
}
if strings.Count(msg, "dev:setup") != 1 {
t.Fatalf("expected dev:setup once, got %q", msg)
}
if strings.Contains(msg, home) {
t.Fatalf("expected cache path to be hidden, got %q", msg)
}
}
func TestEnsureLimactlPresentMissingMessage(t *testing.T) {
t.Setenv("PATH", t.TempDir())
err := ensureLimactlPresent()
if err == nil {
t.Fatal("expected missing Lima error")
}
msg := err.Error()
if !strings.Contains(msg, "Lima is not installed.") {
t.Fatalf("expected missing Lima message, got %q", msg)
}
if !strings.Contains(msg, "brew install lima") {
t.Fatalf("expected brew install hint, got %q", msg)
}
}
func TestEnsureLimactlPresentFindsPathBinary(t *testing.T) {
binDir := t.TempDir()
limactlPath := filepath.Join(binDir, "limactl")
if err := os.WriteFile(limactlPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir)
if err := ensureLimactlPresent(); err != nil {
t.Fatalf("expected limactl to resolve, got %v", err)
}
}

View File

@@ -13,6 +13,7 @@ echo "[setup] Generating agent code..."
bun run codegen:agent
echo "[setup] Syncing VM cache..."
printf '\033[31m[setup] First VM cache sync can take about 5 minutes.\033[0m\n'
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
echo "[setup] Ready"