Compare commits

..
Author SHA1 Message Date
Expo CI 62d46fece5 v21.0.3 2026-07-22 12:31:49 +00:00
Ash Wu d66dc2920f [eas-build-job] add update hook key (#4056)
# Why

EAS Workflows' `update` job is getting `before_update` / `after_update` hooks (ENG-25364). First concrete ask: uploading PostHog sourcemaps right after `eas update` publishes — sourcemaps only exist inside the job, and we don't want a vendor-specific step for every provider.

# How

`update` joins `HOOK_ANCHORS`. It is a stamp-only anchor: the server stamps `__hook_id: 'update'` on the update job's generated publish step (expo/universe#29311); no eas-cli function declares it.

# Test Plan

CI passes.
2026-07-22 19:39:55 +08:00
Ash Wu d725a6c7f4 [build-tools] fix maestro screenshot harvest for v2.7.0 (#4051)
Signed-off-by: Ash Wu <[email protected]>
2026-07-22 16:54:58 +08:00
Ash Wu f93b75b9a6 [build-tools] run workflow hooks around managed native build phases (#4044)
Signed-off-by: Ash Wu <[email protected]>
2026-07-21 20:22:00 +08:00
Jon Samp a323655f7c [eas-cli] Add env:set and deprecate env:create and env:update (#4041)
* [eas-cli] Add `eas env:set`, deprecate `env:create` and `env:update`

Introduce `eas env:set`, which creates or updates an environment
variable in a single command (upsert). `env:create` and `env:update`
now warn that they are deprecated in favor of `env:set` while keeping
their existing behavior for backwards compatibility.

* [eas-cli] Add `--json` to `eas env:set`, preserve file type on update

Look up the existing variable before resolving its value so updating a file
variable no longer requires re-passing `--type file`, mirroring the behavior
that `eas env:update` had. Also fix the `--value` flag description typo.

* [eas-cli] Preserve env memberships and hide deprecated commands

* [eas-cli] Remove redundant env:set comment

* [eas-cli] Align env:set flag names

* [eas-cli] Keep short env:set flags canonical

* [eas-cli] Preserve visibility in non-interactive env:set
2026-07-20 16:39:06 -05:00
Jon Samp 4bb500ae01 [eas-cli] Add eas chat command (#4005)
* [eas-cli] Add hidden `eas chat` command

Ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Streams the reply, supports interactive multi-turn follow-ups, `--project`/`--account` scoping, `--non-interactive`, and `--json` for agent-friendly structured output.

Calls the website's `/api/chat` route using the CLI session secret; command is hidden while the backend feature gate rolls out.

* [eas-cli] Update changelog PR link for `eas chat`

* [eas-cli] Render markdown and steer output for `eas chat`

Render the streamed assistant reply as ANSI (bold, inline code, headings, bullets, links) one line at a time. Prepend a client-side system message steering the assistant to write terminal-friendly answers and include expo.dev links for referenced artifacts (builds, updates, etc.).

* [eas-cli] Add readline REPL with history and slash commands to `eas chat`

Replace the per-turn prompt with a reusable Node readline interface: arrow-key history across turns and /help, /clear, /exit slash commands, all in the normal scrolling buffer (no new dependency).

* [eas-cli] Fix `eas chat` interactive prompt exiting immediately

The ora spinner leaves stdin paused when it stops, so the readline prompt had nothing keeping the event loop alive and the process exited the moment the prompt appeared. Resume stdin before each prompt and stop the spinner from grabbing stdin (discardStdin: false).

* [eas-cli] Auto-scope `eas chat` to the current directory's project

When run inside a linked EAS project and neither --project nor --account is given, chat now focuses on that project automatically. Detection is silent and read-only: it never prompts, links, or creates a config, and falls back to account scope when the directory is not a linked project.

* [eas-cli] Seed `eas chat` history with the initial command-line message

The first message comes from argv, not the readline prompt, so Up-arrow had nothing to recall at the first follow-up. Pass it as readline's initial history.

* [eas-cli] Label and align `eas chat` speakers (Chat > / Expo >)

Prefix the assistant reply with a magenta "Expo > " label and indent its continuation lines to match, and label the user's turn "Chat > " (both 4-char names so the two align).

* [eas-cli] Wrap `eas chat` reply to terminal width and indent the spinner

Wrap each rendered line to the terminal width minus the indent (ANSI-aware, hard-breaking long URLs) and re-apply the indent to every wrapped piece, so terminal soft-wrapping no longer drops wrapped text to column 0. Also indent the ora spinner to align with the reply body.

* [eas-cli] Fix `eas chat` reply indent after spinner and reorder intro

The indented ora spinner leaves the cursor at its indent column when it stops (ora's clear() ends with cursorTo(indent)), which pushed the whole reply right by the indent. Reset the cursor to column 0 after stopping. Also move the commands hint above the echoed message and shorten it.

* [eas-cli] Render `eas chat` spinner with the Expo label prefix

Use ora's prefixText to show "Expo > <spinner> Thinking…" from the first frame instead of the indent option, which rendered unindented for a tick then snapped right. The spinner now reads as the reply and is cleared/replaced by the streamed text.

* [eas-cli] Decode `eas chat` stream with StringDecoder to avoid splitting multi-byte characters

* [eas-cli] Remove CHANGELOG entry for hidden `eas chat` command

* [eas-cli] Preserve chat context across follow-ups

* [eas-cli] Let `eas chat` open an interactive prompt with no message argument

* [eas-cli] Remove comments from `eas chat`

* [eas-cli] Keep `eas chat` interactive session alive when a request fails

In interactive mode, catch errors from the chat stream, print the message, and re-prompt instead of exiting the whole session. Single-shot and --json invocations still throw.

* [eas-cli] Keep `eas chat` spinner visible until the reply is ready to render

The spinner was cleared on the first text delta, but the line-buffered renderer only draws once a full line is buffered, leaving a blank gap. Stop the spinner lazily when the first visible line is written so the labeled loading indicator stays put until output appears.

* [eas-cli] Spell out abbreviated variable names in `eas chat`

rl -> readlineInterface, exp -> expoConfig, the *_RE markdown constants -> *_REGEX, and the repl test var -> replInput.

* [eas-cli] Simplify `eas chat` tool tracking and markdown renderer

Derive the toolCalls result from the assistant message parts instead of maintaining a second parallel map, and drop markdown constructs (headings, blockquotes, horizontal rules, italics) the system prompt tells the model not to emit.

* [eas-cli] Sanitize streamed chat output
2026-07-20 14:38:22 -05:00
Szymon Dziedzic a207e5d62e [build-tools] Generalize remote log uploads (#4027)
* [build-tools] Generalize remote log uploads

* [build-tools] Remove remote logger size policy

* [build-tools] Preserve remote logger behavior
2026-07-20 13:02:33 +02:00
65 changed files with 4442 additions and 758 deletions

No files matched your search

+1 -2
View File
@@ -8,8 +8,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add diff output to `eas env:pull`. ([#4036](https://github.com/expo/eas-cli/pull/4036) by [@khamilowicz](https://github.com/khamilowicz))
- [eas-cli] Support drill-down from event lists in observe:session. ([#3987](https://github.com/expo/eas-cli/pull/3987) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
@@ -52,6 +50,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas env:set`, which creates or updates an environment variable in one command. `eas env:create` and `eas env:update` are now deprecated. ([#4002](https://github.com/expo/eas-cli/pull/4002) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.0.2",
"version": "21.0.3",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.0.2",
"version": "21.0.3",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -39,14 +39,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.2",
"@expo/eas-build-job": "21.0.3",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.0.2",
"@expo/steps": "21.0.3",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -9,6 +9,7 @@ import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import { setupAsync } from '../../common/setup';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
@@ -89,6 +90,48 @@ describe(androidBuilder, () => {
);
});
it('passes the install_node_modules anchor and a hooks ref to setupAsync', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(setupAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
wrappedAnchors: ['install_node_modules'],
jobHooksRef: expect.objectContaining({ current: null }),
})
);
});
it('drains queued hook metric uploads even when setup fails after assigning the ref', async () => {
const drainPendingMetricUploads = jest.fn();
jest.mocked(setupAsync).mockImplementationOnce(async (_ctx, { jobHooksRef }) => {
// A before-hook can queue a metric upload and then fail setup; the drain
// must still run.
jobHooksRef.current = {
customBuildContext: { drainPendingMetricUploads },
} as never;
throw new Error('setup boom');
});
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
await expect(androidBuilder(ctx)).rejects.toThrow('setup boom');
expect(drainPendingMetricUploads).toHaveBeenCalled();
});
it('injects Android version config without build credentials', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
+17 -3
View File
@@ -10,11 +10,11 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { JobHooksRef, setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
cacheStatsAsync,
@@ -50,7 +50,21 @@ export default async function androidBuilder(ctx: BuildContext<Android.Job>): Pr
}
async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
await setupAsync(ctx);
const jobHooksRef: JobHooksRef = { current: null };
try {
await buildInnerAsync(ctx, jobHooksRef);
} finally {
// Native builds have no other drain path for the hook context's queued
// step-metric uploads.
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
}
}
async function buildInnerAsync(
ctx: BuildContext<Android.Job>,
jobHooksRef: JobHooksRef
): Promise<void> {
await setupAsync(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
const evictUsedBefore = new Date();
const workingDirectory = ctx.getReactNativeProjectDirectory();
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
+17 -2
View File
@@ -8,7 +8,7 @@ import { runBuilderWithHooksAsync } from './common';
import { runCustomBuildAsync } from './custom';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { JobHooksRef, setupAsync } from '../common/setup';
import { Artifacts, BuildContext } from '../context';
import { configureXcodeProject } from '../ios/configure';
import CredentialsManager from '../ios/credentials/manager';
@@ -49,7 +49,22 @@ export default async function iosBuilder(ctx: BuildContext<Ios.Job>): Promise<Ar
}
async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
await setupAsync(ctx);
const jobHooksRef: JobHooksRef = { current: null };
try {
await buildInnerAsync(ctx, jobHooksRef);
} finally {
// Native builds have no other drain path for the hook context's queued
// step-metric uploads. Must wrap the WHOLE body — iOS's existing inner
// try/finally only starts after setupAsync returns.
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
}
}
async function buildInnerAsync(
ctx: BuildContext<Ios.Job>,
jobHooksRef: JobHooksRef
): Promise<void> {
await setupAsync(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
@@ -196,4 +196,67 @@ describe('easBuildInternal', () => {
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
describe('hooks retention', () => {
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), child: jest.fn() } as any;
const originalHooks = { before_install_node_modules: [{ run: 'echo original' }] };
function mockRegeneratedJob(hooks?: object): void {
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
...(hooks ? { hooks } : null),
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(JSON.stringify({ job: internalJob, metadata: {} })),
stderr: Buffer.from(''),
} as any);
}
function runWith(jobHooks?: object): Promise<{ newJob: BuildJob }> {
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
...(jobHooks ? { hooks: jobHooks } : null),
} as unknown as BuildJob;
return runEasBuildInternalAsync({ job, logger, env: {}, cwd: '/tmp/project' });
}
it('keeps the original hooks when the regenerated job has none', async () => {
mockRegeneratedJob();
const { newJob } = await runWith(originalHooks);
expect((newJob as any).hooks).toEqual(originalHooks);
});
it('lets the original hooks win over hooks in the regenerated job', async () => {
mockRegeneratedJob({ after_install_node_modules: [{ run: 'echo from-eas-json' }] });
const { newJob } = await runWith(originalHooks);
expect((newJob as any).hooks).toEqual(originalHooks);
});
it('carries no hooks when the original had none, even if the regenerated job adds some', async () => {
mockRegeneratedJob({ before_install_node_modules: [{ run: 'echo from-eas-json' }] });
const { newJob } = await runWith(undefined);
expect((newJob as any).hooks).toBeUndefined();
});
});
});
@@ -0,0 +1,164 @@
import { BuildPhase, BuildTrigger, Hooks, Ios } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { executeHookStepsAsync } from '@expo/steps';
import { createTestIosJob } from '../../__tests__/utils/job';
import { BuildContext } from '../../context';
import { runHookableBuildPhaseAsync } from '../hookableBuildPhase';
import { ParsedJobHooks, parseJobHooksAsync } from '../jobHooks';
// The primitive's step-execution rules (failure()/success(), no-if continuation,
// metric emission) are covered in @expo/steps. Here we mock it to unit-test the
// wrapper's own orchestration: ordering, error precedence, and the env bridge.
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
executeHookStepsAsync: jest.fn(),
}));
const executeHookStepsMock = jest.mocked(executeHookStepsAsync);
async function setup(hooks: Hooks | undefined): Promise<{
ctx: BuildContext<Ios.Job>;
hooks: ParsedJobHooks | null;
}> {
const logger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
child: jest.fn(() => logger),
} as unknown as bunyan;
const ctx = new BuildContext<Ios.Job>(
{ ...createTestIosJob({ triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION }), hooks },
{
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger,
uploadArtifact: jest.fn(),
workingdir: '/tmp/wd',
}
);
jest
.spyOn(ctx, 'runBuildPhase')
.mockImplementation((_phase: BuildPhase, fn: () => Promise<unknown>) => fn() as never);
return { ctx, hooks: await parseJobHooksAsync(ctx, ['install_node_modules']) };
}
const runArgs = <TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
hooks: ParsedJobHooks | null,
fn: () => Promise<unknown>
): Parameters<typeof runHookableBuildPhaseAsync>[0] => ({
ctx,
hooks,
buildPhase: BuildPhase.INSTALL_DEPENDENCIES,
anchor: 'install_node_modules',
fn,
});
describe(runHookableBuildPhaseAsync, () => {
beforeEach(() => {
executeHookStepsMock.mockReset();
executeHookStepsMock.mockResolvedValue({ failedLocally: false, firstError: undefined });
});
it('runs the phase once with no hook execution when there are no hooks', async () => {
const { ctx, hooks } = await setup(undefined);
const fn = jest.fn().mockResolvedValue('ok');
expect(await runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).toBe('ok');
expect(ctx.runBuildPhase).toHaveBeenCalledTimes(1);
expect(executeHookStepsMock).not.toHaveBeenCalled();
});
it('runs before -> phase -> after in order and returns the phase result', async () => {
const { ctx, hooks } = await setup({
before_install_node_modules: [{ run: 'echo before' }],
after_install_node_modules: [{ run: 'echo after' }],
});
const order: string[] = [];
executeHookStepsMock.mockImplementation(async (_ctx, _entries, opts) => {
order.push(opts.timing);
return { failedLocally: false, firstError: undefined };
});
const fn = jest.fn().mockImplementation(async () => {
order.push('phase');
return 'result';
});
expect(await runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).toBe('result');
expect(order).toEqual(['before', 'phase', 'after']);
expect(executeHookStepsMock).toHaveBeenLastCalledWith(hooks!.globalContext, expect.anything(), {
anchor: 'install_node_modules',
timing: 'after',
anchorResult: 'success',
});
});
it('skips the phase and after-hooks when a before-hook fails', async () => {
const { ctx, hooks } = await setup({
before_install_node_modules: [{ run: 'echo before' }],
after_install_node_modules: [{ run: 'echo after' }],
});
executeHookStepsMock.mockResolvedValue({
failedLocally: true,
firstError: new Error('before boom'),
});
const fn = jest.fn().mockResolvedValue('ok');
await expect(runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).rejects.toThrow(
'Hook "before_install_node_modules" failed'
);
expect(fn).not.toHaveBeenCalled();
expect(executeHookStepsMock).toHaveBeenCalledTimes(1);
});
it('throws the after-hook error when the phase succeeded but an after-hook fails', async () => {
const { ctx, hooks } = await setup({
after_install_node_modules: [{ run: 'echo after' }],
});
executeHookStepsMock.mockResolvedValue({
failedLocally: true,
firstError: new Error('after boom'),
});
await expect(
runHookableBuildPhaseAsync(runArgs(ctx, hooks, jest.fn().mockResolvedValue('ok')))
).rejects.toThrow('Hook "after_install_node_modules" failed');
});
it('lets the phase error win over a failing after-hook, marking the context failed', async () => {
const { ctx, hooks } = await setup({
after_install_node_modules: [{ run: 'echo after' }],
});
const markAsFailed = jest.spyOn(hooks!.globalContext, 'markAsFailed');
executeHookStepsMock.mockResolvedValue({
failedLocally: true,
firstError: new Error('after boom'),
});
await expect(
runHookableBuildPhaseAsync(
runArgs(ctx, hooks, jest.fn().mockRejectedValue(new Error('phase boom')))
)
).rejects.toThrow('phase boom');
// after ran against the failed anchor
expect(markAsFailed).toHaveBeenCalled();
expect(executeHookStepsMock).toHaveBeenLastCalledWith(hooks!.globalContext, expect.anything(), {
anchor: 'install_node_modules',
timing: 'after',
anchorResult: 'failed',
});
});
it('refreshes the hook context before and writes env back after each hook', async () => {
const { ctx, hooks } = await setup({
before_install_node_modules: [{ run: 'echo before' }],
});
const forwardRefresh = jest.spyOn(hooks!.customBuildContext, 'updateEnv');
const writeBack = jest.spyOn(ctx, 'updateEnv');
// Captured before the run: the reverse write-back replaces ctx.env, so
// comparing the forward-refresh arg against the post-run ctx.env would drift.
const envBeforeRun = ctx.env;
await runHookableBuildPhaseAsync(runArgs(ctx, hooks, jest.fn().mockResolvedValue('ok')));
expect(forwardRefresh).toHaveBeenCalledWith(envBeforeRun);
// The job/metadata snapshot is synced onto the hook context before the hook.
expect(hooks!.customBuildContext.job).toBe(ctx.job);
expect(hooks!.customBuildContext.metadata).toBe(ctx.metadata);
expect(writeBack).toHaveBeenCalled();
});
});
@@ -0,0 +1,139 @@
import { BuildTrigger, ErrorCode, Hooks, Ios, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { createTestIosJob } from '../../__tests__/utils/job';
import { BuildContext } from '../../context';
import { parseJobHooksAsync } from '../jobHooks';
const INSTALL: ['install_node_modules'] = ['install_node_modules'];
function createCtx(hooks: Hooks | undefined): {
ctx: BuildContext<Ios.Job>;
warn: jest.Mock;
} {
const warn = jest.fn();
const logger = {
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn,
child: jest.fn(() => logger),
} as unknown as bunyan;
const ctx = new BuildContext<Ios.Job>(
{ ...createTestIosJob({ triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION }), hooks },
{
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger,
uploadArtifact: jest.fn(),
workingdir: '/tmp/wd',
}
);
return { ctx, warn };
}
describe(parseJobHooksAsync, () => {
it('returns null when the job has no hooks', async () => {
const { ctx } = createCtx(undefined);
expect(await parseJobHooksAsync(ctx, INSTALL)).toBeNull();
});
it('parses a wrapped-anchor hook into one entry in a checked-out context', async () => {
const { ctx } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed).not.toBeNull();
expect(parsed!.hookEntriesByKey.before_install_node_modules).toHaveLength(1);
expect(parsed!.globalContext.wasCheckedOut()).toBe(true);
});
it('throws a shape error for a malformed step, tagged with the hooks error code', async () => {
const { ctx } = createCtx({
before_install_node_modules: [{} as never],
});
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
'Invalid steps in "hooks.before_install_node_modules"'
);
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toMatchObject({
errorCode: ErrorCode.HOOKS_ERROR,
});
});
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
const { ctx } = createCtx({
before_submit: [{} as never],
});
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
'Invalid steps in "hooks.before_submit"'
);
});
it('accepts an empty-array opt-out without validating or constructing it', async () => {
const { ctx } = createCtx({
before_install_node_modules: [],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_install_node_modules).toBeUndefined();
});
it('errors on an unknown function under a wrapped key (construction, not shape)', async () => {
const { ctx } = createCtx({
before_install_node_modules: [{ uses: 'eas/nonexistent' }],
});
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
'Failed to parse hooks.before_install_node_modules'
);
});
it('parses a function group in an after-hook (no after-hook fence)', async () => {
const { ctx } = createCtx({
after_install_node_modules: [{ uses: 'eas/checkout' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.after_install_node_modules).toHaveLength(1);
});
it('shares one global context across the before and after entries of an anchor', async () => {
const { ctx } = createCtx({
before_install_node_modules: [{ run: 'echo before' }],
after_install_node_modules: [{ run: 'echo after' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
const before = parsed!.hookEntriesByKey.before_install_node_modules![0];
const after = parsed!.hookEntriesByKey.after_install_node_modules![0];
expect(before.steps[0].ctx.global).toBe(parsed!.globalContext);
expect(after.steps[0].ctx.global).toBe(parsed!.globalContext);
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
const { ctx } = createCtx({
before_install_node_modules: [{ id: 'dup', run: 'echo a' }],
after_install_node_modules: [{ id: 'dup', run: 'echo b' }],
});
const result = parseJobHooksAsync(ctx, INSTALL);
await expect(result).rejects.toThrow(UserError);
await expect(result).rejects.toMatchObject({
errorCode: ErrorCode.HOOKS_ERROR,
message: expect.stringContaining("The job's hooks are invalid"),
});
});
});
@@ -0,0 +1,131 @@
import { Android, BuildTrigger } from '@expo/eas-build-job';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { runHookableBuildPhaseAsync } from '../hookableBuildPhase';
import { JobHooksRef, setupAsync } from '../setup';
import { ParsedJobHooks, parseJobHooksAsync } from '../jobHooks';
import { prepareProjectSourcesAsync } from '../projectSources';
import { runHookIfPresent } from '../../utils/hooks';
jest.mock('../projectSources', () => ({ prepareProjectSourcesAsync: jest.fn() }));
jest.mock('../easBuildInternal', () => ({
resolveEnvFromBuildProfileAsync: jest.fn(),
runEasBuildInternalAsync: jest.fn(),
}));
jest.mock('../hookableBuildPhase', () => ({ runHookableBuildPhaseAsync: jest.fn() }));
jest.mock('../jobHooks', () => ({ parseJobHooksAsync: jest.fn() }));
jest.mock('../../utils/npmrc', () => ({ setUpNpmrcAsync: jest.fn() }));
jest.mock('../../utils/hooks', () => ({
Hook: { PRE_INSTALL: 'PRE_INSTALL' },
runHookIfPresent: jest.fn(),
}));
jest.mock('../../utils/project', () => ({
readAndLogPackageJson: jest.fn(() => ({})),
readEasJsonContents: jest.fn(() => '{}'),
readPackageJson: jest.fn(() => ({})),
}));
jest.mock('../../ios/xcodeEnv', () => ({ deleteXcodeEnvLocalIfExistsAsync: jest.fn() }));
// A sentinel the parse mock returns; the install wrapper must receive exactly it.
const PARSED = { sentinel: true } as unknown as ParsedJobHooks;
describe(setupAsync, () => {
let order: string[];
function createCtx(triggeredBy: BuildTrigger): BuildContext<Android.Job> {
const ctx = new BuildContext<Android.Job>(
{ ...createTestAndroidJob(), triggeredBy },
{
env: { __API_SERVER_URL: 'http://api.expo.test' },
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '/workingdir',
}
);
// Run each phase closure inline; skip the phase markers.
jest
.spyOn(ctx, 'runBuildPhase')
.mockImplementation((_phase, fn: () => Promise<unknown>) => fn() as never);
// READ_APP_CONFIG reads this; a minimal config passes validateAppConfigAsync
// and the EAS_BUILD_INTERNAL bundle-id/package checks.
jest.spyOn(ctx, 'appConfig', 'get').mockReturnValue(
Promise.resolve({
android: { package: 'com.test' },
ios: { bundleIdentifier: 'com.test' },
} as never)
);
return ctx;
}
beforeEach(() => {
jest.clearAllMocks();
order = [];
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
order.push('prepare-project');
return { handled: false };
});
jest.mocked(resolveEnvFromBuildProfileAsync).mockImplementation(async () => {
order.push('profile-env');
return {};
});
jest.mocked(runHookIfPresent).mockImplementation(async () => {
order.push('pre-install-hook');
});
jest.mocked(parseJobHooksAsync).mockImplementation(async () => {
order.push('parse-hooks');
return PARSED;
});
jest.mocked(runHookableBuildPhaseAsync).mockImplementation(async () => {
// Record the wrapper call; deliberately do NOT run its `fn` so the install
// internals stay out of the ordering test.
order.push('install-wrapper');
return undefined as never;
});
jest.mocked(runEasBuildInternalAsync).mockImplementation(async ({ job }) => ({
newJob: job,
newMetadata: {} as never,
}));
});
it('parses hooks after project prep + profile-env + pre-install hook, and before the install wrapper', async () => {
const jobHooksRef: JobHooksRef = { current: null };
await setupAsync(createCtx(BuildTrigger.GIT_BASED_INTEGRATION), {
wrappedAnchors: ['install_node_modules'],
jobHooksRef,
});
const parseAt = order.indexOf('parse-hooks');
expect(order.indexOf('prepare-project')).toBeLessThan(parseAt);
expect(order.indexOf('profile-env')).toBeLessThan(parseAt);
expect(order.indexOf('pre-install-hook')).toBeLessThan(parseAt);
expect(parseAt).toBeLessThan(order.indexOf('install-wrapper'));
});
it('parses hooks unconditionally on a non-git-based build (never parked in the git-only branch)', async () => {
const jobHooksRef: JobHooksRef = { current: null };
await setupAsync(createCtx(BuildTrigger.EAS_CLI), {
wrappedAnchors: ['install_node_modules'],
jobHooksRef,
});
expect(parseJobHooksAsync).toHaveBeenCalledTimes(1);
// The profile-env block is git-only; the parse must not be coupled to it.
expect(resolveEnvFromBuildProfileAsync).not.toHaveBeenCalled();
expect(order.indexOf('parse-hooks')).toBeLessThan(order.indexOf('install-wrapper'));
});
it('publishes the parsed hooks to the ref before the install wrapper runs', async () => {
const jobHooksRef: JobHooksRef = { current: null };
await setupAsync(createCtx(BuildTrigger.GIT_BASED_INTEGRATION), {
wrappedAnchors: ['install_node_modules'],
jobHooksRef,
});
expect(jobHooksRef.current).toBe(PARSED);
// The wrapper received the same parsed hooks the ref holds.
expect(runHookableBuildPhaseAsync).toHaveBeenCalledWith(
expect.objectContaining({ hooks: PARSED, anchor: 'install_node_modules' })
);
});
});
@@ -152,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
// Hooks are resolved server-side at submit; the mid-build eas.json
// regeneration must not add, drop, or change them (the original wins).
hooks: oldJob.hooks,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
@@ -0,0 +1,116 @@
import {
BuildJob,
BuildPhase,
ErrorCode,
HookAnchorId,
HookKey,
UserError,
} from '@expo/eas-build-job';
import { executeHookStepsAsync } from '@expo/steps';
import { BuildContext } from '../context';
import { ParsedJobHooks } from './jobHooks';
/**
* Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
* wrapped around it: before-entries -> phase (in a try) -> after-entries, with
* after-on-failure guaranteed. A failing before sequence skips the phase AND the
* after-hooks (steps-engine parity). The phase error always wins over an
* after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
* phase exactly once with zero overhead.
*/
export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
ctx,
hooks,
buildPhase,
anchor,
fn,
}: {
ctx: BuildContext<TJob>;
hooks: ParsedJobHooks | null;
buildPhase: BuildPhase;
anchor: HookAnchorId;
fn: () => Promise<T>;
}): Promise<T> {
// runHookEntriesAsync no-ops when the key has no entries.
if (hooks) {
await runHookEntriesAsync({ ctx, hooks, timing: 'before', anchor });
}
let result: T;
try {
result = await ctx.runBuildPhase(buildPhase, fn);
} catch (phaseError) {
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
}
}
throw phaseError;
}
if (hooks) {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: false });
}
return result;
}
async function runHookEntriesAsync<TJob extends BuildJob>({
ctx,
hooks,
timing,
anchor,
anchorFailed = false,
}: {
ctx: BuildContext<TJob>;
hooks: ParsedJobHooks;
timing: 'before' | 'after';
anchor: HookAnchorId;
anchorFailed?: boolean;
}): Promise<void> {
const hookKey: HookKey = `${timing}_${anchor}`;
const entries = hooks.hookEntriesByKey[hookKey];
if (!entries?.length) {
return;
}
// Refresh the hook context from the live BuildContext before running: env
// moves as the build runs (set-env written back below; profile env), and
// job/metadata are replaced by eas build:internal.
hooks.customBuildContext.updateEnv(ctx.env);
// Overwrite the job/metadata snapshot; NOT updateJobInformation, whose
// delta-merge would duplicate environmentSecrets on each refresh.
hooks.customBuildContext.job = ctx.job;
hooks.customBuildContext.metadata = ctx.metadata;
if (timing === 'after' && anchorFailed) {
// Mirror the engine: the anchor's failure is marked on the shared context so
// `failure()` / `success()` on after-hook steps read the phase outcome.
hooks.globalContext.markAsFailed();
}
const { failedLocally, firstError } = await executeHookStepsAsync(hooks.globalContext, entries, {
anchor,
timing,
...(timing === 'after' ? { anchorResult: anchorFailed ? 'failed' : 'success' } : null),
});
// Reverse write-back BEFORE propagating a failure: env set by the entries that
// did run must reach the build context even when a later entry failed, so the
// native phases (and the outer lifecycle hooks) see it. Hook-carrying builds
// are git-based, so updateEnv's gate does not fire here.
ctx.updateEnv(hooks.globalContext.env);
if (failedLocally) {
throw new UserError(
ErrorCode.HOOKS_ERROR,
`Hook "${hookKey}" failed: ${
firstError instanceof Error ? firstError.message : String(firstError)
}. Check the failing hook step's logs above.`,
{ cause: firstError }
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import {
BuildJob,
ErrorCode,
HookAnchorId,
HookKey,
UserError,
parseHookKey,
validateSteps,
} from '@expo/eas-build-job';
import {
BuildStep,
BuildStepGlobalContext,
HookEntry,
constructHookEntriesAsync,
validateHookStepsAsync,
} from '@expo/steps';
import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
export interface ParsedJobHooks {
globalContext: BuildStepGlobalContext;
// The hook runner re-syncs this from the live BuildContext and runs the
// hooks against it.
customBuildContext: CustomBuildContext;
// One HookEntry per authored hook step (a `uses:` group's whole expansion
// stays inside one entry). The engine evaluates each entry's `if:` once and
// isolates within-entry failures, so the boundary must survive to execution.
hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>>;
}
/**
* Parses the native job's `hooks` into engine-ready entries, once, at setup
* time. Shape-validates every registered key (reachable or not); constructs
* entries only for the anchors this build actually wraps. Returns null when the
* job declares no hooks.
*/
export async function parseJobHooksAsync<TJob extends BuildJob>(
ctx: BuildContext<TJob>,
wrappedAnchors: readonly HookAnchorId[]
): Promise<ParsedJobHooks | null> {
const hooks = ctx.job.hooks;
if (!hooks || Object.keys(hooks).length === 0) {
return null;
}
const customBuildContext = new CustomBuildContext(ctx);
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut(ctx.logger);
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
for (const [key, steps] of Object.entries(hooks)) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
// `[]` is an explicit opt-out; validateSteps requires >= 1 step.
continue;
}
try {
validateSteps(steps);
} catch (err) {
throw new UserError(
ErrorCode.HOOKS_ERROR,
`Invalid steps in "hooks.${key}": ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
// before_ then after_) so generated step ids and output references follow the
// order steps actually run. All entries share one globalContext, so env and
// outputs accumulate across keys.
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
const orderedSteps: BuildStep[] = [];
for (const anchor of wrappedAnchors) {
for (const side of ['before', 'after'] as const) {
const key: HookKey = `${side}_${anchor}`;
const steps = hooks[key];
if (!Array.isArray(steps) || steps.length === 0) {
continue;
}
let entries: HookEntry[];
try {
entries = await constructHookEntriesAsync(globalContext, steps, {
externalFunctions,
externalFunctionGroups,
});
} catch (err) {
throw new UserError(
ErrorCode.HOOKS_ERROR,
`Failed to parse hooks.${key}: ${err instanceof Error ? err.message : String(err)}. Hooks use the same syntax as workflow steps.`,
{ cause: err }
);
}
hookEntriesByKey[key] = entries;
for (const entry of entries) {
orderedSteps.push(...entry.steps);
}
}
}
// One aggregate pass over every constructed step in execution order: unique
// ids, output references, and platform allowance. Per-key validation misses
// cross-key id collisions and would reject a legal after -> before output
// reference as a future reference.
try {
await validateHookStepsAsync(globalContext, orderedSteps);
} catch (err) {
throw new UserError(
ErrorCode.HOOKS_ERROR,
`The job's hooks are invalid: ${err instanceof Error ? err.message : String(err)}. Fix the reported step ids or output references.`,
{ cause: err }
);
}
return { globalContext, customBuildContext, hookEntriesByKey };
}
+61 -25
View File
@@ -3,6 +3,7 @@ import {
BuildJob,
BuildPhase,
BuildTrigger,
HookAnchorId,
Ios,
Job,
Platform,
@@ -14,7 +15,9 @@ import nullthrows from 'nullthrows';
import path from 'path';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal';
import { runHookableBuildPhaseAsync } from './hookableBuildPhase';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import { ParsedJobHooks, parseJobHooksAsync } from './jobHooks';
import { prepareProjectSourcesAsync } from './projectSources';
import { BuildContext } from '../context';
import { deleteXcodeEnvLocalIfExistsAsync } from '../ios/xcodeEnv';
@@ -26,7 +29,7 @@ import {
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
import { readAndLogPackageJson, readEasJsonContents, readPackageJson } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
@@ -36,7 +39,22 @@ const INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS = 30 * 60 * 1000;
class DoctorTimeoutError extends Error {}
class InstallDependenciesTimeoutError extends Error {}
export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>): Promise<void> {
/**
* Mutable holder the builder passes into setupAsync. setupAsync assigns
* `current` the moment hooks are parsed (before any hook runs), so the builder's
* finally can drain queued metric uploads even if setup fails partway through.
*/
export interface JobHooksRef {
current: ParsedJobHooks | null;
}
export async function setupAsync<TJob extends BuildJob>(
ctx: BuildContext<TJob>,
{
wrappedAnchors,
jobHooksRef,
}: { wrappedAnchors: readonly HookAnchorId[]; jobHooksRef: JobHooksRef }
): Promise<void> {
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
await retryAsync(
async () => {
@@ -81,32 +99,48 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
}
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
const expoVersion =
ctx.metadata?.sdkVersion ??
getPackageVersionFromPackageJson({
packageJson,
packageName: 'expo',
});
// Parse hooks unconditionally, after every pre-install env mutation
// (PREPARE_PROJECT, the git-based profile-env block, PRE_INSTALL_HOOK) and
// before the first wrapped phase.
jobHooksRef.current = await parseJobHooksAsync(ctx, wrappedAnchors);
const reactNativeVersion =
ctx.metadata?.reactNativeVersion ??
getPackageVersionFromPackageJson({
packageJson,
packageName: 'react-native',
});
await runHookableBuildPhaseAsync({
ctx,
hooks: jobHooksRef.current,
buildPhase: BuildPhase.INSTALL_DEPENDENCIES,
anchor: 'install_node_modules',
fn: async () => {
// Read package.json fresh here rather than reusing the READ_PACKAGE_JSON
// capture: a before_install_node_modules hook may have patched it, and the
// steps-world install already reads it fresh. Silent read — READ_PACKAGE_JSON
// stays the only logging site.
const freshPackageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
const expoVersion =
ctx.metadata?.sdkVersion ??
getPackageVersionFromPackageJson({
packageJson: freshPackageJson,
packageName: 'expo',
});
await runInstallDependenciesAsync(ctx, {
useFrozenLockfile: shouldUseFrozenLockfile({
env: ctx.env,
sdkVersion: expoVersion,
reactNativeVersion,
}),
});
const reactNativeVersion =
ctx.metadata?.reactNativeVersion ??
getPackageVersionFromPackageJson({
packageJson: freshPackageJson,
packageName: 'react-native',
});
await runInstallDependenciesAsync(ctx, {
useFrozenLockfile: shouldUseFrozenLockfile({
env: ctx.env,
sdkVersion: expoVersion,
reactNativeVersion,
}),
});
},
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
@@ -140,7 +174,9 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
}
const hasExpoPackage = !!packageJson.dependencies?.expo;
// Read fresh: a before_install_node_modules hook may have added or removed
// the expo dependency.
const hasExpoPackage = !!readPackageJson(ctx.getReactNativeProjectDirectory()).dependencies?.expo;
if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) {
await ctx.runBuildPhase(BuildPhase.RUN_EXPO_DOCTOR, async () => {
try {
-59
View File
@@ -1,59 +0,0 @@
import fetch, { FetchError } from 'node-fetch';
import { Readable } from 'stream';
import { URL } from 'url';
import { retryOnGCSUploadFailure } from './retry';
interface UploadWithSignedUrlParams {
signedUrl: GCS.SignedUrl;
srcGeneratorAsync: () => Promise<Readable>;
retryIntervalMs?: number;
retries?: number;
}
export namespace GCS {
export type SignedUrl = {
url: string;
headers: Record<string, string>;
};
export async function uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync,
retries = 2,
retryIntervalMs = 30_000,
}: UploadWithSignedUrlParams): Promise<string> {
let resp;
try {
resp = await retryOnGCSUploadFailure(
async () => {
const src = await srcGeneratorAsync();
return await fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: src,
});
},
{ retries, retryIntervalMs }
);
} catch (err: unknown) {
if (err instanceof FetchError) {
throw new Error(`Failed to upload the file, reason: ${err.code}`);
}
throw err;
}
if (!resp.ok) {
let body: string | undefined;
try {
body = await resp.text();
} catch {}
throw new Error(
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
);
}
const url = new URL(signedUrl.url);
return `${url.protocol}//${url.host}${url.pathname}`;
}
}
+4 -3
View File
@@ -1,8 +1,9 @@
import * as Builders from './builders';
import GCSLoggerStream from './gcs/LoggerStream';
import { GCS } from './gcs/client';
import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, GCS, GCSLoggerStream };
export { Builders, RemoteLoggerStream };
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export {
ArtifactToUpload,
@@ -8,17 +8,17 @@ import { Readable, Writable, pipeline } from 'stream';
import { promisify } from 'util';
import zlib from 'zlib';
import { GCS } from './client';
import { type SignedUrl, uploadWithSignedUrl } from '../storage/uploadWithSignedUrl';
type PromiseResolveFn = (value?: void | PromiseLike<void> | undefined) => void;
const pipe = promisify(pipeline);
class GCSLoggerStream extends Writable {
class RemoteLoggerStream extends Writable {
public writable = true;
private readonly logger: bunyan;
private readonly uploadMethod?: GCSLoggerStream.UploadMethod;
private readonly options: GCSLoggerStream.Options;
private readonly uploadMethod?: RemoteLoggerStream.UploadMethod;
private readonly options: RemoteLoggerStream.Options;
private readonly temporaryLogsPath: string;
private readonly temporaryCompressedLogsPath: string;
private readonly compress: string | null;
@@ -30,7 +30,7 @@ class GCSLoggerStream extends Writable {
private writePromise?: Promise<any>;
private cleanUpCalled: boolean = false;
constructor({ logger, uploadMethod, options }: GCSLoggerStream.Config) {
constructor({ logger, uploadMethod, options }: RemoteLoggerStream.Config) {
super();
this.logger = logger;
this.uploadMethod = uploadMethod;
@@ -84,7 +84,7 @@ class GCSLoggerStream extends Writable {
await fs.remove(this.temporaryLogsPath);
await fs.remove(this.temporaryCompressedLogsPath);
this.logger.info('Cleaning up GCS log stream');
this.logger.info('Cleaning up log stream');
}
public write(rec: any): boolean {
@@ -115,7 +115,7 @@ class GCSLoggerStream extends Writable {
this.buffer = this.buffer.slice(buffer.length);
this.hasChangesToUpload = true;
} catch (err) {
this.logger.error({ err, origin: 'gcs-logger' }, 'Failed to write logs to file');
this.logger.error({ err, origin: 'remote-logger' }, 'Failed to write logs to file');
} finally {
this.writePromise = undefined;
}
@@ -144,7 +144,7 @@ class GCSLoggerStream extends Writable {
return result;
})
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file to GCS');
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(result => {
this.uploadingPromise = undefined;
@@ -165,7 +165,7 @@ class GCSLoggerStream extends Writable {
);
};
return await GCS.uploadWithSignedUrl({
return await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -197,7 +197,7 @@ class GCSLoggerStream extends Writable {
}
}
namespace GCSLoggerStream {
namespace RemoteLoggerStream {
export enum CompressionMethod {
GZIP = 'gzip',
BR = 'br',
@@ -209,7 +209,7 @@ namespace GCSLoggerStream {
}
export type UploadMethod = {
signedUrl: GCS.SignedUrl;
signedUrl: SignedUrl;
};
export interface Config {
@@ -219,4 +219,4 @@ namespace GCSLoggerStream {
}
}
export default GCSLoggerStream;
export default RemoteLoggerStream;
@@ -7,6 +7,7 @@ import {
isFileAttrRun,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
@@ -14,6 +15,33 @@ import {
parseMaestroResultsFromFileAttrs,
} from '../maestroResultParser';
describe(parseFailedFlowNamesFromJUnitFile, () => {
it('returns the names of failed testcases, keeping slashes', async () => {
vol.fromJSON({
'/attempt/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="3" failures="2">',
' <testcase name="home" status="SUCCESS"/>',
' <testcase name="sub/login" status="ERROR"><failure>boom</failure></testcase>',
' <testcase name="checkout" status="ERROR"><failure>boom</failure></testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const failed = await parseFailedFlowNamesFromJUnitFile(
'/attempt/android-maestro-junit-attempt-0.xml'
);
expect([...failed].sort()).toEqual(['checkout', 'sub/login']);
});
it('returns an empty set when the file cannot be read', async () => {
expect((await parseFailedFlowNamesFromJUnitFile('/missing.xml')).size).toBe(0);
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with name→path map', async () => {
vol.fromJSON({
@@ -80,6 +80,7 @@ describe(harvestFailureScreenshotsAsync, () => {
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
failedFlowNames: new Set(),
logger,
});
@@ -113,6 +114,7 @@ describe(harvestFailureScreenshotsAsync, () => {
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
failedFlowNames: new Set(),
logger,
});
@@ -136,6 +138,7 @@ describe(harvestFailureScreenshotsAsync, () => {
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
failedFlowNames: new Set(),
logger,
});
@@ -154,6 +157,7 @@ describe(harvestFailureScreenshotsAsync, () => {
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
failedFlowNames: new Set(),
logger,
});
@@ -175,6 +179,7 @@ describe(harvestFailureScreenshotsAsync, () => {
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
failedFlowNames: new Set(),
logger,
});
@@ -186,11 +191,304 @@ describe(harvestFailureScreenshotsAsync, () => {
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
capturedSinceMs: 0,
attemptIndex: 0,
failedFlowNames: new Set(),
logger,
});
expect(shots).toEqual([]);
});
// --- Maestro >= 2.7.0 bundle layout: <session>/<flow>[-shard-N]/screenshots/step-<NNN>-<slug>.png ---
async function makeBundle(args: {
sessionDir: string;
flowDir: string;
stepFiles: { name: string; mtimeMs: number }[];
sessionDirMtimeMs: number;
}): Promise<void> {
const sessionDir = path.join(testsDirectory, args.sessionDir);
const screenshotsDir = path.join(sessionDir, args.flowDir, 'screenshots');
await fs.mkdir(screenshotsDir, { recursive: true });
for (const { name, mtimeMs } of args.stepFiles) {
const filePath = path.join(screenshotsDir, name);
await fs.writeFile(filePath, '');
const when = new Date(mtimeMs);
await fs.utimes(filePath, when, when);
}
const dirWhen = new Date(args.sessionDirMtimeMs);
await fs.utimes(sessionDir, dirWhen, dirWhen);
}
it('picks the highest step-NNN screenshot in a failed flow bundle (mtime as capturedAtMs)', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: '2026-01-01_100000',
flowDir: 'Login Flow',
stepFiles: [
{ name: 'step-001-launchApp.png', mtimeMs: since + 1_000 },
{ name: 'step-003-assertVisible-Welcome.png', mtimeMs: since + 3_000 },
],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Login Flow']),
logger,
});
expect(shots).toEqual([
{
fileAbsPath: path.join(
testsDirectory,
'2026-01-01_100000',
'Login Flow',
'screenshots',
'step-003-assertVisible-Welcome.png'
),
displayName: 'Failure Screenshot: Login Flow (attempt 1)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login Flow',
attemptIndex: 0,
capturedAtMs: since + 3_000,
},
},
]);
});
it('ignores final.png and non-step pngs in the bundle', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Flow',
stepFiles: [
{ name: 'step-002-tapOn.png', mtimeMs: since + 2_000 },
{ name: 'final.png', mtimeMs: since + 9_000 },
],
sessionDirMtimeMs: since + 9_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Flow']),
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].fileAbsPath.endsWith('step-002-tapOn.png')).toBe(true);
});
it('harvests only flows that JUnit reports as failed', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Failed Flow',
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
// A passing flow can still leave a warned-step screenshot; it must not be harvested.
await makeBundle({
sessionDir: 'session',
flowDir: 'Passed Flow',
stepFiles: [{ name: 'step-001-assertVisible.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Failed Flow']),
logger,
});
expect(shots.map(shot => shot.metadata.flowName)).toEqual(['Failed Flow']);
});
it('strips the -shard-N suffix to match the JUnit flow name', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Checkout-shard-2',
stepFiles: [{ name: 'step-004-tapOn-Pay.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Checkout']),
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Checkout');
});
it('resolves a combined shard + collision dir name to the flow', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Checkout-shard-2-3',
stepFiles: [{ name: 'step-004-tapOn-Pay.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Checkout']),
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Checkout');
});
it('normalizes / to _ when matching the bundle dir to the JUnit flow name', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'sub_Login',
stepFiles: [{ name: 'step-002-tapOn.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['sub/Login']),
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('sub_Login');
});
it('skips a bundle whose dir name is ambiguous against the failed set', async () => {
const since = 1_000_000;
// 'foo-2' could be the literal flow 'foo-2' or a collision-suffixed 'foo' — unresolvable.
await makeBundle({
sessionDir: 'session',
flowDir: 'foo-2',
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['foo', 'foo-2']),
logger,
});
expect(shots).toEqual([]);
});
it('skips a step screenshot captured before capturedSinceMs (dir touched later)', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Flow',
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since - 5_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Flow']),
logger,
});
expect(shots).toEqual([]);
});
it('yields nothing for a failed flow whose screenshots dir is empty', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Flow',
stepFiles: [],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Flow']),
logger,
});
expect(shots).toEqual([]);
});
it('reduces two bundle dirs that resolve to the same flow to a single shot', async () => {
const since = 1_000_000;
await makeBundle({
sessionDir: 'session',
flowDir: 'Dup',
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
sessionDirMtimeMs: since + 5_000,
});
await makeBundle({
sessionDir: 'session',
flowDir: 'Dup-2',
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 2_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Dup']),
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Dup');
});
it('collects both a legacy flat screenshot and a new-layout bundle in the same session dir', async () => {
const since = 1_000_000;
const sessionDir = path.join(testsDirectory, 'session');
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
path.join(sessionDir, `screenshot-❌-${since + 1_000}-(Legacy Flow).png`),
''
);
await makeBundle({
sessionDir: 'session',
flowDir: 'Bundle Flow',
stepFiles: [{ name: 'step-002-tapOn.png', mtimeMs: since + 2_000 }],
sessionDirMtimeMs: since + 5_000,
});
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: since,
attemptIndex: 0,
failedFlowNames: new Set(['Bundle Flow']),
logger,
});
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual([
'Bundle Flow',
'Legacy Flow',
]);
});
});
describe(computePureFailureFlowNames, () => {
@@ -122,6 +122,18 @@ async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]>
}
}
// Raw names (slashes kept) of the testcases a single JUnit file marks failed. Best-effort: an
// unreadable or malformed file yields an empty set rather than throwing.
export async function parseFailedFlowNamesFromJUnitFile(junitFile: string): Promise<Set<string>> {
const failed = new Set<string>();
for (const testcase of await parseJUnitFile(junitFile)) {
if (testcase.status === 'failed') {
failed.add(testcase.name);
}
}
return failed;
}
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
@@ -8,11 +8,20 @@ export interface ParsedFailureScreenshot {
capturedAtMs: number;
}
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
// pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
// `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
// `).png` suffix rather than scanning for parens.
// `).png` suffix rather than scanning for parens. Remove this path once the build fleet is
// entirely on Maestro >= 2.7.0.
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
// Maestro >= 2.7.0 bundle layout. Each flow gets its own dir under the session dir, with step
// screenshots under `screenshots/` named `step-<NNN>-<slug>.png` (NNN is a 1-based, zero-padded
// monotonic step sequence). Under the CLI (no `--analyze`) only failed/warned steps produce one.
const STEP_SCREENSHOTS_DIRNAME = 'screenshots';
const STEP_SCREENSHOT_PATTERN = /^step-(\d+)(?:-.*)?\.png$/;
// pre-v2.7.0: parses the legacy flat failure-screenshot filename.
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
if (!match) {
@@ -41,65 +50,212 @@ export interface HarvestedScreenshot {
};
}
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
// and skipped so a screenshot harvest can't affect the maestro step outcome.
// Harvests one attempt's failure screenshots from the maestro debug output. Walks each session
// dir under testsDirectory whose mtime is recent enough (mtime-based to also cover the maestro
// <2.5.0 bug that splits one invocation across two dirs). Within a session dir, a child is either
// a flat pre-v2.7.0 screenshot file or a Maestro >= 2.7.0 per-flow bundle dir — the single
// structural seam below. Never throws: an unreadable dir/file is logged and skipped so a
// screenshot harvest can't affect the maestro step outcome.
export async function harvestFailureScreenshotsAsync(args: {
testsDirectory: string;
capturedSinceMs: number;
attemptIndex: number;
// Flow names the JUnit report marks failed for THIS attempt (raw; normalized here). A v2.7.0
// bundle dir carries no failure marker, so this is what tells a failed flow's bundle apart from
// a passing flow that merely left a warned-step screenshot. Unused by the legacy path.
failedFlowNames: ReadonlySet<string>;
logger: bunyan;
}): Promise<HarvestedScreenshot[]> {
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate per file is what
// prevents mis-attributing an earlier attempt's screenshot.
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
let entries: Dirent[];
// Bundle dir names already have '/' -> '_'; normalize the JUnit names the same way to match.
const normalizedFailedFlowNames = new Set([...args.failedFlowNames].map(normalizeFlowName));
let sessionEntries: Dirent[];
try {
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
sessionEntries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
} catch (err: any) {
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
return [];
}
const results: HarvestedScreenshot[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
const legacyShots: HarvestedScreenshot[] = [];
const bundleShots: HarvestedScreenshot[] = [];
for (const sessionEntry of sessionEntries) {
if (!sessionEntry.isDirectory()) {
continue;
}
const dirPath = path.join(args.testsDirectory, entry.name);
let filenames: string[];
const sessionDir = path.join(args.testsDirectory, sessionEntry.name);
let children: Dirent[];
try {
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
if ((await fs.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
continue;
}
filenames = await fs.readdir(dirPath);
children = await fs.readdir(sessionDir, { withFileTypes: true });
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
continue;
}
for (const filename of filenames) {
const parsed = parseFailureScreenshotFilename(filename);
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
// earlier attempt's screenshot to this one.
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
continue;
}
results.push({
fileAbsPath: path.join(dirPath, filename),
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName: parsed.flowName,
for (const child of children) {
if (child.isDirectory()) {
// Maestro >= 2.7.0: a per-flow bundle dir.
const shot = await harvestFromBundleAsync({
bundleDir: path.join(sessionDir, child.name),
bundleName: child.name,
normalizedFailedFlowNames,
capturedSinceMs: args.capturedSinceMs,
attemptIndex: args.attemptIndex,
capturedAtMs: parsed.capturedAtMs,
},
});
logger: args.logger,
});
if (shot !== null) {
bundleShots.push(shot);
}
} else {
// pre-v2.7.0: a flat failure screenshot file in the session dir.
const shot = harvestLegacyFlatShot({
fileName: child.name,
sessionDir,
capturedSinceMs: args.capturedSinceMs,
attemptIndex: args.attemptIndex,
});
if (shot !== null) {
legacyShots.push(shot);
}
}
}
}
return results;
// The website shows one screenshot per (flow, attempt). Two v2.7.0 bundle dirs can resolve to
// the same flow — a collision dir (`<flow>-2`) or a duplicate flow name — so keep the latest.
// Legacy shots need no dedupe: a flat filename is emitted once per failed flow, so there is no
// `<flow>-2` collision to collapse (and deduping them would change historical behavior).
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
}
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
// lower numbers. Best-effort limitation: without reading Maestro's per-command JSON we can't tell a
// failed-step frame from a warned-step frame, so if the failing command captured nothing (e.g. a
// non-visible command like runScript) but an earlier optional step warned, we return that warned
// frame instead. Returns null when the bundle isn't a failed flow, its name is ambiguous, or it has
// no usable step screenshot. Never throws.
async function harvestFromBundleAsync(args: {
bundleDir: string;
bundleName: string;
normalizedFailedFlowNames: ReadonlySet<string>;
capturedSinceMs: number;
attemptIndex: number;
logger: bunyan;
}): Promise<HarvestedScreenshot | null> {
const flowName = resolveBundleFlowName(args.bundleName, args.normalizedFailedFlowNames);
if (flowName === null) {
return null;
}
const screenshotsDir = path.join(args.bundleDir, STEP_SCREENSHOTS_DIRNAME);
let filenames: string[];
try {
filenames = await fs.readdir(screenshotsDir);
} catch {
// No screenshots/ dir — e.g. the flow failed on a non-visible command, which captures none.
return null;
}
let best: { fileAbsPath: string; stepIndex: number; capturedAtMs: number } | null = null;
for (const filename of filenames) {
const match = filename.match(STEP_SCREENSHOT_PATTERN);
if (match === null) {
continue; // final.png and any non-step file
}
const stepIndex = Number(match[1]);
const fileAbsPath = path.join(screenshotsDir, filename);
let capturedAtMs: number;
try {
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
continue;
}
if (capturedAtMs < args.capturedSinceMs) {
continue; // a prior attempt's frame (bundle dir touched later than this attempt started)
}
if (best === null || stepIndex > best.stepIndex) {
best = { fileAbsPath, stepIndex, capturedAtMs };
}
}
if (best === null) {
return null;
}
return {
fileAbsPath: best.fileAbsPath,
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex: args.attemptIndex,
capturedAtMs: best.capturedAtMs,
},
};
}
// Maestro names a bundle dir `<cleanFlow>` optionally + `-shard-<n>` (sharded) + `-<n>` (name
// collision), where cleanFlow is the flow name with '/' -> '_'. Those suffixes share a namespace
// with legitimate flow names, so we can't strip blindly (a flow may be named `x-shard-1` or `x-2`).
// Enumerate the ways the dir name could peel back to a flow name, keep those that match a failed
// flow, and use the result only if exactly one fits — otherwise skip rather than guess.
function resolveBundleFlowName(
bundleName: string,
normalizedFailedFlowNames: ReadonlySet<string>
): string | null {
const withoutCollision = bundleName.replace(/-\d+$/, '');
const preimages = [
bundleName,
withoutCollision,
withoutCollision.replace(/-shard-\d+$/, ''),
bundleName.replace(/-shard-\d+$/, ''),
];
const matches = new Set(preimages.filter(name => normalizedFailedFlowNames.has(name)));
return matches.size === 1 ? [...matches][0] : null;
}
// Keep one screenshot per flow name (attemptIndex is constant within a harvest call), preferring
// the latest capture when two bundles resolve to the same flow.
function dedupeBundleShotsByFlowName(shots: HarvestedScreenshot[]): HarvestedScreenshot[] {
const byFlowName = new Map<string, HarvestedScreenshot>();
for (const shot of shots) {
const existing = byFlowName.get(shot.metadata.flowName);
if (existing === undefined || shot.metadata.capturedAtMs > existing.metadata.capturedAtMs) {
byFlowName.set(shot.metadata.flowName, shot);
}
}
return [...byFlowName.values()];
}
// pre-v2.7.0 (Maestro <= 2.6.x) reader: a flat `screenshot-...❌...png` file whose name carries the
// flow name and capture epoch.
function harvestLegacyFlatShot(args: {
fileName: string;
sessionDir: string;
capturedSinceMs: number;
attemptIndex: number;
}): HarvestedScreenshot | null {
const parsed = parseFailureScreenshotFilename(args.fileName);
// Re-check capture time per file — a dir's mtime can advance after this attempt began, so the dir
// gate alone would re-attribute an earlier attempt's screenshot to this one.
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
return null;
}
return {
fileAbsPath: path.join(args.sessionDir, args.fileName),
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName: parsed.flowName,
attemptIndex: args.attemptIndex,
capturedAtMs: parsed.capturedAtMs,
},
};
}
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
@@ -18,6 +18,7 @@ import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
@@ -276,11 +277,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
if (outputFormat === 'junit') {
const failedFlowNames = outputPath
? await parseFailedFlowNamesFromJUnitFile(outputPath)
: new Set<string>();
harvested.push(
...(await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
failedFlowNames,
logger,
}))
);
@@ -2,7 +2,7 @@ import { randomBytes } from 'crypto';
import fetch, { RequestInit, Response } from 'node-fetch';
import { Readable } from 'stream';
import { GCS } from '../client';
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
jest.mock('node-fetch');
@@ -27,14 +27,14 @@ class DNSError extends ErrorWithCode {
const TEST_BUCKET = 'turtle-v2-test';
function createSignedUrl(key: string, contentType: string): GCS.SignedUrl {
function createSignedUrl(key: string, contentType: string): SignedUrl {
return {
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
headers: { 'content-type': contentType },
};
}
describe('GCS client', () => {
describe('signed URL uploader', () => {
describe('uploadWithSignedUrl', () => {
it('should throw an error if upload fails', async () => {
const fetchMock = jest.mocked(fetch);
@@ -49,7 +49,7 @@ describe('GCS client', () => {
fetchMock.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -69,7 +69,7 @@ describe('GCS client', () => {
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -96,7 +96,7 @@ describe('GCS client', () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -130,7 +130,7 @@ describe('GCS client', () => {
} as Response;
})
.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -161,7 +161,7 @@ describe('GCS client', () => {
throw new DNSError('failed once');
})
.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -194,7 +194,7 @@ describe('GCS client', () => {
})
.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -235,7 +235,7 @@ describe('GCS client', () => {
})
.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -262,7 +262,7 @@ describe('GCS client', () => {
})
.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
@@ -305,7 +305,7 @@ describe('GCS client', () => {
const bufferToUpload = randomBytes(16);
const result = await GCS.uploadWithSignedUrl({
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
retryIntervalMs: 0,
@@ -8,7 +8,7 @@ type RetryOptions = {
};
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
export async function retryOnGCSUploadFailure(
export async function retryOnUploadFailure(
fn: (attemptCount: number) => Promise<Response>,
{
retries,
@@ -0,0 +1,57 @@
import fetch, { FetchError } from 'node-fetch';
import { Readable } from 'stream';
import { URL } from 'url';
import { retryOnUploadFailure } from './retry';
export type SignedUrl = {
url: string;
headers: Record<string, string>;
};
export type UploadWithSignedUrlParams = {
signedUrl: SignedUrl;
srcGeneratorAsync: () => Promise<Readable>;
retryIntervalMs?: number;
retries?: number;
};
export async function uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync,
retries = 2,
retryIntervalMs = 30_000,
}: UploadWithSignedUrlParams): Promise<string> {
let resp;
try {
resp = await retryOnUploadFailure(
async () => {
const src = await srcGeneratorAsync();
return await fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: src,
});
},
{ retries, retryIntervalMs }
);
} catch (err: unknown) {
if (err instanceof FetchError) {
throw new Error(`Failed to upload the file, reason: ${err.code}`);
}
throw err;
}
if (!resp.ok) {
let body: string | undefined;
try {
body = await resp.text();
} catch {}
throw new Error(
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
);
}
const url = new URL(signedUrl.url);
return `${url.protocol}//${url.host}${url.pathname}`;
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.0.2",
"version": "21.0.3",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "21.0.2"
"@expo/steps": "21.0.3"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.0.2",
"version": "21.0.3",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -1,13 +1,14 @@
import { HOOK_ANCHORS, parseHookKey } from '../hooks';
describe('HOOK_ANCHORS', () => {
it('contains the v1 anchors', () => {
it('contains the registered anchors', () => {
expect([...HOOK_ANCHORS].sort()).toEqual([
'checkout',
'install_node_modules',
'maestro_cloud',
'maestro_tests',
'submit',
'update',
]);
});
});
+1
View File
@@ -8,6 +8,7 @@ export enum ErrorCode {
UNKNOWN_FASTLANE_RESIGN_ERROR = 'EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR',
UNKNOWN_GRADLE_ERROR = 'EAS_BUILD_UNKNOWN_GRADLE_ERROR',
BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR',
HOOKS_ERROR = 'EAS_BUILD_HOOKS_ERROR',
}
export interface ExternalBuildError {
+1
View File
@@ -18,6 +18,7 @@ export const HOOK_ANCHORS = [
'maestro_tests',
'maestro_cloud',
'checkout',
'update',
] as const;
export type HookAnchorId = (typeof HOOK_ANCHORS)[number];
+144 -170
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.0.2",
"version": "21.0.3",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "21.0.2",
"@expo/eas-json": "21.0.2",
"@expo/eas-build-job": "21.0.3",
"@expo/eas-json": "21.0.3",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "21.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.0.2",
"@expo/steps": "21.0.3",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -0,0 +1,241 @@
import nock from 'nock';
import { makeUserMessage, streamChatResponseAsync } from '../chatClient';
const mockSpinners: { isSpinning: boolean; text: string }[] = [];
jest.mock('../../log');
jest.mock('../../ora', () => ({
ora: () => {
const spinner = {
isSpinning: true,
text: '',
start: () => spinner,
stop: () => {
spinner.isSpinning = false;
return spinner;
},
};
mockSpinners.push(spinner);
return spinner;
},
}));
const WEBSITE_ORIGIN = 'https://expo.dev';
function sseBody(frames: object[]): string {
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
'\n\n'
);
}
describe(streamChatResponseAsync, () => {
let writeSpy: jest.SpyInstance;
beforeEach(() => {
nock.cleanAll();
mockSpinners.length = 0;
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
});
afterEach(() => {
writeSpy.mockRestore();
});
it('streams text deltas to stdout and returns the full text', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
{ type: 'text-delta', id: '0', delta: 'build passed.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"session-id","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Your latest ');
expect(written).toContain('build passed.');
expect(result.text).toBe('Your latest build passed.');
});
it('neutralizes terminal control characters in streamed text deltas', async () => {
const delta = 'Safe \u001b[2Jtext\u009b31m\u0007.';
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(200, sseBody([{ type: 'text-delta', id: '0', delta }]), {
'content-type': 'text/event-stream',
});
const result = await streamChatResponseAsync({
messages: [makeUserMessage('show the build log')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Safe [2Jtext31m.');
expect(written).not.toContain('\u001b[2J');
expect(written).not.toContain('\u009b31m');
expect(written).not.toContain('\u0007');
expect(result.text).toBe(delta);
});
it('keeps the spinner active until a full line is ready instead of clearing it on the first delta', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Looking good' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-delta', id: '0', delta: ' so far.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: true,
});
const written = writeSpy.mock.calls.map(call => call[0]).join('');
// No newline arrives until the end, so nothing is flushed mid-stream: the tool activity
// is shown on the still-running spinner rather than being printed inline.
expect(mockSpinners[0].text).toContain('Looking up builds');
expect(written).not.toContain('Looking up builds');
// The buffered text is rendered in one go once the response completes.
expect(written).toContain('Looking good so far.');
expect(result.text).toBe('Looking good so far.');
});
it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start-step' },
{
type: 'tool-input-available',
toolCallId: 't1',
toolName: 'get_latest_builds',
input: { limit: 1 },
},
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
{ type: 'finish-step' },
{ type: 'start-step' },
{ type: 'text-delta', delta: 'Done.' },
{ type: 'finish-step' },
{ type: 'finish' },
])
);
const result = await streamChatResponseAsync({
messages: [makeUserMessage('builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});
expect(writeSpy).not.toHaveBeenCalled();
expect(result.text).toBe('Done.');
expect(result.toolCalls).toEqual([
{
toolName: 'get_latest_builds',
input: { limit: 1 },
output: { builds: ['b1'] },
errorText: undefined,
},
]);
expect(result.assistantMessage).toEqual(
expect.objectContaining({
role: 'assistant',
parts: [
{ type: 'step-start' },
{
type: 'tool-get_latest_builds',
toolCallId: 't1',
state: 'output-available',
input: { limit: 1 },
output: { builds: ['b1'] },
},
{ type: 'step-start' },
{ type: 'text', text: 'Done.' },
],
})
);
});
it('sends the session secret as a cookie and the account as a header', async () => {
let capturedHeaders: Record<string, string | string[]> = {};
let capturedBody: any;
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(function (_uri, body) {
capturedHeaders = this.req.headers;
capturedBody = body;
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
});
await streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});
const cookie = [capturedHeaders.cookie].flat().join('; ');
expect(cookie).toContain('io.expo.auth.sessionSecret=');
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
expect(capturedBody.messages[0].role).toBe('system');
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
expect(capturedBody.messages[1]).toEqual(
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
);
});
it('maps a 429 response to a usage-limit error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');
await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/usage limit/);
});
it('maps a 403 response to a not-enabled error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');
await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/not enabled/);
});
});
@@ -0,0 +1,77 @@
import chalk from 'chalk';
import {
createMarkdownRenderState,
renderInlineMarkdown,
renderMarkdownLine,
wrapToWidth,
} from '../renderMarkdown';
describe(renderInlineMarkdown, () => {
it('renders bold with chalk.bold', () => {
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
});
it('renders inline code with chalk.cyan', () => {
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
});
it('leaves underscores inside a word untouched', () => {
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
});
it('renders links as underlined label with dimmed url', () => {
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
);
});
});
describe(renderMarkdownLine, () => {
it('renders bullet lines with a bullet glyph', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
});
it('drops code fences and dims code block contents', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('```ts', state)).toBeNull();
expect(state.inCodeBlock).toBe(true);
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
expect(renderMarkdownLine('```', state)).toBeNull();
expect(state.inCodeBlock).toBe(false);
});
it('applies inline styling to plain lines', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('the **latest** build', state)).toBe(
`the ${chalk.bold('latest')} build`
);
});
});
describe(wrapToWidth, () => {
it('word-wraps to the given width', () => {
const segments = wrapToWidth('one two three four five', 12);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(12);
}
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
});
it('hard-breaks long unbreakable tokens like URLs', () => {
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
const segments = wrapToWidth(url, 20);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(20);
}
expect(segments.join('')).toBe(url);
});
it('returns the text unchanged when the width is too small to wrap into', () => {
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
});
});
@@ -0,0 +1,51 @@
import { PassThrough } from 'node:stream';
import { createChatReplInput } from '../replInput';
describe(createChatReplInput, () => {
it('resolves the typed line', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
const pending = replInput.askAsync('> ');
input.write('how are my builds?\n');
expect(await pending).toBe('how are my builds?');
replInput.close();
});
it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
input.pause();
const pending = replInput.askAsync('> ');
input.write('hello\n');
expect(await pending).toBe('hello');
replInput.close();
});
it('resolves null when the input stream ends', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
const pending = replInput.askAsync('> ');
input.end();
expect(await pending).toBeNull();
replInput.close();
});
it('resolves null immediately once closed', async () => {
const input = new PassThrough();
const output = new PassThrough();
const replInput = createChatReplInput({ input, output, terminal: false });
replInput.close();
expect(await replInput.askAsync('> ')).toBeNull();
});
});
+449
View File
@@ -0,0 +1,449 @@
import { StringDecoder } from 'node:string_decoder';
import chalk from 'chalk';
import { v4 as uuidv4 } from 'uuid';
import { getExpoWebsiteBaseUrl } from '../api';
import fetch, { RequestError } from '../fetch';
import Log from '../log';
import { ora } from '../ora';
import {
MarkdownRenderState,
createMarkdownRenderState,
renderMarkdownLine,
wrapToWidth,
} from './renderMarkdown';
const CHAT_SYSTEM_GUIDANCE = [
'You are being used through the EAS CLI in a terminal, not the web dashboard.',
'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.',
'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.',
'Use only simple markdown: bold, inline code, and bullet or numbered lists.',
].join(' ');
function getWebsiteAuthCookieName(): string {
if (process.env.EXPO_STAGING) {
return 'staging.expo.auth.sessionSecret';
} else if (process.env.EXPO_LOCAL) {
return 'local.expo.auth.sessionSecret';
} else {
return 'io.expo.auth.sessionSecret';
}
}
const TOOL_LABELS: Record<string, string> = {
get_projects: 'Looking up your projects',
get_latest_builds: 'Looking up builds',
get_build: 'Looking up a build',
get_build_logs: 'Reading build logs',
get_activity: 'Looking up recent activity',
get_updates: 'Looking up updates',
get_workflow_runs: 'Looking up workflow runs',
get_workflow_run: 'Looking up a workflow run',
get_workflow_run_logs: 'Reading workflow logs',
get_submissions: 'Looking up submissions',
get_channels: 'Looking up channels',
get_branches: 'Looking up branches',
get_worker_deployments: 'Looking up deployments',
get_environment_variables: 'Looking up environment variables',
get_account_members: 'Looking up account members',
get_usage: 'Looking up usage',
get_usage_history: 'Looking up usage history',
get_invoices: 'Looking up invoices',
search_expo_docs: 'Searching Expo docs',
navigate: 'Finding the right page',
};
function describeTool(toolName: string): string {
return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`;
}
const ASSISTANT_LABEL_TEXT = 'Expo';
const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`;
const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length);
const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`;
type UIMessageStreamFrame = {
type: string;
id?: string;
delta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
type ChatMessagePart =
| { type: 'text'; text: string }
| { type: 'step-start' }
| {
type: `tool-${string}`;
toolCallId: string;
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
input: unknown;
output?: unknown;
errorText?: string;
};
export type ChatMessage = {
id: string;
role: 'system' | 'user' | 'assistant';
parts: ChatMessagePart[];
};
function makeSystemMessage(text: string): ChatMessage {
return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] };
}
export function makeUserMessage(text: string): ChatMessage {
return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] };
}
function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage {
return { id: uuidv4(), role: 'assistant', parts };
}
export type ChatToolCall = {
toolName: string;
input?: unknown;
output?: unknown;
errorText?: string;
};
export type ChatResult = {
text: string;
toolCalls: ChatToolCall[];
assistantMessage: ChatMessage;
};
export async function streamChatResponseAsync({
messages,
accountName,
sessionSecret,
stream,
}: {
messages: ChatMessage[];
accountName: string | undefined;
sessionSecret: string;
stream: boolean;
}): Promise<ChatResult> {
let response;
try {
response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`,
...(accountName ? { 'x-account-name': accountName } : {}),
},
body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }),
});
} catch (error) {
throw toFriendlyChatError(error);
}
if (!response.body) {
throw new Error('The chat service returned an empty response.');
}
const spinner = stream
? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start()
: undefined;
let spinnerStopped = false;
const stopSpinner = (): void => {
if (!spinner || spinnerStopped) {
return;
}
spinnerStopped = true;
spinner.stop();
if (process.stdout.isTTY) {
process.stdout.write('\r');
}
};
const toolMessagePartsById = new Map<string, Extract<ChatMessagePart, { toolCallId: string }>>();
const textMessagePartsById = new Map<string, Extract<ChatMessagePart, { type: 'text' }>>();
const assistantMessageParts: ChatMessagePart[] = [];
const announcedTools = new Set<string>();
const markdownState: MarkdownRenderState = createMarkdownRenderState();
let fullText = '';
let streamingText = false;
let errorText: string | undefined;
let buffer = '';
let displayBuffer = '';
let wroteAssistantLine = false;
let currentTextMessagePart: Extract<ChatMessagePart, { type: 'text' }> | undefined;
const getOrCreateToolMessagePart = (
toolCallId: string,
toolName: string
): Extract<ChatMessagePart, { toolCallId: string }> => {
const existing = toolMessagePartsById.get(toolCallId);
if (existing) {
return existing;
}
const part: Extract<ChatMessagePart, { toolCallId: string }> = {
type: `tool-${toolName}`,
toolCallId,
state: 'input-streaming',
input: undefined,
};
toolMessagePartsById.set(toolCallId, part);
assistantMessageParts.push(part);
return part;
};
const getOrCreateTextMessagePart = (
id: string | undefined
): Extract<ChatMessagePart, { type: 'text' }> => {
const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart;
if (existing) {
return existing;
}
const part: Extract<ChatMessagePart, { type: 'text' }> = { type: 'text', text: '' };
if (id) {
textMessagePartsById.set(id, part);
}
currentTextMessagePart = part;
assistantMessageParts.push(part);
return part;
};
const writeAssistantLine = (rendered: string, withNewline: boolean): void => {
if (!streamingText) {
stopSpinner();
streamingText = true;
}
const columns = process.stdout.columns ?? 0;
const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0;
const segments = wrapToWidth(rendered, width);
segments.forEach((segment, index) => {
const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL;
wroteAssistantLine = true;
const isLastSegment = index === segments.length - 1;
const needsNewline = !isLastSegment || withNewline;
process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`);
});
};
const flushDisplayLines = (flushRemainder: boolean): void => {
let newlineIndex: number;
while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) {
const rawLine = displayBuffer.slice(0, newlineIndex);
displayBuffer = displayBuffer.slice(newlineIndex + 1);
const rendered = renderMarkdownLine(rawLine, markdownState);
if (rendered !== null) {
writeAssistantLine(rendered, true);
}
}
if (flushRemainder && displayBuffer.length > 0) {
const rendered = renderMarkdownLine(displayBuffer, markdownState);
displayBuffer = '';
if (rendered !== null) {
writeAssistantLine(rendered, false);
}
}
};
const announceTool = (toolName: string): void => {
if (!stream || announcedTools.has(toolName)) {
return;
}
announcedTools.add(toolName);
const label = describeTool(toolName);
if (streamingText) {
flushDisplayLines(true);
process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`));
} else if (spinner) {
spinner.text = `${label}…`;
}
};
const handleFrame = (frame: UIMessageStreamFrame): void => {
switch (frame.type) {
case 'start-step': {
assistantMessageParts.push({ type: 'step-start' });
currentTextMessagePart = undefined;
break;
}
case 'text-start': {
currentTextMessagePart = getOrCreateTextMessagePart(frame.id);
break;
}
case 'text-delta': {
const delta = typeof frame.delta === 'string' ? frame.delta : '';
if (!delta) {
return;
}
fullText += delta;
getOrCreateTextMessagePart(frame.id).text += delta;
if (stream) {
displayBuffer += delta;
flushDisplayLines(false);
}
break;
}
case 'text-end': {
currentTextMessagePart = undefined;
break;
}
case 'tool-input-start':
case 'tool-input-available': {
const { toolCallId, toolName } = frame;
if (!toolCallId || !toolName) {
return;
}
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
if (frame.type === 'tool-input-available') {
toolPart.state = 'input-available';
toolPart.input = frame.input;
delete toolPart.output;
delete toolPart.errorText;
} else if (frame.input !== undefined) {
toolPart.input = frame.input;
}
announceTool(toolName);
break;
}
case 'tool-input-error': {
const { toolCallId, toolName } = frame;
if (!toolCallId || !toolName || typeof frame.errorText !== 'string') {
return;
}
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
toolPart.state = 'output-error';
toolPart.input = frame.input;
delete toolPart.output;
toolPart.errorText = frame.errorText;
announceTool(toolName);
break;
}
case 'tool-output-available': {
const { toolCallId } = frame;
if (!toolCallId) {
return;
}
const toolPart = toolMessagePartsById.get(toolCallId);
if (toolPart) {
toolPart.state = 'output-available';
toolPart.output = frame.output;
delete toolPart.errorText;
}
break;
}
case 'tool-output-error': {
const { toolCallId } = frame;
if (!toolCallId || typeof frame.errorText !== 'string') {
return;
}
const toolPart = toolMessagePartsById.get(toolCallId);
if (toolPart) {
toolPart.state = 'output-error';
delete toolPart.output;
toolPart.errorText = frame.errorText;
}
break;
}
case 'error': {
if (typeof frame.errorText === 'string') {
errorText = frame.errorText;
}
break;
}
}
};
const decoder = new StringDecoder('utf8');
try {
for await (const chunk of response.body) {
buffer += decoder.write(chunk as Buffer);
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith('data:')) {
continue;
}
const data = line.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
continue;
}
let frame: UIMessageStreamFrame;
try {
frame = JSON.parse(data);
} catch {
continue;
}
handleFrame(frame);
}
}
} finally {
if (stream) {
flushDisplayLines(true);
}
stopSpinner();
}
if (errorText) {
throw new Error(errorText);
}
const toolCalls: ChatToolCall[] = assistantMessageParts
.filter((part): part is Extract<ChatMessagePart, { toolCallId: string }> =>
part.type.startsWith('tool-')
)
.map(part => ({
toolName: part.type.slice('tool-'.length),
input: part.input,
output: part.output,
errorText: part.errorText,
}));
const assistantMessage = makeAssistantMessage(
assistantMessageParts.filter(
part =>
part.type === 'text' ||
part.type === 'step-start' ||
part.state === 'output-available' ||
part.state === 'output-error'
)
);
if (stream) {
if (fullText.length > 0) {
Log.newLine();
} else {
Log.log(
chalk.dim(
toolCalls.length > 0
? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.'
: 'The assistant did not return a response.'
)
);
}
}
return { text: fullText, toolCalls, assistantMessage };
}
function toFriendlyChatError(error: unknown): Error {
if (error instanceof RequestError) {
const status = error.response.status;
if (status === 401) {
return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.');
}
if (status === 403) {
return new Error(
'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.'
);
}
if (status === 429) {
return new Error(
'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.'
);
}
return new Error(`The chat request failed (HTTP ${status}).`);
}
return error instanceof Error ? error : new Error(String(error));
}
@@ -0,0 +1,43 @@
import { getConfigFilePaths } from '@expo/config';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { AppQuery } from '../graphql/queries/AppQuery';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
export async function detectCurrentProjectAsync(
graphqlClient: ExpoGraphqlClient
): Promise<{ accountName: string; label: string } | null> {
let projectDir: string;
try {
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
} catch {
return null;
}
if (!projectDir) {
return null;
}
const configPaths = getConfigFilePaths(projectDir);
if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) {
return null;
}
let projectId: unknown;
try {
const expoConfig = await getPrivateExpoConfigAsync(projectDir);
projectId = expoConfig.extra?.eas?.projectId;
} catch {
return null;
}
if (typeof projectId !== 'string' || !projectId) {
return null;
}
try {
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
return { accountName: app.ownerAccount.name, label: app.fullName };
} catch {
return null;
}
}
@@ -0,0 +1,53 @@
import chalk from 'chalk';
import wrapAnsi from 'wrap-ansi';
export function wrapToWidth(text: string, width: number): string[] {
if (width < 10) {
return [text];
}
return wrapAnsi(text, width, { hard: true, trim: false }).split('\n');
}
export type MarkdownRenderState = { inCodeBlock: boolean };
export function createMarkdownRenderState(): MarkdownRenderState {
return { inCodeBlock: false };
}
const FENCE_REGEX = /^\s*```/;
const BULLET_REGEX = /^(\s*)[-*+]\s+(.*)$/;
const ORDERED_REGEX = /^(\s*)(\d+)\.\s+(.*)$/;
const TERMINAL_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null {
const sanitizedLine = line.replace(TERMINAL_CONTROL_CHARACTERS_REGEX, '');
if (FENCE_REGEX.test(sanitizedLine)) {
state.inCodeBlock = !state.inCodeBlock;
return null;
}
if (state.inCodeBlock) {
return chalk.gray(sanitizedLine);
}
const bullet = sanitizedLine.match(BULLET_REGEX);
if (bullet) {
return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`;
}
const ordered = sanitizedLine.match(ORDERED_REGEX);
if (ordered) {
return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`;
}
return renderInlineMarkdown(sanitizedLine);
}
export function renderInlineMarkdown(text: string): string {
return text
.replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code))
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}`
)
.replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content));
}
+61
View File
@@ -0,0 +1,61 @@
import readline from 'node:readline';
export type ChatReplInput = {
askAsync(prompt: string): Promise<string | null>;
close(): void;
};
export function createChatReplInput(options?: {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
terminal?: boolean;
history?: string[];
}): ChatReplInput {
const inputStream = options?.input ?? process.stdin;
const readlineInterface = readline.createInterface({
input: inputStream,
output: options?.output ?? process.stdout,
terminal: options?.terminal ?? true,
historySize: 100,
history: options?.history,
});
let closed = false;
readlineInterface.on('close', () => {
closed = true;
});
readlineInterface.on('SIGINT', () => {
readlineInterface.close();
});
return {
async askAsync(prompt: string): Promise<string | null> {
if (closed) {
return null;
}
inputStream.resume();
return await new Promise<string | null>(resolve => {
let settled = false;
const onClose = (): void => {
if (!settled) {
settled = true;
resolve(null);
}
};
readlineInterface.once('close', onClose);
readlineInterface.question(prompt, answer => {
if (!settled) {
settled = true;
readlineInterface.removeListener('close', onClose);
resolve(answer);
}
});
});
},
close(): void {
if (!closed) {
readlineInterface.close();
}
},
};
}
@@ -0,0 +1,370 @@
import { getMockOclifConfig } from '../../__tests__/commands/utils';
import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient';
import { detectCurrentProjectAsync } from '../../chat/detectProject';
import { ChatReplInput, createChatReplInput } from '../../chat/replInput';
import * as flagsModule from '../../commandUtils/flags';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import Chat from '../chat';
jest.mock('../../chat/chatClient', () => ({
...jest.requireActual('../../chat/chatClient'),
streamChatResponseAsync: jest.fn(),
}));
jest.mock('../../chat/replInput');
jest.mock('../../chat/detectProject');
jest.mock('../../log');
jest.mock('../../utils/json');
jest.mock('../../graphql/queries/AppQuery', () => ({
AppQuery: { byFullNameAsync: jest.fn() },
}));
const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync);
const mockCreateChatReplInput = jest.mocked(createChatReplInput);
const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync);
const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult {
return {
text,
toolCalls,
assistantMessage: {
id: 'assistant-message',
role: 'assistant',
parts: [{ type: 'text', text }],
},
};
}
const emptyResult = makeChatResult('ok');
function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } {
const askAsync = jest.fn();
for (const line of lines) {
askAsync.mockResolvedValueOnce(line);
}
askAsync.mockResolvedValue(null);
const input = { askAsync, close: jest.fn() };
mockCreateChatReplInput.mockReturnValue(input as ChatReplInput);
return input;
}
function forceInteractive(): void {
jest
.spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags')
.mockReturnValue({ json: false, nonInteractive: false });
}
describe(Chat, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
mockStreamChatResponseAsync.mockResolvedValue(emptyResult);
mockDetectCurrentProjectAsync.mockResolvedValue(null);
});
function createCommand(
argv: string[],
{
sessionSecret = '{"id":"session-id","version":"1"}',
primaryAccountName = 'my-account',
}: { sessionSecret?: string | null; primaryAccountName?: string } = {}
): Chat {
const command = new Chat(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: {
actor: {
__typename: 'User',
id: 'user-id',
primaryAccount: { id: 'account-id', name: primaryAccountName },
accounts: [{ id: 'account-id', name: primaryAccountName }],
},
graphqlClient: {},
authenticationInfo: sessionSecret
? { accessToken: null, sessionSecret }
: { accessToken: 'token', sessionSecret: null },
},
});
return command;
}
it('sends the message for the primary account and does not prompt in non-interactive mode', async () => {
const command = createCommand(['how are my builds?', '--non-interactive']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('my-account');
expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}');
expect(call.stream).toBe(true);
expect(call.messages).toHaveLength(1);
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
});
it('prefers the --account flag over the primary account and skips project auto-detection', async () => {
const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']);
await command.runAsync();
expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account');
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
});
it('auto-scopes to the current directory project when no scope flags are given', async () => {
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
const command = createCommand(['is my build ok?', '--non-interactive']);
await command.runAsync();
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
it('does not auto-detect when --project is given', async () => {
mockAppByFullNameAsync.mockResolvedValue({
id: 'app1',
fullName: '@acme/mobile',
slug: 'mobile',
ownerAccount: { id: 'acc', name: 'acme' },
} as any);
const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']);
await command.runAsync();
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
});
it('resolves --project to its owner account and frames the message', async () => {
mockAppByFullNameAsync.mockResolvedValue({
id: 'app1',
fullName: '@acme/mobile',
slug: 'mobile',
ownerAccount: { id: 'acc', name: 'acme' },
} as any);
const command = createCommand([
'is my build ok?',
'--project',
'acme/mobile',
'--non-interactive',
]);
await command.runAsync();
expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile');
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
it('throws a friendly error when --project cannot be found', async () => {
mockAppByFullNameAsync.mockRejectedValue(new Error('not found'));
const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/);
});
it('continues the conversation with follow-up replies until the user types /exit', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['and my updates?', '/exit']);
const command = createCommand(['how are my builds?']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(3);
expect(secondCall.messages[1].role).toBe('assistant');
expect(secondCall.messages[2].role).toBe('user');
expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' });
expect(input.close).toHaveBeenCalled();
expect(mockCreateChatReplInput).toHaveBeenCalledWith(
expect.objectContaining({ history: ['how are my builds?'] })
);
});
it('retains tool results in the conversation history for follow-up questions', async () => {
forceInteractive();
const result = makeChatResult('The latest build passed.', [
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } },
]);
result.assistantMessage.parts = [
{ type: 'step-start' },
{
type: 'tool-get_latest_builds',
toolCallId: 't1',
state: 'output-available',
input: { limit: 1 },
output: { builds: ['b1'] },
},
{ type: 'step-start' },
{ type: 'text', text: result.text },
];
mockStreamChatResponseAsync.mockResolvedValue(result);
mockReplInput(['what SDK did it use?', '/exit']);
const command = createCommand(['how are my builds?']);
await command.runAsync();
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages[1]).toEqual(result.assistantMessage);
});
it('exits when the input stream closes (Ctrl-D)', async () => {
forceInteractive();
mockReplInput([null]);
const command = createCommand(['how are my builds?']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
});
it('keeps the interactive session alive when a request fails and re-prompts', async () => {
forceInteractive();
mockStreamChatResponseAsync
.mockRejectedValueOnce(new Error('You have reached your chat usage limit.'))
.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['try again', '/exit']);
const command = createCommand(['how are my builds?']);
await expect(command.runAsync()).resolves.toBeUndefined();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'try again' });
expect(input.close).toHaveBeenCalled();
});
it('propagates a request failure in non-interactive mode without retrying', async () => {
mockStreamChatResponseAsync.mockRejectedValue(new Error('boom'));
const command = createCommand(['hi', '--non-interactive']);
await expect(command.runAsync()).rejects.toThrow('boom');
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
});
it('starts a new conversation with /clear', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/clear', 'fresh question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' });
});
it('restores project scope after /clear', async () => {
forceInteractive();
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/clear', 'fresh question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
expect(secondCall.messages).toHaveLength(1);
expect(secondCall.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: fresh question',
});
});
it('ignores unknown slash commands and keeps prompting', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['/bogus', 'a real question', '/exit']);
const command = createCommand(['first question']);
await command.runAsync();
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({
type: 'text',
text: 'a real question',
});
});
it('emits structured JSON and does not stream or prompt when --json is passed', async () => {
mockStreamChatResponseAsync.mockResolvedValue(
makeChatResult('Your latest build passed.', [
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } },
])
);
const command = createCommand(['how are my builds?', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false);
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
message: 'how are my builds?',
account: 'my-account',
project: null,
response: 'Your latest build passed.',
toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }],
});
});
it('throws when authenticated with an access token instead of a session secret', async () => {
const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null });
await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/);
expect(mockStreamChatResponseAsync).not.toHaveBeenCalled();
});
it('requires a message argument', async () => {
const command = createCommand(['--non-interactive']);
await expect(command.runAsync()).rejects.toThrow();
});
it('opens an interactive prompt when no message is given', async () => {
forceInteractive();
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
const input = mockReplInput(['how are my builds?', '/exit']);
const command = createCommand([]);
await command.runAsync();
expect(mockCreateChatReplInput).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.messages).toHaveLength(1);
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
expect(input.close).toHaveBeenCalled();
});
it('frames the first typed message with the auto-detected project when no message is given', async () => {
forceInteractive();
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
mockReplInput(['is my build ok?', '/exit']);
const command = createCommand([]);
await command.runAsync();
const call = mockStreamChatResponseAsync.mock.calls[0][0];
expect(call.accountName).toBe('acme');
expect(call.messages[0].parts[0]).toEqual({
type: 'text',
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
});
});
});
+274
View File
@@ -0,0 +1,274 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import {
ChatMessage,
ChatResult,
makeUserMessage,
streamChatResponseAsync,
} from '../chat/chatClient';
import { detectCurrentProjectAsync } from '../chat/detectProject';
import { ChatReplInput, createChatReplInput } from '../chat/replInput';
import EasCommand from '../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log from '../log';
import { Actor } from '../user/User';
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
const EXIT_WORDS = new Set(['exit', 'quit', 'q']);
const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`;
const CHAT_HELP = [
'Commands:',
' /help Show this help',
' /clear Start a new conversation',
' /exit Quit',
].join('\n');
export default class Chat extends EasCommand {
static override description = 'ask an AI assistant about your Expo account and EAS projects';
static override hidden = true;
static override args = {
message: Args.string({
required: false,
description: 'Message to send to the assistant (omit to open an interactive prompt)',
}),
};
static override flags = {
account: Flags.string({
char: 'a',
description: 'Account to scope the conversation to (defaults to your primary account)',
}),
project: Flags.string({
char: 'p',
description:
'Project to focus the conversation on, as @account-name/project-slug or a project slug',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(Chat);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
if (!args.message && (json || nonInteractive)) {
throw new Error('Provide a message, e.g. `eas chat "how are my builds?"`.');
}
const {
loggedIn: { actor, authenticationInfo, graphqlClient },
} = await this.getContextAsync(Chat, { nonInteractive });
if (!authenticationInfo.sessionSecret) {
throw new Error(
'`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.'
);
}
const { sessionSecret } = authenticationInfo;
let accountName = flags.account ?? getDefaultAccountName(actor);
let projectLabel: string | undefined;
let projectDetectedFromDirectory = false;
if (flags.project) {
const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName);
accountName = resolved.accountName;
projectLabel = resolved.label;
} else if (!flags.account) {
const detected = await detectCurrentProjectAsync(graphqlClient);
if (detected) {
accountName = detected.accountName;
projectLabel = detected.label;
projectDetectedFromDirectory = true;
}
}
if (json) {
const messages: ChatMessage[] = [
makeUserMessage(scopeMessageToProject(args.message as string, projectLabel)),
];
const result = await streamChatResponseAsync({
messages: [...messages],
accountName,
sessionSecret,
stream: false,
});
printJsonOnlyOutput({
message: args.message,
account: accountName ?? null,
project: projectLabel ?? null,
response: result.text,
toolCalls: result.toolCalls,
});
return;
}
if (projectLabel) {
Log.log(
chalk.dim(
projectDetectedFromDirectory
? `Project: ${projectLabel} (from current directory; use --account to widen)`
: `Project: ${projectLabel}`
)
);
}
if (!nonInteractive) {
Log.log(chalk.dim('Use /help for commands, or /exit to quit.'));
}
const input = nonInteractive
? undefined
: createChatReplInput({ history: args.message ? [args.message] : [] });
const messages: ChatMessage[] = [];
if (args.message) {
Log.log(`${USER_LABEL}${args.message}`);
Log.newLine();
messages.push(makeUserMessage(scopeMessageToProject(args.message, projectLabel)));
}
try {
for (;;) {
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role === 'assistant') {
if (!input) {
break;
}
const scopeNextMessage = messages.length === 0;
const nextMessage = await readNextUserMessageAsync(
input,
messages,
projectLabel,
scopeNextMessage
);
if (nextMessage === null) {
Log.log(chalk.dim('Ending chat.'));
break;
}
messages.push(makeUserMessage(nextMessage));
}
let result: ChatResult;
try {
result = await streamChatResponseAsync({
messages: [...messages],
accountName,
sessionSecret,
stream: true,
});
} catch (error) {
if (!input) {
throw error;
}
Log.error(error instanceof Error ? error.message : String(error));
messages.pop();
continue;
}
messages.push(result.assistantMessage);
}
} finally {
input?.close();
}
}
}
async function readNextUserMessageAsync(
input: ChatReplInput,
messages: ChatMessage[],
projectLabel: string | undefined,
scopeFirstMessage = false
): Promise<string | null> {
let shouldRestoreProjectScope = scopeFirstMessage;
for (;;) {
Log.newLine();
const line = await input.askAsync(USER_LABEL);
if (line === null) {
return null;
}
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (EXIT_WORDS.has(trimmed.toLowerCase())) {
return null;
}
if (trimmed.startsWith('/')) {
const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
if (['exit', 'quit', 'q'].includes(command)) {
return null;
}
if (['clear', 'new', 'reset'].includes(command)) {
messages.length = 0;
shouldRestoreProjectScope = true;
Log.log(chalk.dim('Started a new conversation.'));
continue;
}
if (['help', 'h', '?'].includes(command)) {
Log.log(chalk.dim(CHAT_HELP));
continue;
}
Log.warn(`Unknown command "/${command}". Type /help for commands.`);
continue;
}
Log.newLine();
return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed;
}
}
function scopeMessageToProject(message: string, projectLabel: string | undefined): string {
return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message;
}
function getDefaultAccountName(actor: Actor): string | undefined {
if ('primaryAccount' in actor && actor.primaryAccount) {
return actor.primaryAccount.name;
}
return actor.accounts[0]?.name ?? undefined;
}
async function resolveProjectAsync(
graphqlClient: ExpoGraphqlClient,
projectReference: string,
fallbackAccountName: string | undefined
): Promise<{ accountName: string; label: string }> {
const parsed = parseProjectReference(projectReference);
const accountForLookup = parsed.account ?? fallbackAccountName;
if (!accountForLookup) {
throw new Error(
`Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.`
);
}
const fullName = `@${accountForLookup}/${parsed.slug}`;
let app;
try {
app = await AppQuery.byFullNameAsync(graphqlClient, fullName);
} catch {
throw new Error(`Project ${fullName} was not found or you do not have access to it.`);
}
return { accountName: app.ownerAccount.name, label: app.fullName };
}
function parseProjectReference(reference: string): { account?: string; slug: string } {
const cleaned = reference.startsWith('@') ? reference.slice(1) : reference;
const parts = cleaned.split('/');
if (parts.length === 2 && parts[0] && parts[1]) {
return { account: parts[0], slug: parts[1] };
}
return { slug: cleaned };
}
@@ -352,8 +352,8 @@ describe(EnvPull, () => {
});
describe('existing .env.local file handling', () => {
it.each(['existing-secret-value', ''])('preserves an existing secret value %j', async value => {
const existingEnvContent = `SECRET_KEY=${value}\nOTHER_VAR=other-value`;
it('preserves existing secret values when they exist', async () => {
const existingEnvContent = 'SECRET_KEY=existing-secret-value\nOTHER_VAR=other-value';
jest.mocked(fs.exists).mockImplementation(() => Promise.resolve(true));
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(existingEnvContent));
jest.mocked(confirmAsync).mockResolvedValue(true);
@@ -376,7 +376,7 @@ describe(EnvPull, () => {
const fileContent = envFileCall![1] as string;
// Should use existing secret value instead of placeholder
expect(fileContent).toContain(`SECRET_KEY=${value}`);
expect(fileContent).toContain('SECRET_KEY=existing-secret-value');
expect(fileContent).not.toContain('# SECRET_KEY=***** (secret)');
// Should log that it reused the local value
@@ -0,0 +1,654 @@
import fs from 'fs-extra';
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
import { DefaultEnvironment } from '../../../build/utils/environment';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../../graphql/generated';
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import {
parseVisibility,
promptVariableEnvironmentAsync,
promptVariableNameAsync,
promptVariableTypeAsync,
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../../utils/prompts';
import EnvSet from '../set';
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
jest.mock('../../../utils/prompts');
jest.mock('../../../utils/json');
jest.mock('fs-extra');
describe(EnvSet, () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const variableId = 'testId';
const testAccountId = 'test-account-id';
beforeEach(() => {
jest.resetAllMocks();
jest
.mocked(parseVisibility)
.mockImplementation(jest.requireActual('../../../utils/prompts').parseVisibility);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest
.mocked(EnvironmentVariableMutation.createForAppAsync)
.mockImplementation(async (_client, input, _appId) => ({
...input,
id: variableId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.String,
}));
jest
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
.mockImplementation(async (_client, input, _appId) => ({
...input,
id: variableId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Shared,
type: EnvironmentSecretType.String,
}));
jest
.mocked(EnvironmentVariableMutation.updateAsync)
.mockImplementation(async (_client, input) => ({
...input,
id: variableId,
name: 'VarName',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.String,
}));
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockImplementation(async () => []);
jest.mocked(EnvironmentVariablesQuery.sharedAsync).mockImplementation(async () => []);
});
it('creates a project variable when none exists', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--visibility',
'secret',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
type: EnvironmentSecretType.String,
},
testProjectId
);
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('accepts --variable-name and --variable-value aliases', async () => {
const command = new EnvSet(
[
'--variable-name',
'VarName',
'--variable-value',
'VarValue',
'--environment',
'production',
'--visibility',
'secret',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
type: EnvironmentSecretType.String,
},
testProjectId
);
});
it('updates an existing project variable in the same environment without --force', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--visibility',
'secret',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
const otherVariableId = 'otherId';
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
},
]);
await command.runAsync();
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
filterNames: ['VarName'],
});
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Secret,
type: undefined,
fileName: undefined,
});
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
});
it('preserves visibility when updating non-interactively without --visibility', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
const otherVariableId = 'otherId';
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.String,
visibility: EnvironmentVariableVisibility.Sensitive,
},
]);
await command.runAsync();
expect(promptVariableVisibilityAsync).not.toHaveBeenCalled();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Sensitive,
type: EnvironmentSecretType.String,
fileName: undefined,
});
});
it('preserves other environments when updating an existing project variable', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--visibility',
'secret',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
const otherVariableId = 'otherId';
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
scope: EnvironmentVariableScope.Project,
},
]);
await command.runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
visibility: EnvironmentVariableVisibility.Secret,
type: undefined,
fileName: undefined,
});
});
it('creates an account-wide variable when none exists', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--scope',
'account',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createSharedVariableAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testAccountId
);
});
it('updates an existing account-wide variable without --force', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--scope',
'account',
],
mockConfig
);
const otherVariableId = 'otherId';
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
jest
.mocked(EnvironmentVariablesQuery.sharedAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Shared,
},
]);
await command.runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: undefined,
});
expect(EnvironmentVariableMutation.createSharedVariableAsync).not.toHaveBeenCalled();
});
it('preserves other environments when updating an existing account-wide variable', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--scope',
'account',
],
mockConfig
);
const otherVariableId = 'otherId';
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
jest
.mocked(EnvironmentVariablesQuery.sharedAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
scope: EnvironmentVariableScope.Shared,
},
]);
await command.runAsync();
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
visibility: EnvironmentVariableVisibility.Public,
type: undefined,
});
});
it('accepts the environment as a positional argument', async () => {
const command = new EnvSet(
[
'development',
'--name',
'TEST_VAR',
'--value',
'test-value',
'--visibility',
'plaintext',
'--scope',
'project',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'TEST_VAR',
value: 'test-value',
environments: [DefaultEnvironment.Development],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testProjectId
);
});
it('prompts for missing arguments', async () => {
const command = new EnvSet([], mockConfig);
jest.mocked(promptVariableNameAsync).mockImplementation(async () => 'VarName');
jest.mocked(promptVariableValueAsync).mockImplementation(async () => 'VarValue');
jest
.mocked(promptVariableTypeAsync)
.mockImplementation(async () => EnvironmentSecretType.String);
jest
.mocked(promptVariableEnvironmentAsync)
// @ts-expect-error
.mockImplementation(async () => [DefaultEnvironment.Production]);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(promptVariableNameAsync).toHaveBeenCalled();
expect(promptVariableValueAsync).toHaveBeenCalled();
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testProjectId
);
});
it('preserves the file type when updating a file variable with --value but no --type', async () => {
const testFilePath = '/path/to/creds.json';
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
const testFileName = 'creds.json';
const otherVariableId = 'otherId';
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
name: 'VarName',
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.FileBase64,
},
]);
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
testFilePath,
'--environment',
'production',
'--visibility',
'secret',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
id: otherVariableId,
name: 'VarName',
value: testFileBase64,
visibility: EnvironmentVariableVisibility.Secret,
environments: [DefaultEnvironment.Production],
type: EnvironmentSecretType.FileBase64,
fileName: testFileName,
});
});
it('throws when updating a file variable with a value that is not a valid file path and no --type', async () => {
const plainStringValue = 'some-new-value';
const otherVariableId = 'otherId';
jest
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
// @ts-expect-error
.mockImplementation(async () => [
{
id: otherVariableId,
name: 'VarName',
environments: [DefaultEnvironment.Production],
scope: EnvironmentVariableScope.Project,
type: EnvironmentSecretType.FileBase64,
},
]);
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
plainStringValue,
'--environment',
'production',
'--visibility',
'secret',
'--non-interactive',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await expect(command.runAsync()).rejects.toThrow(
`Variable "VarName" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
);
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
});
it('outputs the created variable as JSON with --json and does not print a tick', async () => {
const command = new EnvSet(
[
'--name',
'VarName',
'--value',
'VarValue',
'--environment',
'production',
'--visibility',
'plaintext',
'--json',
],
mockConfig
);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue({
loggedIn: { graphqlClient },
projectId: testProjectId,
});
await command.runAsync();
expect(enableJsonOutput).toHaveBeenCalled();
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
graphqlClient,
{
name: 'VarName',
value: 'VarValue',
environments: [DefaultEnvironment.Production],
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
},
testProjectId
);
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
id: variableId,
name: 'VarName',
value: 'VarValue',
})
);
});
});
+6 -1
View File
@@ -57,7 +57,9 @@ interface CreateFlags {
}
export default class EnvCreate extends EasCommand {
static override description = 'create an environment variable for the current project or account';
static override description =
'create an environment variable for the current project or account (deprecated, use eas env:set)';
static override hidden = true;
static override args = {
environment: Args.string({
@@ -95,6 +97,9 @@ export default class EnvCreate extends EasCommand {
};
async runAsync(): Promise<void> {
Log.warn('This command is deprecated. Use eas env:set instead.');
Log.newLine();
const { args, flags } = await this.parse(EnvCreate);
const validatedFlags = this.sanitizeFlags(flags);
+9 -33
View File
@@ -1,4 +1,5 @@
import { Args, Flags } from '@oclif/core';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
@@ -11,10 +12,6 @@ import {
} from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import {
formatEnvironmentVariableDiffAsync,
getEnvironmentVariableNamesFromEnvFile,
} from '../../utils/environmentVariableDiff';
import { promptVariableEnvironmentAsync } from '../../utils/prompts';
export default class EnvPull extends EasCommand {
@@ -79,8 +76,7 @@ export default class EnvPull extends EasCommand {
}
);
const targetExists = await fs.exists(targetPath);
if (!nonInteractive && targetExists) {
if (!nonInteractive && (await fs.exists(targetPath))) {
const result = await confirmAsync({
message: `File ${targetPath} already exists. Do you want to overwrite it?`,
});
@@ -91,12 +87,9 @@ export default class EnvPull extends EasCommand {
}
let currentEnvLocal: Record<string, string> = {};
let existingVariableNames = new Set<string>();
if (targetExists) {
const currentEnvFile = await fs.readFile(targetPath, 'utf8');
({ values: currentEnvLocal, variableNames: existingVariableNames } =
getEnvironmentVariableNamesFromEnvFile(currentEnvFile));
if (await fs.exists(targetPath)) {
currentEnvLocal = dotenv.parse(await fs.readFile(targetPath, 'utf8'));
}
const filePrefix = `# Environment: ${environment.toLocaleLowerCase()}\n\n`;
@@ -110,22 +103,14 @@ export default class EnvPull extends EasCommand {
await fs.mkdir(envDir, { recursive: true });
}
const diffLog = await formatEnvironmentVariableDiffAsync({
environmentVariables,
currentEnvValues: currentEnvLocal,
existingVariableNames,
envDir,
targetExists,
});
const skippedSecretVariables: string[] = [];
const overriddenSecretVariables: string[] = [];
const overridenSecretVariables: string[] = [];
const envFileContentLines = await Promise.all(
environmentVariables.map(async (variable: EnvironmentVariableWithFileContent) => {
if (variable.visibility === EnvironmentVariableVisibility.Secret) {
if (Object.hasOwn(currentEnvLocal, variable.name)) {
overriddenSecretVariables.push(variable.name);
if (currentEnvLocal[variable.name]) {
overridenSecretVariables.push(variable.name);
return `${variable.name}=${currentEnvLocal[variable.name]}`;
}
skippedSecretVariables.push(variable.name);
@@ -146,11 +131,9 @@ export default class EnvPull extends EasCommand {
`Pulled plain text and sensitive environment variables from "${environment.toLowerCase()}" environment to ${targetPath}.`
);
if (overriddenSecretVariables.length > 0) {
if (overridenSecretVariables.length > 0) {
Log.addNewLineIfNone();
Log.log(
`Reused local values for following secrets: ${overriddenSecretVariables.join('\n')}.`
);
Log.log(`Reused local values for following secrets: ${overridenSecretVariables.join('\n')}.`);
}
if (skippedSecretVariables.length > 0) {
@@ -161,12 +144,5 @@ export default class EnvPull extends EasCommand {
)}.`
);
}
if (diffLog.length > 0) {
Log.addNewLineIfNone();
for (const line of diffLog) {
Log.log(line);
}
}
}
}
+388
View File
@@ -0,0 +1,388 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EASEnvironmentVariableScopeFlag,
EASEnvironmentVariableScopeFlagValue,
EASMultiEnvironmentFlag,
EASVariableVisibilityFlag,
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EnvironmentSecretType,
EnvironmentVariableFragment,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';
import {
getDisplayNameForProjectIdAsync,
getOwnerAccountForProjectIdAsync,
} from '../../project/projectUtils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import {
parseVisibility,
promptVariableEnvironmentAsync,
promptVariableNameAsync,
promptVariableTypeAsync,
promptVariableValueAsync,
promptVariableVisibilityAsync,
} from '../../utils/prompts';
interface RawSetFlags {
name?: string;
value?: string;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
scope: EASEnvironmentVariableScopeFlagValue;
environment?: string[];
'non-interactive': boolean;
json: boolean;
}
interface SetFlags {
name?: string;
value?: string;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
scope: EnvironmentVariableScope;
environment?: string[];
'non-interactive': boolean;
json: boolean;
}
interface ResolvedVariableDetails {
value: string;
visibility: EnvironmentVariableVisibility;
type: EnvironmentSecretType | undefined;
fileName: string | undefined;
}
export default class EnvSet extends EasCommand {
static override description =
'set (create or update) an environment variable on the current project or account';
static override args = {
environment: Args.string({
description:
"Environment to set the variable in. Default environments are 'production', 'preview', and 'development'.",
required: false,
}),
};
static override flags = {
name: Flags.string({
description: 'Name of the variable',
aliases: ['variable-name'],
}),
value: Flags.string({
description: 'Text value of the variable',
aliases: ['variable-value'],
}),
type: Flags.option({
description: 'The type of variable',
options: ['string', 'file'] as const,
})(),
...EASVariableVisibilityFlag,
...EASEnvironmentVariableScopeFlag,
...EASMultiEnvironmentFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.Analytics,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(EnvSet);
const {
name: nameFlag,
value,
type,
visibility,
scope,
environment,
} = this.sanitizeFlags(flags);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvSet, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const name = nameFlag ?? (await promptVariableNameAsync(nonInteractive));
const environments = await this.resolveEnvironmentsAsync(environment, args.environment, {
nonInteractive,
graphqlClient,
projectId,
});
const existingVariable = await this.findExistingVariableAsync(graphqlClient, {
scope,
projectId,
name,
environments,
});
const environmentsToSet = existingVariable
? [...new Set([...(existingVariable.environments ?? []), ...environments])]
: environments;
const {
value: resolvedValue,
visibility: resolvedVisibility,
type: resolvedType,
fileName,
} = await this.resolveVariableDetailsAsync(
{ name, value, type, visibility, nonInteractive },
existingVariable
);
const [projectDisplayName, ownerAccount] = await Promise.all([
getDisplayNameForProjectIdAsync(graphqlClient, projectId),
getOwnerAccountForProjectIdAsync(graphqlClient, projectId),
]);
if (scope === EnvironmentVariableScope.Project) {
const variable = existingVariable
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: existingVariable.id,
name,
value: resolvedValue,
visibility: resolvedVisibility,
environments: environmentsToSet,
type: resolvedType,
fileName,
})
: await EnvironmentVariableMutation.createForAppAsync(
graphqlClient,
{
name,
value: resolvedValue,
environments,
visibility: resolvedVisibility,
type: resolvedType ?? EnvironmentSecretType.String,
fileName,
},
projectId
);
if (!variable) {
throw new Error(
`Could not set variable with name ${name} on project ${projectDisplayName}`
);
}
if (jsonFlag) {
printJsonOnlyOutput(variable);
} else {
Log.withTick(
`${existingVariable ? 'Updated' : 'Created'} variable ${chalk.bold(name)} on project ${chalk.bold(
projectDisplayName
)}.`
);
}
} else if (scope === EnvironmentVariableScope.Shared) {
const variable = existingVariable
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
id: existingVariable.id,
name,
value: resolvedValue,
visibility: resolvedVisibility,
environments: environmentsToSet,
type: resolvedType,
})
: await EnvironmentVariableMutation.createSharedVariableAsync(
graphqlClient,
{
name,
value: resolvedValue,
visibility: resolvedVisibility,
environments,
type: resolvedType ?? EnvironmentSecretType.String,
},
ownerAccount.id
);
if (!variable) {
throw new Error(`Could not set variable with name ${name} on account ${ownerAccount.name}`);
}
if (jsonFlag) {
printJsonOnlyOutput(variable);
} else {
Log.withTick(
`${existingVariable ? 'Updated' : 'Created'} variable ${chalk.bold(name)} on account ${chalk.bold(
ownerAccount.name
)}.`
);
}
}
}
private async resolveEnvironmentsAsync(
environmentFlag: string[] | undefined,
environmentArg: string | undefined,
{
nonInteractive,
graphqlClient,
projectId,
}: { nonInteractive: boolean; graphqlClient: ExpoGraphqlClient; projectId: string }
): Promise<string[]> {
const environments = environmentFlag ?? (environmentArg ? [environmentArg] : undefined);
if (environments && environments.length > 0) {
return environments;
}
const promptedEnvironments = await promptVariableEnvironmentAsync({
nonInteractive,
multiple: true,
canEnterCustomEnvironment: true,
graphqlClient,
projectId,
});
if (!promptedEnvironments || promptedEnvironments.length === 0) {
throw new Error('No environments selected');
}
return promptedEnvironments;
}
private async findExistingVariableAsync(
graphqlClient: ExpoGraphqlClient,
{
scope,
projectId,
name,
environments,
}: {
scope: EnvironmentVariableScope;
projectId: string;
name: string;
environments: string[];
}
): Promise<EnvironmentVariableFragment | undefined> {
if (scope === EnvironmentVariableScope.Project) {
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
appId: projectId,
filterNames: [name],
});
return existingVariables.find(
variable =>
variable.scope === EnvironmentVariableScope.Project &&
variable.environments?.some(env => environments.includes(env))
);
}
const existingVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
appId: projectId,
filterNames: [name],
});
return existingVariables.find(variable =>
variable.environments?.some(env => environments.includes(env))
);
}
private async resolveVariableDetailsAsync(
{
name,
value,
type,
visibility,
nonInteractive,
}: {
name: string;
value?: string;
type?: 'string' | 'file';
visibility?: 'plaintext' | 'sensitive' | 'secret';
nonInteractive: boolean;
},
existingVariable: EnvironmentVariableFragment | undefined
): Promise<ResolvedVariableDetails> {
let newType: EnvironmentSecretType | undefined;
if (type === 'file') {
newType = EnvironmentSecretType.FileBase64;
} else if (type === 'string') {
newType = EnvironmentSecretType.String;
}
let newVisibility = visibility ? parseVisibility(visibility) : undefined;
if (!type && !value && !nonInteractive) {
newType = await promptVariableTypeAsync(nonInteractive, existingVariable?.type);
}
if (!newVisibility && !nonInteractive) {
newVisibility = await promptVariableVisibilityAsync(
nonInteractive,
existingVariable?.visibility
);
}
if (value && !newType) {
newType = existingVariable?.type;
}
if (!value) {
value = await promptVariableValueAsync({
nonInteractive,
hidden: newVisibility !== EnvironmentVariableVisibility.Public,
filePath: (newType ?? existingVariable?.type) === EnvironmentSecretType.FileBase64,
});
}
newVisibility =
newVisibility ?? existingVariable?.visibility ?? EnvironmentVariableVisibility.Public;
let fileName: string | undefined;
if (newType === EnvironmentSecretType.FileBase64) {
const environmentFilePath = path.resolve(value);
if (!(await fs.pathExists(environmentFilePath))) {
if (type === 'file') {
throw new Error(`File "${value}" does not exist`);
}
throw new Error(
`Variable "${name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
);
}
fileName = path.basename(environmentFilePath);
value = await fs.readFile(environmentFilePath, 'base64');
}
return {
value,
visibility: newVisibility,
type: newType,
fileName,
};
}
private sanitizeFlags(flags: RawSetFlags): SetFlags {
return {
...flags,
scope:
flags.scope === 'account'
? EnvironmentVariableScope.Shared
: EnvironmentVariableScope.Project,
};
}
}
+6 -1
View File
@@ -63,7 +63,9 @@ interface UpdateFlags {
}
export default class EnvUpdate extends EasCommand {
static override description = 'update an environment variable on the current project or account';
static override description =
'update an environment variable on the current project or account (deprecated, use eas env:set)';
static override hidden = true;
static override flags = {
'variable-name': Flags.string({
@@ -104,6 +106,9 @@ export default class EnvUpdate extends EasCommand {
};
async runAsync(): Promise<void> {
Log.warn('This command is deprecated. Use eas env:set instead.');
Log.newLine();
const { args, flags } = await this.parse(EnvUpdate);
const {
name,
@@ -1,194 +0,0 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
} from '../../graphql/generated';
import { EnvironmentVariableWithFileContent } from '../../graphql/queries/EnvironmentVariablesQuery';
import {
formatEnvironmentVariableDiffAsync,
getEnvironmentVariableNamesFromEnvFile,
} from '../environmentVariableDiff';
jest.mock('fs-extra');
const envDir = '/project/.eas/.env';
function variable(
name: string,
value: string | null,
overrides: Partial<EnvironmentVariableWithFileContent> = {}
): EnvironmentVariableWithFileContent {
return {
id: name,
name,
value,
environments: ['development'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scope: EnvironmentVariableScope.Project,
visibility: EnvironmentVariableVisibility.Public,
type: EnvironmentSecretType.String,
...overrides,
};
}
describe(formatEnvironmentVariableDiffAsync, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('renders a fresh pull as a neutral baseline', async () => {
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [variable('ONE', '1'), variable('TWO', '2')],
currentEnvValues: {},
existingVariableNames: new Set(),
envDir,
targetExists: false,
});
expect(result).toEqual([' ONE', ' TWO']);
});
it('renders variables as added when the target file exists but is empty', async () => {
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [variable('ONE', '1')],
currentEnvValues: {},
existingVariableNames: new Set(),
envDir,
targetExists: true,
});
expect(result).toEqual([chalk.green('+ ONE')]);
});
it('renders added, changed, unchanged, and removed string variables', async () => {
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [
variable('ADDED', 'new'),
variable('CHANGED', 'new'),
variable('UNCHANGED', 'same'),
],
currentEnvValues: { CHANGED: 'old', UNCHANGED: 'same', REMOVED: 'old' },
existingVariableNames: new Set(['CHANGED', 'UNCHANGED', 'REMOVED']),
envDir,
targetExists: true,
});
expect(result).toEqual([
chalk.green('+ ADDED'),
chalk.yellow('~ CHANGED'),
' UNCHANGED',
chalk.red('- REMOVED'),
]);
});
it('keeps existing secrets neutral without claiming their values match', async () => {
const secret = variable('SECRET', null, {
visibility: EnvironmentVariableVisibility.Secret,
});
const result = await formatEnvironmentVariableDiffAsync({
environmentVariables: [
secret,
variable('NEW_SECRET', null, {
visibility: EnvironmentVariableVisibility.Secret,
}),
],
currentEnvValues: {},
existingVariableNames: new Set(['SECRET']),
envDir,
targetExists: true,
});
expect(result).toEqual([' SECRET', chalk.green('+ NEW_SECRET')]);
});
it('only considers a file unchanged when its output path and contents match', async () => {
const fileContents = Buffer.from('contents').toString('base64');
const fileVariable = variable('FILE', null, {
type: EnvironmentSecretType.FileBase64,
valueWithFileContent: fileContents,
});
const expectedFilePath = path.join(envDir, fileVariable.name);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.pathExists).mockResolvedValue(true);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.readFile).mockResolvedValue(fileContents);
await expect(
formatEnvironmentVariableDiffAsync({
environmentVariables: [fileVariable],
currentEnvValues: { FILE: expectedFilePath },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
})
).resolves.toEqual([' FILE']);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.readFile).mockResolvedValue(Buffer.from('different').toString('base64'));
await expect(
formatEnvironmentVariableDiffAsync({
environmentVariables: [fileVariable],
currentEnvValues: { FILE: expectedFilePath },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
})
).resolves.toEqual([chalk.yellow('~ FILE')]);
await expect(
formatEnvironmentVariableDiffAsync({
environmentVariables: [fileVariable],
currentEnvValues: { FILE: '/some/other/file' },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
})
).resolves.toEqual([chalk.yellow('~ FILE')]);
});
it('reports missing or unreadable file variables as changed instead of failing', async () => {
const fileVariable = variable('FILE', null, {
type: EnvironmentSecretType.FileBase64,
valueWithFileContent: Buffer.from('contents').toString('base64'),
});
const expectedFilePath = path.join(envDir, fileVariable.name);
const options = {
environmentVariables: [fileVariable],
currentEnvValues: { FILE: expectedFilePath },
existingVariableNames: new Set(['FILE']),
envDir,
targetExists: true,
};
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.pathExists).mockResolvedValue(false);
await expect(formatEnvironmentVariableDiffAsync(options)).resolves.toEqual([
chalk.yellow('~ FILE'),
]);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.pathExists).mockResolvedValue(true);
// @ts-expect-error fs-extra overloads are not inferred correctly by Jest.
jest.mocked(fs.readFile).mockRejectedValue(new Error('EACCES'));
await expect(formatEnvironmentVariableDiffAsync(options)).resolves.toEqual([
chalk.yellow('~ FILE'),
]);
});
});
describe(getEnvironmentVariableNamesFromEnvFile, () => {
it('includes secret placeholders in the existing variable names', () => {
const result = getEnvironmentVariableNamesFromEnvFile(
['PLAIN=value', '# SECRET=***** (secret)', '# unrelated comment'].join('\n')
);
expect(result.values).toEqual({ PLAIN: 'value' });
expect(result.variableNames).toEqual(new Set(['PLAIN', 'SECRET']));
});
});
@@ -1,103 +0,0 @@
import chalk from 'chalk';
import dotenv from 'dotenv';
import fs from 'fs-extra';
import path from 'path';
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../graphql/generated';
import { EnvironmentVariableWithFileContent } from '../graphql/queries/EnvironmentVariablesQuery';
interface EnvironmentVariableDiffOptions {
environmentVariables: EnvironmentVariableWithFileContent[];
currentEnvValues: Record<string, string>;
existingVariableNames: Set<string>;
envDir: string;
targetExists: boolean;
}
export function getEnvironmentVariableNamesFromEnvFile(contents: string): {
values: Record<string, string>;
variableNames: Set<string>;
} {
const values = dotenv.parse(contents);
const variableNames = new Set(Object.keys(values));
const secretPlaceholderPattern = /^#\s+([^=\s]+)=\*{5}\s+\(secret\)\s*$/gm;
for (const match of contents.matchAll(secretPlaceholderPattern)) {
variableNames.add(match[1]);
}
return { values, variableNames };
}
async function isFileVariableUnchangedAsync(
currentEnvValue: string | undefined,
newVariable: EnvironmentVariableWithFileContent,
envDir: string
): Promise<boolean> {
const expectedFilePath = path.join(envDir, newVariable.name);
if (currentEnvValue !== expectedFilePath || !newVariable.valueWithFileContent) {
return false;
}
try {
if (!(await fs.pathExists(currentEnvValue))) {
return false;
}
return (await fs.readFile(currentEnvValue, 'base64')) === newVariable.valueWithFileContent;
} catch {
// Diff rendering should never prevent env:pull from replacing an unreadable file.
return false;
}
}
async function isEnvironmentVariableUnchangedAsync(
currentEnvValue: string | undefined,
newVariable: EnvironmentVariableWithFileContent,
envDir: string
): Promise<boolean> {
if (newVariable.visibility === EnvironmentVariableVisibility.Secret) {
// env:pull preserves an existing local secret; it cannot compare it with the remote value.
return true;
}
if (newVariable.type === EnvironmentSecretType.FileBase64) {
return await isFileVariableUnchangedAsync(currentEnvValue, newVariable, envDir);
}
return currentEnvValue === newVariable.value;
}
export async function formatEnvironmentVariableDiffAsync({
environmentVariables,
currentEnvValues,
existingVariableNames,
envDir,
targetExists,
}: EnvironmentVariableDiffOptions): Promise<string[]> {
if (!targetExists) {
return environmentVariables.map(variable => ` ${variable.name}`);
}
const newVariablesByName = new Map(
environmentVariables.map(variable => [variable.name, variable])
);
const diffLog: string[] = [];
for (const variable of environmentVariables) {
if (!existingVariableNames.has(variable.name)) {
diffLog.push(chalk.green(`+ ${variable.name}`));
} else if (
await isEnvironmentVariableUnchangedAsync(currentEnvValues[variable.name], variable, envDir)
) {
diffLog.push(` ${variable.name}`);
} else {
diffLog.push(chalk.yellow(`~ ${variable.name}`));
}
}
for (const variableName of existingVariableNames) {
if (!newVariablesByName.has(variableName)) {
diffLog.push(chalk.red(`- ${variableName}`));
}
}
return diffLog;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "21.0.2",
"version": "21.0.3",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "21.0.2",
"@expo/eas-build-job": "21.0.3",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "21.0.2",
"version": "21.0.3",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "21.0.2",
"@expo/eas-build-job": "21.0.2",
"@expo/build-tools": "21.0.3",
"@expo/eas-build-job": "21.0.3",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "21.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "21.0.2",
"version": "21.0.3",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "21.0.2",
"@expo/eas-build-job": "21.0.3",
"@expo/logger": "21.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+1 -3
View File
@@ -17,8 +17,6 @@ jest.mock('node:fs', () => jest.requireMock('fs'));
jest.mock('@expo/build-tools', () => {
return {
...jest.requireActual('@expo/build-tools'),
GCS: {
uploadWithSignedUrl: jest.fn(),
},
uploadWithSignedUrl: jest.fn(),
};
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "21.0.2",
"version": "21.0.3",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "21.0.2",
"@expo/build-tools": "21.0.3",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.2",
"@expo/eas-build-job": "21.0.3",
"@expo/logger": "21.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.0.2",
"@expo/steps": "21.0.3",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@hapi/boom": "10.0.1",
+2 -2
View File
@@ -1,4 +1,4 @@
import { BuildContext, CacheManager, GCS } from '@expo/build-tools';
import { BuildContext, CacheManager, uploadWithSignedUrl } from '@expo/build-tools';
import downloadFile from '@expo/downloader';
import { BuildJob } from '@expo/eas-build-job';
import filesize from 'filesize';
@@ -59,7 +59,7 @@ export class GCSCacheManager implements CacheManager {
}
try {
await GCS.uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: config.buildCache.gcsSignedUploadUrlForBuildCache,
srcGeneratorAsync: async () => {
return fs.createReadStream(archivePath);
@@ -1,4 +1,4 @@
import { GCS } from '@expo/build-tools';
import { uploadWithSignedUrl } from '@expo/build-tools';
import downloadFile from '@expo/downloader';
import { Cache, Platform } from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
@@ -41,7 +41,7 @@ describe(GCSCacheManager, () => {
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
jest.mocked(GCS.uploadWithSignedUrl).mockReset();
jest.mocked(uploadWithSignedUrl).mockReset();
jest.mocked(downloadFile).mockReset();
});
@@ -63,7 +63,7 @@ describe(GCSCacheManager, () => {
},
} as any;
let tarReadStream: Readable;
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
jest.mocked(uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
const result = await srcGeneratorAsync();
tarReadStream = result;
return randomUUID();
+13 -15
View File
@@ -8,16 +8,16 @@ jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
const { Writable } = jest.requireActual('node:stream');
class MockGCSLoggerStream extends Writable {
public static readonly CompressionMethod = actual.GCSLoggerStream.CompressionMethod;
public static readonly instances: MockGCSLoggerStream[] = [];
class MockRemoteLoggerStream extends Writable {
public static readonly CompressionMethod = actual.RemoteLoggerStream.CompressionMethod;
public static readonly instances: MockRemoteLoggerStream[] = [];
public readonly writes: any[] = [];
constructor(...args: any[]) {
super({ objectMode: true });
void args;
MockGCSLoggerStream.instances.push(this);
MockRemoteLoggerStream.instances.push(this);
}
public async init(): Promise<string> {
@@ -38,10 +38,8 @@ jest.mock('@expo/build-tools', () => {
return {
...actual,
GCS: {
uploadWithSignedUrl: jest.fn(),
},
GCSLoggerStream: MockGCSLoggerStream,
uploadWithSignedUrl: jest.fn(),
RemoteLoggerStream: MockRemoteLoggerStream,
};
});
@@ -65,8 +63,8 @@ async function waitForStreamFlush(): Promise<void> {
}
const fetchMock = jest.mocked(fetch);
const { GCSLoggerStream } = jest.requireMock('@expo/build-tools') as {
GCSLoggerStream: {
const { RemoteLoggerStream } = jest.requireMock('@expo/build-tools') as {
RemoteLoggerStream: {
instances: Array<{
writes: any[];
}>;
@@ -81,7 +79,7 @@ describe('logger', () => {
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('', { status: 200, statusText: 'OK' }));
GCSLoggerStream.instances.length = 0;
RemoteLoggerStream.instances.length = 0;
});
afterEach(() => {
@@ -297,13 +295,13 @@ describe('logger', () => {
await cleanUp();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(GCSLoggerStream.instances).toHaveLength(1);
expect(RemoteLoggerStream.instances).toHaveLength(1);
const gcsLogs = GCSLoggerStream.instances[0].writes;
expect(gcsLogs).toEqual(
const remoteLogs = RemoteLoggerStream.instances[0].writes;
expect(remoteLogs).toEqual(
expect.arrayContaining([expect.objectContaining({ msg: 'Actual build log' })])
);
expect(gcsLogs).not.toEqual(
expect(remoteLogs).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ msg: 'Failed to send logs batch over HTTP' }),
])
+1 -1
View File
@@ -14,7 +14,7 @@ jest.mock('@expo/build-tools', () => {
const actual = jest.requireActual('@expo/build-tools');
return {
...actual,
GCS: { uploadWithSignedUrl: jest.fn() },
uploadWithSignedUrl: jest.fn(),
Datadog: {
setup: jest.fn(),
flushAsync: jest.fn(async () => {}),
+9 -9
View File
@@ -1,4 +1,4 @@
import { BuildContext, GCS } from '@expo/build-tools';
import { BuildContext, uploadWithSignedUrl } from '@expo/build-tools';
import { Job, errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { randomBytes, randomUUID } from 'crypto';
@@ -143,7 +143,7 @@ describe(uploadApplicationArchiveAsync.name, () => {
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
@@ -225,7 +225,7 @@ describe(uploadApplicationArchiveAsync.name, () => {
}),
} as Response;
});
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
jest.mocked(uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
let thrownError: unknown;
try {
@@ -354,8 +354,8 @@ describe(uploadBuildArtifactsAsync.name, () => {
buildId: randomUUID(),
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1);
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect(uploadWithSignedUrl).toHaveBeenCalledTimes(1);
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
@@ -433,7 +433,7 @@ describe(uploadBuildArtifactsAsync.name, () => {
}),
} as Response;
});
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
jest.mocked(uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
let thrownError: unknown;
try {
@@ -525,7 +525,7 @@ describe('with signed upload url provided via www', () => {
logger: ctx.logger,
});
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
@@ -624,8 +624,8 @@ describe(uploadWorkflowArtifactAsync.name, () => {
logger: ctx.logger,
});
expect(artifactId).toBe(expectedArtifactId);
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1);
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect(uploadWithSignedUrl).toHaveBeenCalledTimes(1);
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
+5 -5
View File
@@ -1,4 +1,4 @@
import { GCS, GCSLoggerStream } from '@expo/build-tools';
import { RemoteLoggerStream, type SignedUrl } from '@expo/build-tools';
import path from 'path';
import { Environment } from './constants';
@@ -34,12 +34,12 @@ export default {
},
gcs: {
uploadIntervalMs: env('LOGGER_INTERVAL_MS', { defaultValue: 10000, transform: Number }),
compressionMethod: GCSLoggerStream.CompressionMethod.BR,
signedUploadUrlForLogs: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
compressionMethod: RemoteLoggerStream.CompressionMethod.BR,
signedUploadUrlForLogs: env<SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForLogs'),
defaultValue: null,
}),
signedUploadUrlForXcodeBuildLogs: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
signedUploadUrlForXcodeBuildLogs: env<SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForXcodeBuildLogs'),
defaultValue: null,
}),
@@ -54,7 +54,7 @@ export default {
},
},
buildCache: {
gcsSignedUploadUrlForBuildCache: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
gcsSignedUploadUrlForBuildCache: env<SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildCache'),
defaultValue: null,
}),
+5 -5
View File
@@ -1,4 +1,4 @@
import { GCS } from '@expo/build-tools';
import { type SignedUrl } from '@expo/build-tools';
import { BuildJob, Generic, Metadata, errors } from '@expo/eas-build-job';
export const androidImagesWithJavaVersionLowerThen11 = [
@@ -22,7 +22,7 @@ export namespace Worker {
}
type JobRunWorkerRuntimeConfig = {
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
gcsSignedUploadUrlForLogs: SignedUrl;
runMetricsServer: boolean;
@@ -31,9 +31,9 @@ export namespace Worker {
};
type BuildWorkerRuntimeConfig = {
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
gcsSignedUploadUrlForXcodeBuildLogs?: GCS.SignedUrl;
gcsSignedUploadUrlForBuildCache?: GCS.SignedUrl;
gcsSignedUploadUrlForLogs: SignedUrl;
gcsSignedUploadUrlForXcodeBuildLogs?: SignedUrl;
gcsSignedUploadUrlForBuildCache?: SignedUrl;
gcsSignedBuildCacheDownloadUrl?: string;
runMetricsServer: boolean;
+3 -3
View File
@@ -1,4 +1,4 @@
import { GCS } from '@expo/build-tools';
import { uploadWithSignedUrl } from '@expo/build-tools';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
@@ -14,7 +14,7 @@ export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Pr
if (!config.loggers.gcs.signedUploadUrlForXcodeBuildLogs) {
logger.warn('GCS Presigned URL for Xcode logs was not specified, skipping upload');
} else {
await GCS.uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: config.loggers.gcs.signedUploadUrlForXcodeBuildLogs,
srcGeneratorAsync: async () => {
const stat = await fs.stat(logsPath);
@@ -28,7 +28,7 @@ export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Pr
}
}
// HACK(Mike): Copypasta of a fragment of functionality from GCSLoggerStream.
// HACK(Mike): Copypasta of a fragment of functionality from RemoteLoggerStream.
async function compress(
src: { stream: Readable; length: number },
method: 'gzip' | 'br' | null
+6 -6
View File
@@ -1,4 +1,4 @@
import { GCSLoggerStream, LogBuffer } from '@expo/build-tools';
import { LogBuffer, RemoteLoggerStream } from '@expo/build-tools';
import { BuildPhase, EnvironmentSecret, Job } from '@expo/eas-build-job';
import { LoggerLevel, bunyan, createLogger } from '@expo/logger';
import { Readable, Transform, TransformCallback, Writable } from 'stream';
@@ -123,9 +123,9 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
level: buildLogger.level(),
});
let gcsLoggerStream: GCSLoggerStream | null = null;
let remoteLoggerStream: RemoteLoggerStream | null = null;
if (config.loggers.gcs.signedUploadUrlForLogs) {
gcsLoggerStream = new GCSLoggerStream({
remoteLoggerStream = new RemoteLoggerStream({
uploadMethod: { signedUrl: config.loggers.gcs.signedUploadUrlForLogs },
options: {
uploadIntervalMs: config.loggers.gcs.uploadIntervalMs,
@@ -133,8 +133,8 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
},
logger: defaultLogger,
});
transformStream.pipe(gcsLoggerStream);
await gcsLoggerStream.init();
transformStream.pipe(remoteLoggerStream);
await remoteLoggerStream.init();
}
const logBuffer = new WorkerLogBuffer(MAX_LINES_IN_BUFFER);
@@ -163,7 +163,7 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
return {
logger: buildLogger,
cleanUp: async () => {
await Promise.all([gcsLoggerStream?.cleanUp?.(), httpLogStream?.cleanUp?.()]);
await Promise.all([remoteLoggerStream?.cleanUp?.(), httpLogStream?.cleanUp?.()]);
},
outputStream: transformStream,
logBuffer,
+7 -7
View File
@@ -1,4 +1,4 @@
import { BuildContext, GCS } from '@expo/build-tools';
import { BuildContext, type SignedUrl, uploadWithSignedUrl } from '@expo/build-tools';
import { ArchiveSourceType, errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
@@ -42,7 +42,7 @@ export async function uploadApplicationArchiveAsync(
const { localPath, suffix, size } = await prepareArtifactsForUploadAsync(logger, artifactPaths);
const filename = `application-${buildId}${suffix}`;
let uploadSession: GCS.SignedUrl | null = null;
let uploadSession: SignedUrl | null = null;
try {
// Try to upload to the upload session first.
@@ -54,7 +54,7 @@ export async function uploadApplicationArchiveAsync(
uploadSession = signedUrl;
await GCS.uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => {
return fs.createReadStream(localPath);
@@ -98,7 +98,7 @@ export async function uploadBuildArtifactsAsync(
const { localPath, suffix, size } = await prepareArtifactsForUploadAsync(logger, artifactPaths);
const filename = `artifacts-${buildId}${suffix}`;
let uploadSession: GCS.SignedUrl | null = null;
let uploadSession: SignedUrl | null = null;
try {
// Try to upload to the upload session first.
@@ -110,7 +110,7 @@ export async function uploadBuildArtifactsAsync(
uploadSession = signedUrl;
await GCS.uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => {
return fs.createReadStream(localPath);
@@ -162,7 +162,7 @@ export async function uploadWorkflowArtifactAsync(
metadata,
});
await GCS.uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: uploadSession,
srcGeneratorAsync: async () => {
return fs.createReadStream(localPath);
@@ -249,7 +249,7 @@ async function createUploadSessionAsync(
}: { filename: string; name: string; size: number; metadata?: Record<string, unknown> }
): Promise<{
bucketKey: string;
signedUrl: GCS.SignedUrl;
signedUrl: SignedUrl;
storageType: ArchiveSourceType;
artifactId: string | null;
}> {
Generated
+17 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:21.0.2, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:21.0.3, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.2"
"@expo/eas-build-job": "npm:21.0.3"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:21.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.0.2"
"@expo/steps": "npm:21.0.3"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2547,7 +2547,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:21.0.2, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:21.0.3, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2566,12 +2566,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:21.0.2, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:21.0.3, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:21.0.2"
"@expo/eas-build-job": "npm:21.0.3"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2884,11 +2884,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:21.0.2, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:21.0.3, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:21.0.2"
"@expo/eas-build-job": "npm:21.0.3"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2957,16 +2957,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:21.0.2"
"@expo/build-tools": "npm:21.0.3"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:21.0.0"
"@expo/eas-build-job": "npm:21.0.2"
"@expo/eas-build-job": "npm:21.0.3"
"@expo/logger": "npm:21.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.0.2"
"@expo/steps": "npm:21.0.3"
"@expo/template-file": "npm:21.0.2"
"@expo/turtle-spawn": "npm:21.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9722,7 +9722,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:21.0.2"
"@expo/steps": "npm:21.0.3"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10294,8 +10294,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:21.0.2"
"@expo/eas-build-job": "npm:21.0.2"
"@expo/build-tools": "npm:21.0.3"
"@expo/eas-build-job": "npm:21.0.3"
"@expo/logger": "npm:21.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:21.0.0"
@@ -10352,8 +10352,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:21.0.2"
"@expo/eas-json": "npm:21.0.2"
"@expo/eas-build-job": "npm:21.0.3"
"@expo/eas-json": "npm:21.0.3"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:21.0.0"
@@ -10368,7 +10368,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:21.0.2"
"@expo/steps": "npm:21.0.3"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"