From 4a5393cbbd158db055051e244c0d000ddcbcdbf7 Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Fri, 30 Oct 2026 22:46:24 -0300 Subject: [PATCH 1/2] fix(cli): the preview installs when a declared dependency is missing from node_modules A scratch Box whose agent added expo after an earlier install had a node_modules/ without it; the pre-flight trusted the folder and 'npx expo start' died with ERR_SPAWN_FAILED ("the module `expo` is not installed"). Any declared dependency with no folder now triggers the install first. Co-Authored-By: Claude Fable 5.1 --- apps/cli/__tests__/preview-setup-deps.test.ts | 37 +++++++++++++++++++ apps/cli/src/services/preview/setup-deps.ts | 21 ++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 apps/cli/__tests__/preview-setup-deps.test.ts diff --git a/apps/cli/__tests__/preview-setup-deps.test.ts b/apps/cli/__tests__/preview-setup-deps.test.ts new file mode 100644 index 00000000..12d28856 --- /dev/null +++ b/apps/cli/__tests__/preview-setup-deps.test.ts @@ -0,0 +1,37 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { detectMissingNodeDeps, missingDeclaredDep } from '../src/services/preview/setup-deps'; + +function project(deps: Record, installed: string[]): string { + const dir = mkdtempSync(join(tmpdir(), 'codeam-preview-deps-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'app', dependencies: deps })); + for (const name of installed) { + mkdirSync(join(dir, 'node_modules', name), { recursive: true }); + writeFileSync(join(dir, 'node_modules', name, 'package.json'), '{}'); + } + return dir; +} + +describe('the preview installs when a declared dependency is missing (2026-10-31, expo not installed)', () => { + it('a node_modules/ built before `expo` was added still gets an install', () => { + const dir = project({ react: '19.0.0', expo: '~54.0.0' }, ['react']); + expect(missingDeclaredDep(dir)).toBe('expo'); + expect(detectMissingNodeDeps(dir)).toEqual({ + cmd: 'npm', + args: ['install', '--legacy-peer-deps', '--no-audit', '--no-fund'], + }); + }); + + it('every declared dependency present: no install', () => { + const dir = project({ react: '19.0.0', expo: '~54.0.0' }, ['react', 'expo']); + expect(missingDeclaredDep(dir)).toBeNull(); + expect(detectMissingNodeDeps(dir)).toBeNull(); + }); + + it('no node_modules at all installs, as before', () => { + const dir = project({ react: '19.0.0' }, []); + expect(detectMissingNodeDeps(dir)?.cmd).toBe('npm'); + }); +}); diff --git a/apps/cli/src/services/preview/setup-deps.ts b/apps/cli/src/services/preview/setup-deps.ts index 34a887e4..808bcc02 100644 --- a/apps/cli/src/services/preview/setup-deps.ts +++ b/apps/cli/src/services/preview/setup-deps.ts @@ -49,9 +49,28 @@ import path from 'path'; * Non-Node projects (no `package.json`) return null and let the * agent's `setup_commands` cover Python / Ruby / Go / Rust. */ +/** + * A declared dependency with no folder under node_modules/. A node_modules/ that exists but was + * built from another package.json (a scratch Box whose agent added `expo` after an earlier + * install, 2026-10-31: `npx expo start` → "the module `expo` is not installed") used to count + * as installed, and the preview died with ERR_SPAWN_FAILED instead of installing first. + */ +export function missingDeclaredDep(cwd: string): string | null { + let pkg: { dependencies?: Record; devDependencies?: Record }; + try { + pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')) as typeof pkg; + } catch { + return null; + } + for (const name of Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) })) { + if (!fs.existsSync(path.join(cwd, 'node_modules', name, 'package.json'))) return name; + } + return null; +} + export function detectMissingNodeDeps(cwd: string): { cmd: string; args: string[] } | null { if (!fs.existsSync(path.join(cwd, 'package.json'))) return null; - if (fs.existsSync(path.join(cwd, 'node_modules'))) return null; + if (fs.existsSync(path.join(cwd, 'node_modules')) && missingDeclaredDep(cwd) === null) return null; if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { return { cmd: 'yarn', args: ['install'] }; } From 966cbcc8c271abefa5f6431afb114f091418cdc1 Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Fri, 30 Oct 2026 22:47:21 -0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(box):=20Expo=20baked=20into=20the=20im?= =?UTF-8?q?age=20=E2=80=94=20the=20CLI=20and=20a=20current=20SDK's=20depen?= =?UTF-8?q?dency=20tree=20in=20the=20npm=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner 2026-10-31: the in-app Preview of an Expo project on a Box downloaded the Expo CLI at first use and then stalled on a project install. The image now ships `expo` globally and prewarms /opt/npm-cache with an Expo SDK app's dependencies (resolved by `expo install`). Co-Authored-By: Claude Fable 5.1 --- apps/box/Dockerfile | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/box/Dockerfile b/apps/box/Dockerfile index d0e26a15..c8271c23 100644 --- a/apps/box/Dockerfile +++ b/apps/box/Dockerfile @@ -315,6 +315,24 @@ RUN install -d -o box -g box /opt/npm-cache /opt/uv-cache /opt/uv-tools \ && (command -v uv >/dev/null 2>&1 && echo " OK $(uv --version)" || echo " MISS uv") \ && su box -s /bin/sh -c 'HOME=/home/box codeam mcp-warm' || true +# ── Expo, baked (owner 2026-10-31) ───────────────────────────────────────── +# The in-app Preview of an Expo project ran `npx expo start`, which first downloaded the +# Expo CLI ("npm warn exec … expo@58 … will be installed") and then needed the project's own +# `expo` package. Both are prepared here: the `expo` CLI is global, and the npm cache holds a +# current Expo SDK app's whole dependency tree (expo, react-native, react, react-dom, +# react-native-web, @expo/metro-runtime, expo-status-bar — resolved by `expo install`, so the +# versions match the SDK), so the project's `npm install` reads tarballs from disk. Same +# never-fails-the-build contract as mcp-warm: a MISS means a slower first preview, not a broken box. +RUN npm install -g expo \ + && chown -R box:box /usr/local/lib/node_modules /usr/local/bin \ + && su box -s /bin/sh -c 'set -e; HOME=/home/box; export HOME; d=$(mktemp -d); cd "$d"; \ + printf "{\"name\":\"expo-prewarm\",\"private\":true}" > package.json; \ + npm install --no-audit --no-fund --legacy-peer-deps expo@latest; \ + npx --no-install expo install react react-native react-dom react-native-web @expo/metro-runtime expo-status-bar; \ + cd /; rm -rf "$d"' \ + && echo "expo: OK $(expo --version)" \ + || echo "expo: MISS (the first Expo preview downloads it)" + # ── PATH: expose the curl-installer bin dirs under the runtime HOME=/home/box ─ ENV PATH="/opt/agents/.local/bin:/opt/agents/.kimi-code/bin:/opt/agents/bin:${PATH}"