Skip to content
Draft
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
89 changes: 89 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/install-bootstrap-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @ref llp/0010-agent-conventions.rfc.md §Forwarded codes, and the exception
//
// A fresh clone has no node_modules, so `expo install` cannot read the SDK. A bare `install` then
// runs the lockfile's frozen install, and a named package is refused until it has.
import fs from 'node:fs';
import path from 'node:path';

import {
executeAgentCliAsync,
getTemporaryPath,
installStubBinAsync,
pathEnvVars,
setupFixtureAsync,
stubExpoEnv,
} from '../utils';

const STUB_PACKAGE_MANAGER = `const fs = require('fs');
fs.appendFileSync(process.env.STUB_PM_LOG, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + '\\n');
process.exit(Number(process.env.STUB_PM_EXIT_CODE ?? 0));
`;

/** A go-app copy with no installed `expo`, and stub `npm` and `bun` bins that record each call. */
async function setupUninstalledProjectAsync(lockfile: string) {
const projectRoot = await setupFixtureAsync('go-app');
await fs.promises.rm(path.join(projectRoot, 'node_modules', 'expo'), { recursive: true });
await fs.promises.writeFile(path.join(projectRoot, lockfile), '');

const binDir = path.join(getTemporaryPath(), 'pm-bin');
const script = path.join(binDir, 'stub-pm.js');
await fs.promises.mkdir(binDir, { recursive: true });
await fs.promises.writeFile(script, STUB_PACKAGE_MANAGER);
for (const name of ['npm', 'bun']) {
await installStubBinAsync(binDir, name, script);
}
const log = path.join(binDir, 'calls.jsonl');
const env = {
...pathEnvVars(`${binDir}${path.delimiter}${stubExpoEnv(projectRoot).PATH}`),
STUB_PM_LOG: log,
};
const calls = (): { args: string[]; cwd: string }[] =>
fs.existsSync(log)
? fs
.readFileSync(log, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line))
: [];
return { projectRoot, env, calls };
}

describe('@expo/agent-cli install with no installed dependencies', () => {
it.each([
['package-lock.json', ['npm', 'ci']],
['bun.lock', ['bun', 'install', '--frozen-lockfile']],
])(`should install %s's dependencies on a bare install`, async (lockfile, argv) => {
const { projectRoot, env, calls } = await setupUninstalledProjectAsync(lockfile);

await executeAgentCliAsync(projectRoot, ['install'], { env });

expect(calls()).toEqual([{ args: argv.slice(1), cwd: fs.realpathSync(projectRoot) }]);
});

it(`should exit with the package manager's code when its install fails`, async () => {
const { projectRoot, env } = await setupUninstalledProjectAsync('package-lock.json');

const result = await executeAgentCliAsync(projectRoot, ['install'], {
env: { ...env, STUB_PM_EXIT_CODE: '3' },
reject: false,
});

expect(result.exitCode).toBe(3);
});

it(`should refuse a named package and name the bare install`, async () => {
const { projectRoot, env, calls } = await setupUninstalledProjectAsync('package-lock.json');

const result = await executeAgentCliAsync(projectRoot, ['install', 'expo-sqlite', '--json'], {
env,
reject: false,
});

expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout).error).toMatchObject({
code: 'DEPENDENCIES_NOT_INSTALLED',
suggestedCommand: 'npx @expo/agent-cli install',
});
expect(calls()).toEqual([]);
});
});
16 changes: 16 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/typecheck-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,22 @@ describe('@expo/agent-cli typecheck', () => {
expect(error.message).not.toContain('nothing to type-check');
});

it(`should suggest the bare install when package.json declares typescript and node_modules is empty`, async () => {
const projectRoot = await setupFixtureAsync('go-app');
await fs.promises.writeFile(path.join(projectRoot, 'tsconfig.json'), '{}');
const manifestPath = path.join(projectRoot, 'package.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.devDependencies = { ...manifest.devDependencies, typescript: '~5.9.0' };
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest));

const result = await executeAgentCliAsync(projectRoot, ['typecheck', '--json'], {
reject: false,
});

expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout).error.suggestedCommand).toBe('npx @expo/agent-cli install');
});

// A compiler that failed without reporting a diagnostic has not answered the question, so it is
// the tool failing and not a verdict on the code — exit 1, with the envelope.
it(`should exit 1 when the compiler failed without reporting anything`, async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/@expo/agent-cli/llp/0010-agent-conventions.rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ No-dev-server and wrong-platform stay `1`. Neither becomes different within a se

### Forwarded codes, and the exception

`install`, `start`, `dev`, and the `expo` passthrough hand back whatever the subprocess exited with, verbatim. A wrapper's own failures use the table.
`install`, `start`, `dev`, and the `expo` passthrough hand back whatever the subprocess exited with, verbatim. A wrapper's own failures use the table. A bare `install` in a project whose `expo` package does not resolve installs the lockfile first (`npm ci`, `bun install --frozen-lockfile`, `pnpm install --frozen-lockfile` or `yarn install`, in the lockfile's directory) and exits with its code.

The exception is a command that adds a verdict. `doctor` is `0` when every check passed, `20` when any failed, `1` only when the run produced no verdict ([[0016-v1-scope]] §Doctor's exit code). expo-doctor's own code stays on the `--json` `exitCode` field.

Expand All @@ -62,7 +62,7 @@ No `eas --json` payload can contain a `null`. `printJsonOnlyOutput` deletes ever

A project with no TypeScript exits `0`, with `checked: false`. `reason` is present exactly when `checked` is false. The follow-up says so in a command as well as in a field, so "nothing was checked" cannot read as "everything passed".

A TypeScript project with no compiler is `1` (`TYPECHECK_CLI_MISSING`), with `Try: npx @expo/agent-cli install typescript --dev`. Either `tsconfig.json` or `.ts`/`.tsx` sources makes it a TypeScript project. `.d.ts` files alone do not, since `expo-env.d.ts` is generated into every app.
A TypeScript project with no compiler is `1` (`TYPECHECK_CLI_MISSING`), with `Try: npx @expo/agent-cli install typescript --dev`. When package.json declares typescript, the Try line is the bare `install`. Either `tsconfig.json` or `.ts`/`.tsx` sources makes it a TypeScript project. `.d.ts` files alone do not, since `expo-env.d.ts` is generated into every app.

No compiler is ever fetched. `doctor:check` falls back to `npx expo-doctor`. This command does not. A type check is a function of the project's own compiler version, its `tsconfig.json`, and its `@types`. Only `node_modules/.bin/tsc` counts.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { vol } from 'memfs';
import path from 'path';

import * as Log from '../../log';
import type { InstallImpactReport } from '../../project/types';
import {
Expand Down Expand Up @@ -38,6 +41,11 @@ function printed(): string {
}

beforeEach(() => {
// A project whose dependencies are installed: `installAsync` runs `expo install` only then.
vol.reset();
vol.fromJSON({
[path.join(projectRoot, 'node_modules', 'expo', 'package.json')]: '{"name":"expo"}',
});
// `clearMocks` empties the call log but keeps implementations, so every mock whose *answer*
// matters is given its default back here — otherwise one test's answer leaks into the next.
vi.mocked(runExpoAsync).mockResolvedValue(0);
Expand Down
65 changes: 64 additions & 1 deletion packages/@expo/agent-cli/src/install/installAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import { buildInstallFollowUps, followUpsEnabled, reportFollowUps } from '../fol
import type { FollowUp } from '../followups/types';
import * as Log from '../log';
import type { InstallImpactReport } from '../project/types';
import { PROGRAM_PREFIX } from '../programName';
import {
autoSyncSkillsAsync,
listSkillPackagesAsync,
printSkillsForAgentAsync,
} from '../skills/skillsAsync';
import { resolvePackageRootSync } from '../project/nodeModules';
import { CommandError } from '../utils/errors';
import { runExpoAsync, spawnExpoAsync } from '../utils/expoCli';
import { runInheritedAsync } from '../utils/inheritedRun';
import { lockfileInstall } from '../utils/packageRunner';
import { spawnSubprocessAsync } from '../utils/subprocess';
import { diagnoseCheckedPackagesAsync, type InstallCheckReport } from './checkReport';
import { reportInstallImpactAsync } from './impactReport';
import type { InstallPlan } from './resolveOptions';
Expand All @@ -25,7 +31,16 @@ import type { InstallReport } from './types';
* @returns the exit code of the `expo install` subprocess.
*/
export async function installAsync(projectRoot: string, plan: InstallPlan): Promise<number> {
const { exitCode, checkPayload, checkOutput } = await runInstallAsync(projectRoot, plan);
const dependenciesMissing =
!plan.check &&
!plan.expoArgs.includes('--fix') &&
resolvePackageRootSync(projectRoot, 'expo') == null;
if (dependenciesMissing && plan.packages.length > 0) {
throw dependenciesNotInstalledError();
}
const { exitCode, checkPayload, checkOutput } = dependenciesMissing
? await runLockfileInstallAsync(projectRoot, plan)
: await runInstallAsync(projectRoot, plan);
if (exitCode !== 0) {
// Nothing was installed, so there is nothing to classify and nothing to link — but a caller
// that asked for JSON still gets one object, the way a successful run does.
Expand Down Expand Up @@ -64,6 +79,54 @@ export async function installAsync(projectRoot: string, plan: InstallPlan): Prom
return exitCode;
}

/**
* Install the lockfile's dependencies, for a project whose `expo` does not resolve yet.
*
* `expo install` cannot do this: the `expo` it would run is fetched by the package runner, and it
* cannot read the SDK of a project with no `node_modules`. The package manager's own frozen install
* can, so it runs, in the lockfile's directory. Output follows {@link runInstallAsync}: inherited
* for a person, captured and sent to stderr under `--json`.
*
* @ref llp/0010-agent-conventions.rfc.md §Forwarded codes, and the exception
*/
async function runLockfileInstallAsync(
projectRoot: string,
plan: InstallPlan
): Promise<{ exitCode: number; checkPayload: unknown; checkOutput: string | null }> {
const { lockfile, cwd, argv } = lockfileInstall(projectRoot);
const [command, ...args] = argv as [string, ...string[]];
const message = `Installing the dependencies of ${lockfile ?? 'package.json'} with ${argv.join(' ')}`;

if (!plan.json) {
Log.log(message);
return {
exitCode: await runInheritedAsync(command, args, { cwd }),
checkPayload: null,
checkOutput: null,
};
}
Log.error(message);
const result = await spawnSubprocessAsync(command, args, { cwd, output: 'capture' });
const printed = `${result.stdout}${result.stderr}`.trim();
if (printed) {
Log.error(printed);
}
return { exitCode: result.exitCode ?? 1, checkPayload: null, checkOutput: null };
}

function dependenciesNotInstalledError(): CommandError {
const error = new CommandError(
'DEPENDENCIES_NOT_INSTALLED',
[
`This project has no installed dependencies, so no package was added.`,
`Why: the expo package does not resolve from this project or any directory above it, and "expo install" cannot read the SDK of a project with no node_modules.`,
`How: run "${PROGRAM_PREFIX} install" with no packages first. It installs the lockfile. Then add the package.`,
].join('\n')
);
error.suggestedCommand = `${PROGRAM_PREFIX} install`;
return error;
}

/**
* Run `expo install`, and read the `--check` report out of it when there is one.
*
Expand Down
32 changes: 28 additions & 4 deletions packages/@expo/agent-cli/src/typecheck/checkAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import fs from 'fs';
import path from 'path';
import { stripVTControlCharacters } from 'util';

import {
listDependencyNames,
readJsonFileSync,
type ProjectPackageJson,
} from '../project/nodeModules';
import { PROGRAM_PREFIX } from '../programName';
import { fileExistsSync } from '../utils/dir';
import { CommandError } from '../utils/errors';
Expand Down Expand Up @@ -161,7 +166,11 @@ export async function runTypeCheckAsync(projectRoot: string): Promise<TypeCheckR
const tsConfigPath = resolveTsConfigPath(projectRoot);
if (!cli) {
if (tsConfigPath != null || hasTypeScriptSourcesSync(projectRoot)) {
throw compilerMissingError(tsConfigPath != null, lookupProjectBin(projectRoot, 'tsc'));
throw compilerMissingError(
tsConfigPath != null,
lookupProjectBin(projectRoot, 'tsc'),
declaresTypeScriptSync(projectRoot)
);
}
return nothingToCheck(
`this project has no TypeScript in it — no tsconfig.json and no .ts or .tsx files of its own — so there is nothing to type-check`
Expand Down Expand Up @@ -227,20 +236,35 @@ export async function runTypeCheckAsync(projectRoot: string): Promise<TypeCheckR
* @param hasTsConfig whether it was the `tsconfig.json` that identified this as a TypeScript
* project, or its `.ts` files — the two are different evidence and the reader should see theirs.
* @param lookup the search that came back empty, which is what says where it looked.
* @param declared whether package.json declares typescript. Then the compiler is missing because
* the dependencies are not installed, and the bare `install` fixes that without editing package.json.
*/
function compilerMissingError(hasTsConfig: boolean, lookup: ProjectBinLookup): CommandError {
function compilerMissingError(
hasTsConfig: boolean,
lookup: ProjectBinLookup,
declared: boolean
): CommandError {
const error = new CommandError(
'TYPECHECK_CLI_MISSING',
[
`This project is a TypeScript project with no TypeScript compiler installed, so nothing was type-checked.`,
`Why: ${hasTsConfig ? 'it has a tsconfig.json' : 'it has .ts or .tsx source files'}, but ${describeProjectBinSearch('tsc', lookup)} — so no copy of it is installed for this project, in its own node_modules or in a workspace's. Nothing here falls back to a compiler from the registry, because a type check is a function of the version this project pinned, its tsconfig.json and its @types — one fetched from elsewhere would answer a question about a project that does not exist.`,
`How: add TypeScript to this project with "${PROGRAM_PREFIX} install typescript --dev". If it is already in package.json, then its dependencies have not been installed since — run your package manager's install, at the workspace root when this is a workspace package.`,
declared
? `How: This project declares typescript, but its dependencies are not installed. Run "${PROGRAM_PREFIX} install" with no packages, which installs the lockfile and does not change package.json.`
: `How: add TypeScript to this project with "${PROGRAM_PREFIX} install typescript --dev". If it is already in package.json, then its dependencies have not been installed since — run your package manager's install, at the workspace root when this is a workspace package.`,
].join('\n')
);
error.suggestedCommand = `${PROGRAM_PREFIX} install typescript --dev`;
error.suggestedCommand = declared
? `${PROGRAM_PREFIX} install`
: `${PROGRAM_PREFIX} install typescript --dev`;
return error;
}

function declaresTypeScriptSync(projectRoot: string): boolean {
const manifest = readJsonFileSync<ProjectPackageJson>(path.join(projectRoot, 'package.json'));
return listDependencyNames(manifest).includes('typescript');
}

/** The error for a compiler that is on disk and could not be started. */
function notRunnableError(cli: TypeScriptCli, why: string): CommandError {
const error = new CommandError(
Expand Down
51 changes: 51 additions & 0 deletions packages/@expo/agent-cli/src/utils/__tests__/packageRunner-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import path from 'path';

import { resetInvokerCache } from '../invoker';
import {
lockfileInstall,
packageRunnerLabel,
resetPackageRunnerCache,
resolvePackageRunner,
Expand Down Expand Up @@ -83,3 +84,53 @@ describe(packageRunnerLabel, () => {
expect(packageRunnerLabel({ runner: 'npx', command: 'npx' })).toBe('npx');
});
});

describe(lockfileInstall, () => {
const workspace = path.resolve('/workspace');
const app = path.join(workspace, 'apps', 'mobile');

it(`should prefer bun over npm in one directory`, () => {
vol.fromJSON({
[path.join(workspace, 'bun.lock')]: '',
[path.join(workspace, 'package-lock.json')]: '{}',
});

expect(lockfileInstall(workspace)).toMatchObject({
manager: 'bun',
cwd: workspace,
argv: ['bun', 'install', '--frozen-lockfile'],
});
});

it(`should use the workspace root's lockfile for a package of it`, () => {
vol.fromJSON({
[path.join(workspace, 'package-lock.json')]: '{}',
[path.join(app, 'package.json')]: '{}',
});

expect(lockfileInstall(app)).toMatchObject({
manager: 'npm',
cwd: workspace,
argv: ['npm', 'ci'],
});
});

it.each([
['pnpm-lock.yaml', ['pnpm', 'install', '--frozen-lockfile']],
['yarn.lock', ['yarn', 'install']],
])(`should read %s`, (file, argv) => {
vol.fromJSON({ [path.join(workspace, file)]: '' });

expect(lockfileInstall(workspace).argv).toEqual(argv);
});

it(`should run npm install in the project when there is no lockfile`, () => {
vol.fromJSON({ [path.join(app, 'package.json')]: '{}' });

expect(lockfileInstall(app)).toMatchObject({
manager: 'npm',
cwd: app,
argv: ['npm', 'install'],
});
});
});
Loading