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
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describeLive('live-eas', gate)('live-eas: the real service, on expo-ci', () => {
//
// The fix is a per-spec mutex in the spawn layer (`src/utils/runnerLock.ts`) plus a guard that will
// not let a runner's line be quoted as the service's answer even when the two do collide
// (`looksLikeRunnerNoise`). This test asserts both halves against the real runner.
// (`readEasFailure`). This test asserts both halves against the real runner.
it("F93: a build lookup never reports the package runner's progress line as EAS's answer", async () => {
// **The cache has to go first**, and this is the whole reason the test says so out loud: by the
// time this runs, the tests above have written `.expo/agent-cli-eas-builds.json`, and a cache hit
Expand Down
45 changes: 45 additions & 0 deletions packages/@expo/agent-cli/e2e/__tests__/cloud-simulator-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,51 @@ describe('@expo/agent-cli smoke --eas', () => {
});

describe('@expo/agent-cli runtime:reload --eas', () => {
it('reports an inner agent-device install failure without blaming the EAS installation', async () => {
const projectRoot = await setupAsync('go-app');
await writeSessionFileAsync(projectRoot, 'sess-e2e');
const stub = await startStubDevServerAsync({
projectRoot,
targets: [],
messageSocket: 'none',
manifestOrigin: 'https://stub-tunnel.example',
});
const releaseLock = await holdDevLockAsync(projectRoot, {
url: stub.url,
port: stub.port,
pid: process.pid,
startedAt: new Date().toISOString(),
projectRoot,
});
try {
const result = await executeAgentCliAsync(
projectRoot,
['runtime:reload', '--eas', '--json', '--no-followups'],
{
reject: false,
env: {
STUB_SIM_EXEC_EXIT: '1',
STUB_SIM_STDERR:
'npm warn exec The following package was not found and will be installed: agent-device@latest',
},
}
);
expect(result.exitCode).toBe(20);
const report = JSON.parse(result.stdout);
const attempt = report.attempts.find((item: { method: string }) => item.method === 'device');
expect(attempt.ok).toBe(false);
expect(attempt.reason).toContain('installation output for "agent-device@latest"');
expect(attempt.reason).not.toContain('failed to deliver the eas CLI');
expect(result.stdout + result.stderr).not.toContain('install --save-dev');
expect(
easInvocations(projectRoot).filter((args) => args[0] === 'simulator:exec')
).toHaveLength(1);
} finally {
await releaseLock();
await stub.close();
}
});

// @ref llp/0005-runtime-loop-tools.rfc.md §Cloud simulator — wave 19.
//
// The reload on a cloud session is **two** controller verbs, and this is where the argv leaving a
Expand Down
3 changes: 2 additions & 1 deletion packages/@expo/agent-cli/e2e/__tests__/deploy-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,8 @@ describe('@expo/agent-cli deploy', () => {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('The EAS CLI could not be reached');
expect(result.stderr).toContain('no package runner');
expect(result.stderr).toContain('Try: npm install --save-dev eas-cli');
expect(result.stderr).toContain('on PATH');
expect(result.stderr).not.toContain('npm install --save-dev eas-cli');
// The export costs minutes, so the missing tool is found before it runs.
expect(readStubExpoInvocations(projectRoot)).toEqual([]);
});
Expand Down
4 changes: 3 additions & 1 deletion packages/@expo/agent-cli/e2e/__tests__/dev-eas-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ describe('@expo/agent-cli dev — the EAS route', () => {
const report = JSON.parse(result.stdout);
expect(report.error.code).toBe('EAS_CLI_MISSING');
expect(report.error.message).toContain('no package runner');
expect(report.error.suggestedCommand).toBe('npm install --save-dev eas-cli');
expect(report.error.message).toContain('on PATH');
expect(report.error.message).not.toContain('install --save-dev');
expect(report.error.suggestedCommand).toBeNull();
});

// @ref llp/0001-agentic-cli-on-expo-cli.rfc.md §Constraints — the thing on the other side of the
Expand Down
4 changes: 3 additions & 1 deletion packages/@expo/agent-cli/e2e/__tests__/eas-runner-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ describe('a machine with no eas-cli installed', () => {
expect(report.error.code).toBe('EAS_CLI_MISSING');
expect(report.error.message).toContain('no package runner');
expect(report.error.message).not.toContain('npm install -g eas-cli');
expect(report.error.suggestedCommand).toBe('npm install --save-dev eas-cli');
expect(report.error.message).toContain('on PATH');
expect(report.error.message).not.toContain('install --save-dev');
expect(report.error.suggestedCommand).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ runner -> bunx, when the project uses bun; else npx --yes

The runners already prefer the project's own copy, so a "project bin first" rung was doing nothing the runner does not do. A runner resolves a package and never a file on `PATH`, so a stray `eas` is not spawned at all. The impostor guards stay (`utils/wrapperCrash.ts`) because "unreachable" is a claim about today's resolver rather than about the process boundary.

An existing project pin is respected, but error recovery does not recommend adding `eas-cli` to the project. A missing or unspawnable runner names the runner and asks for its installation/PATH to be repaired. A download failure suggests retrying or checking registry access; a timeout alone does not prove a download was in progress. [confirmed — review of #127, 2026-10-07]

Load-bearing rules of the rung:

- The pin survives. Local before published, in a monorepo too. A version in the spec (`@latest`) defeats the pin, so `pinned` chooses the bare name.
Expand Down
2 changes: 2 additions & 0 deletions packages/@expo/agent-cli/llp/0021-honest-reports.rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ A failed EAS Simulator session start whose reason is `Resolving dependencies` is

When only the outer runner printed, the reason attributes those lines to the runner and suggests retrying the download or checking registry access. Recovery advice does not recommend adding `eas-cli` to the project's dependencies. A signal is reported as a signal, never as exit code `null`.

`simulator:exec` also relays an inner package runner. Cloud reload uses an explicit npm install notice to name that package; an `agent-device` download is not evidence that EAS never started. Runner output that names no package stays unattributed. Fingerprint comparison uses a recognised cause's recovery advice as well as its summary, so the Why and How describe the same failure.

Copying one EAS build's fingerprint comparison onto both platforms is rule 3. `src/impact/buildCache.ts` answers per platform.

`deploy` classifying EAS failures is rule 4. `src/deploy/easFailure.ts` reads the EAS CLI's own sentence first.
Expand Down
12 changes: 8 additions & 4 deletions packages/@expo/agent-cli/src/deploy/deployAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import {
UNTRUSTED_OUTPUT_END,
wrapUntrustedAppOutput,
} from '../runtime/untrusted';
import { easCliLabel, resolveEasCliOrThrow, type EasCli } from '../utils/easCli';
import {
easCliLabel,
EAS_RUNNER_RECOVERY,
resolveEasCliOrThrow,
type EasCli,
} from '../utils/easCli';
import { CommandError } from '../utils/errors';
import { spawnExpoAsync } from '../utils/expoCli';
import { toPosixPath } from '../utils/filePath';
Expand Down Expand Up @@ -490,11 +495,10 @@ function easCliUnavailable(command: string, spawnError: NodeJS.ErrnoException):
'EAS_CLI_MISSING',
[
`Could not run the EAS CLI (${command}), so nothing was shipped.`,
`Why: the file was found but spawning it failed (${spawnError.code ?? spawnError.message}), which usually means a broken or partial install.`,
`How: add the EAS CLI to the project with "npm install --save-dev eas-cli", then run this command again — the project's own copy is the first thing this command looks for, so it takes precedence over whatever is broken.`,
`Why: the package runner could not be spawned (${spawnError.code ?? spawnError.message}).`,
`How: ${EAS_RUNNER_RECOVERY}`,
].join('\n')
);
error.suggestedCommand = 'npm install --save-dev eas-cli';
return error;
}

Expand Down
36 changes: 14 additions & 22 deletions packages/@expo/agent-cli/src/device/cloudSimulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ import path from 'path';
import { classifySubprocessFailure } from '../needsHuman/detect';
import { needsHumanErrorFrom } from '../needsHuman/error';
import { PROGRAM_PREFIX } from '../programName';
import { easCliArgs, easCliLabel, resolveEasCli, type EasCli } from '../utils/easCli';
import { classifyEasFailure } from '../utils/easFailure';
import {
easCliArgs,
easCliLabel,
EAS_RUNNER_RECOVERY,
resolveEasCli,
type EasCli,
} from '../utils/easCli';
import { classifyEasFailure, easFailureReason } from '../utils/easFailure';
import { CommandError } from '../utils/errors';
import { spawnCaptureAsync } from '../utils/spawnCapture';
import {
Expand Down Expand Up @@ -706,18 +712,9 @@ export async function probeCloudSessionAsync({
);
}
if (result.exitCode !== 0) {
// @ref llp/0027-everything-on-eas.rfc.md §What EAS said — a refusal this CLI recognises is
// answered in its own words, with the fix; anything else quotes the CLI's first line.
const cause = classifyEasFailure(`${result.stdout}\n${result.stderr}`);
// @ref llp/0027-everything-on-eas.rfc.md §What EAS said
return {
...unknownSession(
preferredId,
cause
? `"${result.command}" exited ${result.exitCode ?? 'on a signal'}: ${cause.summary}`
: `"${result.command}" exited ${result.exitCode ?? 'on a signal'}${
firstLine(result.stderr) ? `: ${firstLine(result.stderr)}` : ''
}`
),
...unknownSession(preferredId, easFailureReason(result, result.command)),
failure: result,
};
}
Expand Down Expand Up @@ -1140,7 +1137,7 @@ export function cloudVerbFailedError(
[
`Could not run "${result.command}", so ${what}`,
`Why: ${result.spawnError}`,
`How: add the EAS CLI to the project with "npm install --save-dev eas-cli", then run this command again. The project's own copy is the first thing this command looks for, so it takes precedence over whatever could not be spawned.`,
`How: ${EAS_RUNNER_RECOVERY}`,
].join('\n')
);
}
Expand Down Expand Up @@ -1221,17 +1218,16 @@ export function cloudVerbNotSupportedError(action: string): CommandError {
return error;
}

/** The failure for a cloud run on a machine with no EAS CLI at all. */
/** The failure for a cloud run on a machine with no reachable package runner. */
export function easCliMissingError(): CommandError {
const error = new CommandError(
'EAS_CLI_MISSING',
[
'The EAS CLI could not be reached, so a cloud simulator cannot be driven from here.',
'Why: no "eas" binary was found in node_modules/.bin or on PATH, and no package runner ("npx" or "bunx") is on PATH either, so the published eas-cli could not be downloaded to stand in for one — and an EAS Simulator session is created and driven entirely through that CLI.',
'How: add the EAS CLI to the project with "npm install --save-dev eas-cli", then run this command again. If that command is also unavailable, PATH is missing the Node.js install that provides npm and npx — fix that first.',
'Why: no package runner ("npx" or "bunx") is on PATH, so nothing here can start the published EAS CLI — and an EAS Simulator session is created and driven entirely through that CLI.',
`How: ${EAS_RUNNER_RECOVERY}`,
].join('\n')
);
error.suggestedCommand = 'npm install --save-dev eas-cli';
return error;
}

Expand Down Expand Up @@ -1276,7 +1272,3 @@ function stringOf(value: unknown): string | null {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function firstLine(text: string): string {
return text.trim().split('\n')[0] ?? '';
}
34 changes: 34 additions & 0 deletions packages/@expo/agent-cli/src/impact/__tests__/buildCache-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,25 @@ describe(describeLookupFailure, () => {
expect(reason).not.toContain('failed to deliver the eas CLI');
});

it("quotes the lookup's error, not eas-cli's notice about the project's dependencies", () => {
expect(
describeLookupFailure(
{
exitCode: 1,
stdout: 'Entity not authorized: Build (ID 123)\n',
stderr: [
'Found eas-cli in your project dependencies.',
'It\'s recommended to use the "cli.version" field in eas.json to enforce the eas-cli version for your project.',
'Learn more: https://github.com/expo/eas-cli#enforcing-eas-cli-version-for-your-project',
'',
'Error: build:list command failed.',
].join('\n'),
},
INVOCATION
)
).toBe('Entity not authorized: Build (ID 123)');
});

it('says so when the lookup ran and printed nothing at all', () => {
expect(describeLookupFailure({ exitCode: 1, stdout: '', stderr: '' }, INVOCATION)).toBe(
'the EAS CLI refused the lookup and printed nothing'
Expand Down Expand Up @@ -106,4 +125,19 @@ describe(describeLookupFailure, () => {
)
).toContain('not linked to an EAS project');
});

// On a cold scratch directory the runner prints first and the CLI after it, on stderr
// [observed — 2026-10-05]. A guard that read only the first line said the runner did all of it.
it("quotes the CLI's line after the runner's progress", () => {
expect(
describeLookupFailure(
{
exitCode: 1,
stdout: '',
stderr: 'Resolving dependencies\nSaved lockfile\nEntity not authorized: Build (ID 123)\n',
},
INVOCATION
)
).toBe('Entity not authorized: Build (ID 123)');
});
});
38 changes: 38 additions & 0 deletions packages/@expo/agent-cli/src/impact/__tests__/compare-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
findCachedBuildAsync,
lookUpBuildPlatformAsync,
lookUpCachedBuildAsync,
runnerDownloadNote,
parseBuildPlatform,
parseCachedBuild,
} from '../buildCache';
Expand Down Expand Up @@ -56,6 +57,13 @@ function mockSpawn(result: Partial<Awaited<ReturnType<typeof spawnSubprocessAsyn

const source = { type: 'dir', filePath: 'node_modules/x', reasons: ['expoAutolinkingIos'] };

it('describes a possible runner download without prescribing a project dependency', () => {
expect(runnerDownloadNote(easCli)).toContain('may need to download');
expect(runnerDownloadNote(easCli)).toContain('package registry');
expect(runnerDownloadNote(easCli)).not.toContain('install --save-dev');
expect(runnerDownloadNote({ ...easCli, pinned: true })).toBe('');
});

beforeEach(() => {
vi.mocked(spawnSubprocessAsync).mockReset();
vi.mocked(readLastBuildRecord).mockReset().mockReturnValue({});
Expand Down Expand Up @@ -193,6 +201,36 @@ describe(compareWithEasBuildAsync, () => {
expect(result.error).toContain('eas-cli@latest build:list');
});

it.each([
[
'EAS project not configured.\nAccounts you can create projects in: alice',
'init --account alice --non-interactive',
],
[
'Either log in with "eas login" or set the EXPO_TOKEN environment variable to authenticate.',
'npx @expo/agent-cli login',
],
])(
'uses the recognized comparison failure to choose recovery advice',
async (stdout, recovery) => {
mockSpawn({ exitCode: 1, stdout, stderr: 'Error: fingerprint:compare command failed.' });
const result = await compareWithEasBuildAsync(easCli, projectRoot, 'build-1');
const how = result.error?.split('\n').find((line) => line.startsWith('How:'));
expect(how).toContain(recovery);
expect(how).not.toContain('build:list');
expect(result.error).toContain('build-1');
}
);

it('offers a runner retry without adding eas-cli to the project', async () => {
mockSpawn({ exitCode: null, stderr: 'Resolving dependencies\n' });
const result = await compareWithEasBuildAsync(easCli, projectRoot, 'build-1');
expect(result.error).toContain('exited on a signal');
expect(result.error).toContain('How: run this command again');
expect(result.error).not.toContain('install --save-dev');
expect(result.error).not.toContain('build:list');
});

// The "How" line of the ordinary error is advice about the build id and the sign-in, and neither
// is what is wrong when the file under the name `eas` was never the EAS CLI.
it(`should name a binary that was never the EAS CLI instead of quoting its panic`, async () => {
Expand Down
Loading
Loading