Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/box/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
37 changes: 37 additions & 0 deletions apps/cli/__tests__/preview-setup-deps.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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');
});
});
21 changes: 20 additions & 1 deletion apps/cli/src/services/preview/setup-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; devDependencies?: Record<string, string> };
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'] };
}
Expand Down
Loading