Compare commits

..
Author SHA1 Message Date
Stanisław Chmiela 7e912ca078 [eas-cli] Remove changelog conflict markers after rebase 2026-03-31 15:15:44 +02:00
Stanisław Chmiela f366dbec69 [eas-cli] Fix workflow Git regression test setup 2026-03-31 12:09:05 +02:00
Stanisław Chmiela 5ce625e9f2 [eas-cli] Add changelog entry for workflow detection fix 2026-03-31 12:06:22 +02:00
Stanisław Chmiela 7005300cff [eas-cli] Fix workflow detection for tracked gitignored files 2026-03-31 12:06:11 +02:00
Stanisław Chmiela 394576368a [worker] Lower project dependencies telemetry errors to warning (#3535)
## Summary
- mark `FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT` as a Sentry warning instead of an error
- reduce noise from non-critical analytics failures in worker project dependency reporting
2026-03-31 10:30:17 +02:00
Evan BaconandClaude Opus 4.6 f80e34cffe feat(metadata): add screenshots and preview support (#3301)
* Add support for Apple screenshots and video previews

Introduces new tasks for managing App Store screenshots and video previews, including downloading and uploading assets per locale and display type. Updates AppleData and PartialAppleData types to include screenshots and previews, and extends config reader/writer to handle these assets. Also bumps @expo/apple-utils to 2.1.13 and updates related tests and types.

* Update apple-utils and improve asset file handling

Bump @expo/apple-utils to 2.1.14 and add a version check for preview support. Normalize preview and screenshot asset directory structure and filenames for consistency and future compatibility.

* Use original type names for preview and screenshot dirs

Updated directory structure for storing Apple previews and screenshots to use the original previewType and displayType values instead of lowercased, hyphenated versions. This change ensures consistency with the original type naming.

* Improve Apple metadata tasks error handling and schema

Replaces assert-based error handling with conditional checks and log messages in Apple metadata tasks (age-rating, app-review-detail, previews, screenshots) to prevent crashes when context is missing. Updates the metadata schema to add support for 'screenshots' and 'previews' fields, including detailed structure for video previews.

* Use original filenames for Apple previews and screenshots

Updated the download logic to use the original filenames from Apple metadata for previews and screenshots instead of normalized index-based names. This change improves file matching during sync operations.

* Update tests to skip on missing context instead of abort

Changed age rating and app review detail task tests to expect skipping (not throwing) when required context is missing, reflecting updated behavior. Also updated CHANGELOG to note new screenshots and previews support for metadata commands.

* wip -- unit tests for metadata

* Include .query(true) in apple metadata tests

Allow nock mocks to match requests with query parameters in two tests. Added `.query(true)` to the GET interceptors in packages/eas-cli/src/metadata/apple/tasks/__tests__/previews.test.ts and packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts so the mocks won't fail when requests include query strings.

* [eas-cli] Fix age rating API and update @expo/apple-utils to 2.1.16

Move age rating from appStoreVersions to appInfos — Apple removed the
ageRatingDeclaration relationship from appStoreVersions. Also apply
formatting fixes from oxfmt.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Update CHANGELOG.md

* [eas-cli] Improve screenshots/previews tasks: native fs, fileSize matching, parallel fetching

- Replace fs-extra with native fs/fs.promises
- Add fileSize check alongside filename matching to detect changed files
- Parallelize locale fetching in prepareAsync with Promise.all
- Skip reorder API call when screenshot order is unchanged
- Remove unnecessary runtime method check for getAppPreviewSetsAsync
- Use consistent index-based fallback filenames for previews

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 17:46:55 -07:00
Quinlan Jung 🦥 59ad2c2ca0 Reapply "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534) (#3543)
This reverts commit 92ad6b5ba3.
2026-03-30 14:04:44 -07:00
34 changed files with 1963 additions and 384 deletions

No files matched your search

+5 -2
View File
@@ -8,18 +8,19 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix workflow detection for tracked `.gitignore`d native projects when `requireCommit` is disabled. ([#3561](https://github.com/expo/eas-cli/pull/3561) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
@@ -64,6 +65,8 @@ This is the log of notable changes to EAS CLI and related packages.
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
### 🧹 Chores
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
+2 -2
View File
@@ -35,8 +35,8 @@
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/env": "^0.4.0",
+3 -3
View File
@@ -43,10 +43,10 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.15",
"@expo/apple-utils": "2.1.16",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-json": "18.4.0",
"@expo/env": "^1.0.0",
+36
View File
@@ -315,6 +315,42 @@
"liveEdits": true,
"optional": true
}
},
"screenshots": {
"type": "object",
"description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"previews": {
"type": "object",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
"additionalProperties": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Video file path (relative to project root)"
},
"previewFrameTimeCode": {
"type": "string",
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
}
},
"required": ["path"],
"additionalProperties": false
}
]
}
}
}
},
+1 -1
View File
@@ -107,7 +107,7 @@ async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): P
},
{
configPath: 'exp.notification.icon',
pngPath: ctx.exp.notification?.icon,
pngPath: (ctx.exp as Record<string, any>).notification?.icon,
},
];
await validatePNGsAsync(pngs);
@@ -16,7 +16,7 @@ import {
import uniq from '../../../utils/expodash/uniq';
import { AttributesOf } from '../../utils/asc';
import { removeDatePrecision } from '../../utils/date';
import { AppleMetadata } from '../types';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
@@ -211,4 +211,14 @@ export class AppleConfigReader {
// TODO: add attachment
};
}
/** Get screenshots configuration for a specific locale */
public getScreenshots(locale: string): AppleScreenshots | null {
return this.schema.info?.[locale]?.screenshots ?? null;
}
/** Get video previews configuration for a specific locale */
public getPreviews(locale: string): ApplePreviews | null {
return this.schema.info?.[locale]?.previews ?? null;
}
}
@@ -13,7 +13,7 @@ import {
} from '@expo/apple-utils';
import { AttributesOf } from '../../utils/asc';
import { AppleMetadata } from '../types';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
/**
* Serializes the Apple ASC entities into the metadata configuration schema.
@@ -186,6 +186,21 @@ export class AppleConfigWriter {
// TODO: add attachment
};
}
/** Set screenshots for a specific locale */
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].screenshots =
Object.keys(screenshots).length > 0 ? screenshots : undefined;
}
/** Set video previews for a specific locale */
public setPreviews(locale: string, previews: ApplePreviews): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+11 -2
View File
@@ -4,16 +4,25 @@ import type { AgeRatingData } from './tasks/age-rating';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppVersionData &
AgeRatingData &
AppReviewData &
ScreenshotsData &
PreviewsData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
* It contains a reference to the app, each task should populate the necessary data.
* If an entity fails to prepare the data, individual tasks should raise errors about the missing data.
*/
export type PartialAppleData = { app: App } & Partial<Omit<AppleData, 'app'>>;
export type PartialAppleData = { app: App; projectDir: string } & Partial<
Omit<AppleData, 'app' | 'projectDir'>
>;
@@ -1,8 +1,14 @@
import { truthy } from '../../../utils/expodash/filter';
import { IssueRule } from '../../config/issue';
import { AppleInfo } from '../types';
const RESTRICTED_PROPERTIES: (keyof AppleInfo)[] = ['title', 'subtitle', 'description', 'keywords'];
/** Only check text properties that may contain restricted words */
type AppleInfoTextProperty = 'title' | 'subtitle' | 'description' | 'keywords';
const RESTRICTED_PROPERTIES: AppleInfoTextProperty[] = [
'title',
'subtitle',
'description',
'keywords',
];
const RESTRICTED_WORDS = {
beta: 'Apple restricts the word "beta" and synonyms implying incomplete functionality.',
};
@@ -1,4 +1,4 @@
import { AgeRatingDeclaration, AppStoreVersion, Rating } from '@expo/apple-utils';
import { AgeRatingDeclaration, AppInfo, Rating } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
@@ -12,13 +12,13 @@ jest.mock('../../config/writer');
describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('loads age rating from app version', async () => {
it('loads age rating from app info', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
.get(`/v1/${AppInfo.type}/stub-id/ageRatingDeclaration`)
.reply(200, require('./fixtures/appStoreVersions/get-ageRatingDeclaration-200.json'));
const context: any = {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
info: new AppInfo(requestContext, 'stub-id', {} as any),
};
await new AgeRatingTask().prepareAsync({ context });
@@ -26,6 +26,16 @@ describe(AgeRatingTask, () => {
expect(context.ageRating).toBeInstanceOf(AgeRatingDeclaration);
expect(scope.isDone()).toBeTruthy();
});
it('skips when info is not available', async () => {
const context: any = {
info: undefined,
};
await new AgeRatingTask().prepareAsync({ context });
expect(context.ageRating).toBeUndefined();
});
});
describe('downloadAsync', () => {
@@ -54,13 +64,13 @@ describe(AgeRatingTask, () => {
});
describe('uploadAsync', () => {
it('aborts when age rating is not loaded', async () => {
const promise = new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
});
await expect(promise).rejects.toThrow('rating not initialized');
it('skips when age rating is not loaded', async () => {
await expect(
new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
})
).resolves.not.toThrow();
});
it('skips updating age rating when not configured', async () => {
@@ -23,6 +23,7 @@ describe(AppInfoTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppInfoTask().prepareAsync({ context });
@@ -72,20 +72,20 @@ describe(AppReviewDetailTask, () => {
nock.cleanAll();
});
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: 'review@example.com',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
});
await expect(promise).rejects.toThrow('version not init');
it('skips when version is not loaded', async () => {
await expect(
new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
})
).resolves.not.toThrow();
});
it('updates review details when loaded', async () => {
@@ -44,6 +44,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -83,6 +84,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -118,6 +120,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -153,6 +156,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -194,6 +198,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '2.0' }).prepareAsync({ context });
@@ -243,6 +248,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '3.0' }).prepareAsync({ context });
@@ -371,6 +377,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
};
@@ -399,6 +406,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
versionPhasedRelease: null, // Not enabled yet
};
@@ -417,6 +425,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
versionPhasedRelease: new AppStoreVersionPhasedRelease(
@@ -440,8 +449,9 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
// Enabled, and completed
versionPhasedRelease: new AppStoreVersionPhasedRelease(
requestContext,
'APP_STORE_VERSION_PHASED_RELEASE_1',
@@ -0,0 +1,405 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PreviewsTask } from '../previews';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 10240 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(PreviewsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty preview sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(0);
});
it('fetches preview sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppPreviewSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(1);
expect(context.previewSets!.get('en-US')).toBeDefined();
expect(context.previewSets!.get('en-US')!.has(PreviewType.IPHONE_67)).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PreviewsTask().downloadAsync({
config: writer,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('skips locales with no preview sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const previewSets = new Map();
previewSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
} as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('downloads previews and sets config with simple path', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
});
});
it('downloads previews with previewFrameTimeCode as object config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: {
path: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
});
});
});
describe('uploadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without previews in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new preview for configured locale with string path', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: './previews/demo.mp4',
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
})
);
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: {
path: './previews/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
previewFrameTimeCode: '00:05:00',
})
);
});
});
});
@@ -0,0 +1,303 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { ScreenshotsTask } from '../screenshots';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1024 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(ScreenshotsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty screenshot sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(0);
});
it('fetches screenshot sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppScreenshotSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(1);
expect(context.screenshotSets!.get('en-US')).toBeDefined();
expect(
context.screenshotSets!.get('en-US')!.has(ScreenshotDisplayType.APP_IPHONE_67)
).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new ScreenshotsTask().downloadAsync({
config: writer,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('skips locales with no screenshot sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
} as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('downloads screenshots and sets config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshot = new AppScreenshot(requestContext, 'SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
// Mock getImageAssetUrl
jest.spyOn(screenshot, 'getImageAssetUrl').mockReturnValue('https://example.com/home.png');
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [screenshot],
} as any);
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
const screenshotSets = new Map();
screenshotSets.set('en-US', displayTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
// Mock fetch response
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-image-data')),
});
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPHONE_67]: [
'store/apple/screenshot/en-US/APP_IPHONE_67/home.png',
],
});
});
});
describe('uploadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without screenshots in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No screenshots
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new screenshots for configured locale', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
screenshots: {
APP_IPHONE_67: ['./screenshots/home.png'],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newScreenshot = new AppScreenshot(requestContext, 'NEW_SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
// Mock createAppScreenshotSetAsync on the locale
const scope = nock('https://api.appstoreconnect.apple.com')
// Create screenshot set
.post(`/v1/${AppScreenshotSet.type}`)
.reply(201, {
data: {
id: 'NEW_SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
});
// Mock AppScreenshot.uploadAsync to avoid real file access
jest.spyOn(AppScreenshot, 'uploadAsync').mockResolvedValue(newScreenshot);
// Mock AppScreenshotSet.infoAsync for reorder step
jest.spyOn(AppScreenshotSet, 'infoAsync').mockResolvedValue(
new AppScreenshotSet(requestContext, 'NEW_SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [newScreenshot],
} as any)
);
// Mock reorderScreenshotsAsync
const reorderMock = jest.fn().mockResolvedValue([]);
AppScreenshotSet.prototype.reorderScreenshotsAsync = reorderMock;
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppScreenshot.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_SET_1',
filePath: '/test/project/screenshots/home.png',
waitForProcessing: true,
})
);
// Reorder is skipped because the current order already matches
expect(reorderMock).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,4 @@
import { AgeRatingDeclaration } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -7,7 +6,7 @@ import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AgeRatingData = {
/** The app age rating declaration for the app version */
/** The app age rating declaration for the app info */
ageRating: AgeRatingDeclaration;
};
@@ -15,7 +14,22 @@ export class AgeRatingTask extends AppleTask {
public name = (): string => 'age rating declarations';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
if (!context.info) {
return;
}
// The ageRatingDeclaration relationship is on appInfos (not appStoreVersions).
try {
context.ageRating = (await context.info.getAgeRatingDeclarationAsync()) ?? undefined;
} catch (error: any) {
// Gracefully handle cases where the relationship is not available
if (error?.message?.includes('ageRatingDeclaration')) {
Log.warn(
chalk`{yellow Skipped age rating - not available for this app. This may require updating through App Store Connect directly.}`
);
return;
}
throw error;
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -25,7 +39,10 @@ export class AgeRatingTask extends AppleTask {
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
assert(context.ageRating, `Age rating not initialized, can't update age rating`);
if (!context.ageRating) {
Log.log(chalk`{dim - Skipped age rating update, not available}`);
return;
}
const ageRating = config.getAgeRating();
if (!ageRating) {
@@ -1,5 +1,4 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -16,7 +15,10 @@ export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
if (!context.version) {
return;
}
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -32,7 +34,10 @@ export class AppReviewDetailTask extends AppleTask {
return;
}
assert(context.version, `App version not initialized, can't upload store review details`);
if (!context.version) {
Log.log(chalk`{dim - Skipped store review details, no version available}`);
return;
}
const { versionString } = context.version.attributes;
if (!context.reviewDetail) {
@@ -2,6 +2,8 @@ import { AgeRatingTask } from './age-rating';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
type AppleTaskOptions = {
@@ -17,5 +19,7 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new AppInfoTask(),
new AgeRatingTask(),
new AppReviewDetailTask(),
new ScreenshotsTask(),
new PreviewsTask(),
];
}
@@ -0,0 +1,297 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { ApplePreviewConfig, ApplePreviews } from '../types';
/** Locale -> PreviewType -> AppPreviewSet */
export type PreviewSetsMap = Map<string, Map<PreviewType, AppPreviewSet>>;
export type PreviewsData = {
/** Map of locales to their preview sets */
previewSets: PreviewSetsMap;
};
/**
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
*/
function normalizePreviewConfig(config: ApplePreviewConfig): {
path: string;
previewFrameTimeCode?: string;
} {
if (typeof config === 'string') {
return { path: config };
}
return config;
}
/**
* Task for managing App Store video previews.
* Downloads existing previews and uploads new ones based on store configuration.
*/
export class PreviewsTask extends AppleTask {
public name = (): string => 'video previews';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
// Initialize the preview sets map
context.previewSets = new Map();
if (!context.versionLocales) {
return;
}
// Fetch preview sets for all locales in parallel
await Promise.all(
context.versionLocales.map(async locale => {
const sets = await locale.getAppPreviewSetsAsync();
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
for (const set of sets) {
previewTypeMap.set(set.attributes.previewType, set);
}
context.previewSets!.set(locale.attributes.locale, previewTypeMap);
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.previewSets || !context.versionLocales) {
return;
}
for (const locale of context.versionLocales) {
const localeCode = locale.attributes.locale;
const previewTypeMap = context.previewSets.get(localeCode);
if (!previewTypeMap || previewTypeMap.size === 0) {
continue;
}
const previews: ApplePreviews = {};
for (const [previewType, set] of previewTypeMap) {
const previewModels = set.attributes.appPreviews;
if (!previewModels || previewModels.length === 0) {
continue;
}
// For now, we only handle the first preview per set (App Store allows up to 3)
// We can extend this later to support multiple previews
const preview = previewModels[0];
const relativePath = await downloadPreviewAsync(
context.projectDir,
localeCode,
previewType,
preview,
0
);
if (relativePath) {
// Include preview frame time code if available
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = relativePath;
}
}
}
if (Object.keys(previews).length > 0) {
config.setPreviews(localeCode, previews);
}
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.previewSets || !context.versionLocales) {
Log.log(chalk`{dim - Skipped video previews, no version available}`);
return;
}
const locales = config.getLocales();
if (locales.length <= 0) {
Log.log(chalk`{dim - Skipped video previews, no locales configured}`);
return;
}
for (const localeCode of locales) {
const previews = config.getPreviews(localeCode);
if (!previews || Object.keys(previews).length === 0) {
continue;
}
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
Log.warn(chalk`{yellow Skipping video previews for ${localeCode} - locale not found}`);
continue;
}
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
context.previewSets.get(localeCode)
);
}
}
}
}
/**
* Sync a preview set - upload new preview, delete old one if changed.
*/
async function syncPreviewSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
previewType: PreviewType,
previewConfig: { path: string; previewFrameTimeCode?: string },
existingSets: Map<PreviewType, AppPreviewSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
const absolutePath = path.resolve(projectDir, previewConfig.path);
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`);
return;
}
// Get or create the preview set
let previewSet = existingSets?.get(previewType);
if (!previewSet) {
previewSet = await logAsync(
() =>
localization.createAppPreviewSetAsync({
previewType,
}),
{
pending: `Creating preview set for ${chalk.bold(previewType)} (${locale})...`,
success: `Created preview set for ${chalk.bold(previewType)} (${locale})`,
failure: `Failed creating preview set for ${chalk.bold(previewType)} (${locale})`,
}
);
}
const existingPreviews = previewSet.attributes.appPreviews || [];
// Check if we need to update (different filename, size, or no existing preview)
const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName);
const localSize = fs.statSync(absolutePath).size;
if (
existingPreview &&
existingPreview.isComplete() &&
existingPreview.attributes.fileSize === localSize
) {
// Preview with same filename exists, check if we need to update preview frame time code
if (
previewConfig.previewFrameTimeCode &&
existingPreview.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
) {
await logAsync(
() =>
existingPreview.updateAsync({
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
}
);
}
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
return;
}
// Delete existing previews that don't match
for (const preview of existingPreviews) {
if (preview.attributes.fileName !== fileName) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
}
// Upload new preview
await logAsync(
() =>
AppPreview.uploadAsync(localization.context, {
id: previewSet!.id,
filePath: absolutePath,
waitForProcessing: true,
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Uploading video preview ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded video preview ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading video preview ${chalk.bold(fileName)} (${locale})`,
}
);
}
/**
* Download a video preview to the local filesystem.
* Returns the relative path to the downloaded file.
*/
async function downloadPreviewAsync(
projectDir: string,
locale: string,
previewType: PreviewType,
preview: AppPreview,
index: number
): Promise<string | null> {
const videoUrl = preview.getVideoUrl();
if (!videoUrl) {
Log.warn(chalk`{yellow Could not get download URL for preview ${preview.attributes.fileName}}`);
return null;
}
// Create directory structure: store/apple/preview/{locale}/{previewType}/
const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType);
await fs.promises.mkdir(previewsDir, { recursive: true });
// Use original filename for matching during sync
const fileName = preview.attributes.fileName || `${String(index + 1).padStart(2, '0')}.mp4`;
const outputPath = path.join(previewsDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(videoUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded video preview: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(chalk`{yellow Failed to download video preview ${fileName}: ${error.message}}`);
return null;
}
}
@@ -0,0 +1,314 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { AppleScreenshots } from '../types';
/** Locale -> ScreenshotDisplayType -> AppScreenshotSet */
export type ScreenshotSetsMap = Map<string, Map<ScreenshotDisplayType, AppScreenshotSet>>;
export type ScreenshotsData = {
/** Map of locales to their screenshot sets */
screenshotSets: ScreenshotSetsMap;
};
/**
* Task for managing App Store screenshots.
* Downloads existing screenshots and uploads new ones based on store configuration.
*/
export class ScreenshotsTask extends AppleTask {
public name = (): string => 'screenshots';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
// Initialize the screenshot sets map
context.screenshotSets = new Map();
if (!context.versionLocales) {
return;
}
// Fetch screenshot sets for all locales in parallel
await Promise.all(
context.versionLocales.map(async locale => {
const sets = await locale.getAppScreenshotSetsAsync();
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
for (const set of sets) {
displayTypeMap.set(set.attributes.screenshotDisplayType, set);
}
context.screenshotSets!.set(locale.attributes.locale, displayTypeMap);
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.screenshotSets || !context.versionLocales) {
return;
}
for (const locale of context.versionLocales) {
const localeCode = locale.attributes.locale;
const displayTypeMap = context.screenshotSets.get(localeCode);
if (!displayTypeMap || displayTypeMap.size === 0) {
continue;
}
const screenshots: AppleScreenshots = {};
for (const [displayType, set] of displayTypeMap) {
const screenshotModels = set.attributes.appScreenshots;
if (!screenshotModels || screenshotModels.length === 0) {
continue;
}
// Download screenshots and save to local filesystem
const paths: string[] = [];
for (let i = 0; i < screenshotModels.length; i++) {
const screenshot = screenshotModels[i];
const relativePath = await downloadScreenshotAsync(
context.projectDir,
localeCode,
displayType,
screenshot,
i
);
if (relativePath) {
paths.push(relativePath);
}
}
if (paths.length > 0) {
screenshots[displayType] = paths;
}
}
if (Object.keys(screenshots).length > 0) {
config.setScreenshots(localeCode, screenshots);
}
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.screenshotSets || !context.versionLocales) {
Log.log(chalk`{dim - Skipped screenshots, no version available}`);
return;
}
const locales = config.getLocales();
if (locales.length <= 0) {
Log.log(chalk`{dim - Skipped screenshots, no locales configured}`);
return;
}
for (const localeCode of locales) {
const screenshots = config.getScreenshots(localeCode);
if (!screenshots || Object.keys(screenshots).length === 0) {
continue;
}
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
Log.warn(chalk`{yellow Skipping screenshots for ${localeCode} - locale not found}`);
continue;
}
for (const [displayType, paths] of Object.entries(screenshots)) {
if (!paths || paths.length === 0) {
continue;
}
await syncScreenshotSetAsync(
context.projectDir,
localization,
displayType as ScreenshotDisplayType,
paths,
context.screenshotSets.get(localeCode)
);
}
}
}
}
/**
* Sync a screenshot set - upload new screenshots, delete removed ones, reorder if needed.
*/
async function syncScreenshotSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
displayType: ScreenshotDisplayType,
paths: string[],
existingSets: Map<ScreenshotDisplayType, AppScreenshotSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
// Get or create the screenshot set
let screenshotSet = existingSets?.get(displayType);
if (!screenshotSet) {
screenshotSet = await logAsync(
() =>
localization.createAppScreenshotSetAsync({
screenshotDisplayType: displayType,
}),
{
pending: `Creating screenshot set for ${chalk.bold(displayType)} (${locale})...`,
success: `Created screenshot set for ${chalk.bold(displayType)} (${locale})`,
failure: `Failed creating screenshot set for ${chalk.bold(displayType)} (${locale})`,
}
);
}
const existingScreenshots = screenshotSet.attributes.appScreenshots || [];
// Build a map of existing screenshots by filename for comparison
const existingByFilename = new Map<string, AppScreenshot>();
for (const screenshot of existingScreenshots) {
existingByFilename.set(screenshot.attributes.fileName, screenshot);
}
// Track which screenshots to keep, upload, and delete
const screenshotIdsToKeep: string[] = [];
const pathsToUpload: string[] = [];
for (const relativePath of paths) {
const absolutePath = path.resolve(projectDir, relativePath);
const fileName = path.basename(absolutePath);
// Check if screenshot already exists with same name and file size
const existing = existingByFilename.get(fileName);
const localSize = fs.existsSync(absolutePath) ? fs.statSync(absolutePath).size : null;
if (
existing &&
existing.isComplete() &&
(localSize === null || existing.attributes.fileSize === localSize)
) {
screenshotIdsToKeep.push(existing.id);
existingByFilename.delete(fileName);
} else {
pathsToUpload.push(absolutePath);
}
}
// Delete screenshots that are no longer in config
for (const screenshot of existingByFilename.values()) {
await logAsync(() => screenshot.deleteAsync(), {
pending: `Deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})...`,
success: `Deleted screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
failure: `Failed deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
});
}
// Upload new screenshots
for (const absolutePath of pathsToUpload) {
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Screenshot not found: ${absolutePath}}`);
continue;
}
const newScreenshot = await logAsync(
() =>
AppScreenshot.uploadAsync(localization.context, {
id: screenshotSet!.id,
filePath: absolutePath,
waitForProcessing: true,
}),
{
pending: `Uploading screenshot ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded screenshot ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading screenshot ${chalk.bold(fileName)} (${locale})`,
}
);
screenshotIdsToKeep.push(newScreenshot.id);
}
// Reorder screenshots to match config order
if (screenshotIdsToKeep.length > 0) {
const refreshedSet = await AppScreenshotSet.infoAsync(localization.context, {
id: screenshotSet.id,
});
const refreshedScreenshots = refreshedSet.attributes.appScreenshots || [];
const screenshotsByFilename = new Map<string, AppScreenshot>();
for (const s of refreshedScreenshots) {
screenshotsByFilename.set(s.attributes.fileName, s);
}
// Build the desired order based on config paths
const orderedIds: string[] = [];
for (const relativePath of paths) {
const fileName = path.basename(relativePath);
const screenshot = screenshotsByFilename.get(fileName);
if (screenshot) {
orderedIds.push(screenshot.id);
}
}
// Only call reorder if the order actually differs from current
const currentIds = refreshedScreenshots.map(s => s.id);
if (
orderedIds.length > 0 &&
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
) {
await screenshotSet.reorderScreenshotsAsync({ appScreenshots: orderedIds });
}
}
}
/**
* Download a screenshot to the local filesystem.
* Returns the relative path to the downloaded file.
*/
async function downloadScreenshotAsync(
projectDir: string,
locale: string,
displayType: ScreenshotDisplayType,
screenshot: AppScreenshot,
index: number
): Promise<string | null> {
const imageUrl = screenshot.getImageAssetUrl({ type: 'png' });
if (!imageUrl) {
Log.warn(
chalk`{yellow Could not get download URL for screenshot ${screenshot.attributes.fileName}}`
);
return null;
}
// Create directory structure: store/apple/screenshot/{locale}/{displayType}/
const screenshotsDir = path.join(projectDir, 'store', 'apple', 'screenshot', locale, displayType);
await fs.promises.mkdir(screenshotsDir, { recursive: true });
// Use original filename for matching during sync
const fileName = screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}.png`;
const outputPath = path.join(screenshotsDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded screenshot: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(chalk`{yellow Failed to download screenshot ${fileName}: ${error.message}}`);
return null;
}
}
+45 -1
View File
@@ -1,7 +1,46 @@
import type { AgeRatingDeclarationProps } from '@expo/apple-utils';
import type {
AgeRatingDeclarationProps,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
export type AppleLocale = string;
/** Screenshot display type enum values from App Store Connect API */
export type AppleScreenshotDisplayType = `${ScreenshotDisplayType}`;
/** Preview display type enum values from App Store Connect API */
export type ApplePreviewType = `${PreviewType}`;
/**
* Screenshots organized by display type.
* Key is the display type (e.g., 'APP_IPHONE_67'), value is array of file paths.
* @example { "APP_IPHONE_67": ["./screenshots/home.png", "./screenshots/profile.png"] }
*/
export type AppleScreenshots = Partial<Record<AppleScreenshotDisplayType, string[]>>;
/**
* Video preview configuration - either a simple path string or an object with options.
* @example "./previews/demo.mp4"
* @example { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" }
*/
export type ApplePreviewConfig =
| string
| {
/** Video file path (relative to project root) */
path: string;
/** Optional preview frame time code (e.g., '00:05:00' for 5 seconds) */
previewFrameTimeCode?: string;
};
/**
* Video previews organized by display type.
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
* @example { "IPHONE_67": "./previews/demo.mp4" }
* @example { "IPHONE_67": { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } }
*/
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
export interface AppleMetadata {
version?: string;
copyright?: string;
@@ -9,6 +48,7 @@ export interface AppleMetadata {
categories?: AppleCategory;
release?: AppleRelease;
advisory?: AppleAdvisory;
/** @deprecated Use screenshots/previews in AppleInfo instead */
preview?: Record<string, string[]>;
review?: AppleReview;
}
@@ -40,6 +80,10 @@ export interface AppleInfo {
privacyPolicyText?: string;
privacyChoicesUrl?: string;
supportUrl?: string;
/** Screenshots for this locale, organized by display type */
screenshots?: AppleScreenshots;
/** Video previews for this locale, organized by display type */
previews?: ApplePreviews;
}
export interface AppleReview {
+1 -1
View File
@@ -77,7 +77,7 @@ export async function downloadMetadataAsync({
const errors: Error[] = [];
const config = createAppleWriter();
const tasks = createAppleTasks();
const taskCtx = { app };
const taskCtx = { app, projectDir };
for (const task of tasks) {
try {
+1 -1
View File
@@ -69,7 +69,7 @@ export async function uploadMetadataAsync({
version: config.getVersion()?.versionString,
});
const taskCtx = { app };
const taskCtx = { app, projectDir };
for (const task of tasks) {
try {
@@ -0,0 +1,63 @@
import spawnAsync from '@expo/spawn-async';
import { Platform, Workflow } from '@expo/eas-build-job';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import GitClient from '../../vcs/clients/git';
import { resolveWorkflowAsync } from '../workflow';
describe('resolveWorkflowAsync with GitClient', () => {
let repoRoot: string;
beforeEach(async () => {
repoRoot = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-workflow-test-'))
);
await spawnAsync('git', ['init'], { cwd: repoRoot });
await spawnAsync('git', ['config', 'user.email', '[email protected]'], { cwd: repoRoot });
await spawnAsync('git', ['config', 'user.name', 'Test User'], { cwd: repoRoot });
});
afterEach(async () => {
await fs.rm(repoRoot, { recursive: true, force: true });
});
it('treats tracked gitignored native projects as managed when requireCommit is false', async () => {
const vcsClient = new GitClient({
requireCommit: false,
maybeCwdOverride: repoRoot,
});
await fs.mkdir(path.join(repoRoot, 'ios', 'app.xcodeproj'), { recursive: true });
await fs.writeFile(path.join(repoRoot, 'ios', 'app.xcodeproj', 'project.pbxproj'), 'fake');
await fs.writeFile(path.join(repoRoot, '.gitignore'), 'ios/\n');
await spawnAsync('git', ['add', '.gitignore'], { cwd: repoRoot });
await spawnAsync('git', ['add', '-f', 'ios/app.xcodeproj/project.pbxproj'], { cwd: repoRoot });
await spawnAsync('git', ['commit', '-m', 'test setup'], { cwd: repoRoot });
await expect(resolveWorkflowAsync(repoRoot, Platform.IOS, vcsClient)).resolves.toBe(
Workflow.MANAGED
);
});
it('treats tracked gitignored native projects as generic when requireCommit is true', async () => {
const vcsClient = new GitClient({
requireCommit: true,
maybeCwdOverride: repoRoot,
});
await fs.mkdir(path.join(repoRoot, 'ios', 'app.xcodeproj'), { recursive: true });
await fs.writeFile(path.join(repoRoot, 'ios', 'app.xcodeproj', 'project.pbxproj'), 'fake');
await fs.writeFile(path.join(repoRoot, '.gitignore'), 'ios/\n');
await spawnAsync('git', ['add', '.gitignore'], { cwd: repoRoot });
await spawnAsync('git', ['add', '-f', 'ios/app.xcodeproj/project.pbxproj'], { cwd: repoRoot });
await spawnAsync('git', ['commit', '-m', 'test setup'], { cwd: repoRoot });
await expect(resolveWorkflowAsync(repoRoot, Platform.IOS, vcsClient)).resolves.toBe(
Workflow.GENERIC
);
});
});
@@ -137,81 +137,21 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { android: { package: 'com.jester.myapp' } },
});
});
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
it('throws an error in non-interactive mode', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
'app.json': '{ "blah": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: '' } as any,
projectId: '',
exp: {} as any,
vcsClient,
nonInteractive: true,
})
@@ -148,8 +148,15 @@ async function configureApplicationIdAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
const paths = getConfigFilePaths(projectDir);
if (nonInteractive) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -159,50 +166,25 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
let packageName: string;
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
if (nonInteractive) {
let suggestedAndroidApplicationId: string | undefined;
try {
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedAndroidApplicationId) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
packageName = suggestedAndroidApplicationId;
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
} else {
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const result = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
packageName = result.packageName;
}
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -115,81 +115,20 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
it('throws an error in non-interactive mode', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
'app.json': '{ "blah": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
});
});
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: '' } as any,
exp: {} as any,
vcsClient,
nonInteractive: true,
})
@@ -7,6 +7,23 @@ import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../exe
jest.mock('fs');
jest.mock('../../../prompts');
// @expo/require-utils uses require() to load JS configs, which bypasses the
// mocked fs. Override loadModuleSync to read from the mocked fs and evaluate.
jest.mock('@expo/require-utils', () => {
const actual = jest.requireActual('@expo/require-utils');
return {
...actual,
loadModuleSync: (filename: string) => {
const fs = require('fs');
const content = fs.readFileSync(filename, 'utf-8');
const mod = { exports: {} as any };
// eslint-disable-next-line no-new-func
new Function('module', 'exports', content)(mod, mod.exports);
return mod.exports;
},
};
});
beforeEach(async () => {
jest.resetAllMocks();
vol.reset();
@@ -137,8 +137,15 @@ async function configureBundleIdentifierAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
const paths = getConfigFilePaths(projectDir);
if (nonInteractive) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -148,51 +155,26 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
let bundleIdentifier: string;
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
if (nonInteractive) {
let suggestedBundleIdentifier: string | undefined;
try {
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedBundleIdentifier) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
bundleIdentifier = suggestedBundleIdentifier;
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
} else {
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
const result = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
});
bundleIdentifier = result.bundleIdentifier;
}
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -91,14 +91,14 @@ describe('git', () => {
await fs.rm(`${repoRoot}/.gitignore`);
});
it.each(testFiles.filter(file => file.gitignore && !file.commit))(
it.each(testFiles.filter(file => file.gitignore))(
'$filename should be ignored',
async file => {
expect(await vcs.isFileIgnoredAsync(file.filename)).toBe(true);
}
);
it.each(testFiles.filter(file => !file.gitignore || file.commit))(
it.each(testFiles.filter(file => !file.gitignore))(
'$filename should not be ignored',
async file => {
expect(await vcs.isFileIgnoredAsync(file.filename)).toBe(false);
+9 -3
View File
@@ -376,12 +376,18 @@ export default class GitClient extends Client {
return wouldNotBeCopiedToClone && (!isTracked || wouldBeDeletedFromClone);
}
if (isTracked) {
return false; // Tracked files aren't ignored even if they match ignore patterns
if (isTracked && this.requireCommit) {
// With requireCommit=true we rely on a Git checkout, so tracked files are included
// even if they match ignore patterns.
return false;
}
try {
await spawnAsync('git', ['check-ignore', '-q', filePath], { cwd: rootPath });
await spawnAsync(
'git',
['check-ignore', '-q', ...(this.requireCommit ? [] : ['--no-index']), filePath],
{ cwd: rootPath }
);
return true;
} catch {
return false;
+2 -2
View File
@@ -20,8 +20,8 @@
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.4.0",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/logger": "18.0.1",
+13 -4
View File
@@ -82,6 +82,7 @@ export async function logProjectDependenciesAsync(
});
} catch (error: any) {
sentry.handleError('Failed to report project dependencies metrics', error, {
level: 'warning',
tags: {
errorCode: 'FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT',
},
@@ -133,10 +134,15 @@ async function resolveNewArchEnabled(
}
if (
appConfig?.[job.platform]?.newArchEnabled !== undefined ||
appConfig.newArchEnabled !== undefined
(appConfig?.[job.platform] as unknown as Record<string, unknown>)?.newArchEnabled !==
undefined ||
(appConfig as unknown as Record<string, unknown>).newArchEnabled !== undefined
) {
return appConfig?.[job.platform]?.newArchEnabled ?? appConfig.newArchEnabled;
return (
((appConfig?.[job.platform] as unknown as Record<string, unknown>)
?.newArchEnabled as boolean) ??
((appConfig as unknown as Record<string, unknown>).newArchEnabled as boolean)
);
}
if (job.type === Workflow.GENERIC) {
@@ -220,7 +226,10 @@ async function getPackageManagerVersion(
}
): Promise<string | undefined> {
try {
const { stdout } = await spawnAsync(packageManager, ['--version'], { cwd, env });
const { stdout } = await spawnAsync(packageManager, ['--version'], {
cwd,
env,
});
return stdout.toString().trim();
} catch {
// Some package managers can fail version checks in project dirs with mismatched packageManager fields.
Generated
+259 -107
View File
@@ -844,6 +844,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.20.0, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/code-frame@npm:7.29.0"
dependencies:
"@babel/helper-validator-identifier": "npm:^7.28.5"
js-tokens: "npm:^4.0.0"
picocolors: "npm:^1.1.1"
checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.22.13":
version: 7.22.13
resolution: "@babel/code-frame@npm:7.22.13"
@@ -911,6 +922,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/compat-data@npm:^7.28.6":
version: 7.29.0
resolution: "@babel/compat-data@npm:7.29.0"
checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94
languageName: node
linkType: hard
"@babel/core@npm:7.24.7":
version: 7.24.7
resolution: "@babel/core@npm:7.24.7"
@@ -1026,6 +1044,29 @@ __metadata:
languageName: node
linkType: hard
"@babel/core@npm:^7.25.2":
version: 7.29.0
resolution: "@babel/core@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-compilation-targets": "npm:^7.28.6"
"@babel/helper-module-transforms": "npm:^7.28.6"
"@babel/helpers": "npm:^7.28.6"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/traverse": "npm:^7.29.0"
"@babel/types": "npm:^7.29.0"
"@jridgewell/remapping": "npm:^2.3.5"
convert-source-map: "npm:^2.0.0"
debug: "npm:^4.1.0"
gensync: "npm:^1.0.0-beta.2"
json5: "npm:^2.2.3"
semver: "npm:^6.3.1"
checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa
languageName: node
linkType: hard
"@babel/generator@npm:^7.14.0, @babel/generator@npm:^7.16.5":
version: 7.16.5
resolution: "@babel/generator@npm:7.16.5"
@@ -1107,6 +1148,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.29.0":
version: 7.29.1
resolution: "@babel/generator@npm:7.29.1"
dependencies:
"@babel/parser": "npm:^7.29.0"
"@babel/types": "npm:^7.29.0"
"@jridgewell/gen-mapping": "npm:^0.3.12"
"@jridgewell/trace-mapping": "npm:^0.3.28"
jsesc: "npm:^3.0.2"
checksum: 10c0/349086e6876258ef3fb2823030fee0f6c0eb9c3ebe35fc572e16997f8c030d765f636ddc6299edae63e760ea6658f8ee9a2edfa6d6b24c9a80c917916b973551
languageName: node
linkType: hard
"@babel/generator@npm:^7.7.2":
version: 7.15.0
resolution: "@babel/generator@npm:7.15.0"
@@ -1216,6 +1270,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-compilation-targets@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-compilation-targets@npm:7.28.6"
dependencies:
"@babel/compat-data": "npm:^7.28.6"
"@babel/helper-validator-option": "npm:^7.27.1"
browserslist: "npm:^4.24.0"
lru-cache: "npm:^5.1.1"
semver: "npm:^6.3.1"
checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50
languageName: node
linkType: hard
"@babel/helper-create-class-features-plugin@npm:^7.12.1":
version: 7.12.1
resolution: "@babel/helper-create-class-features-plugin@npm:7.12.1"
@@ -1368,6 +1435,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-imports@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-module-imports@npm:7.28.6"
dependencies:
"@babel/traverse": "npm:^7.28.6"
"@babel/types": "npm:^7.28.6"
checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.12.1":
version: 7.12.1
resolution: "@babel/helper-module-transforms@npm:7.12.1"
@@ -1461,6 +1538,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-module-transforms@npm:7.28.6"
dependencies:
"@babel/helper-module-imports": "npm:^7.28.6"
"@babel/helper-validator-identifier": "npm:^7.28.5"
"@babel/traverse": "npm:^7.28.6"
peerDependencies:
"@babel/core": ^7.0.0
checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b
languageName: node
linkType: hard
"@babel/helper-optimise-call-expression@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/helper-optimise-call-expression@npm:7.10.4"
@@ -1498,6 +1588,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-plugin-utils@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-plugin-utils@npm:7.28.6"
checksum: 10c0/3f5f8acc152fdbb69a84b8624145ff4f9b9f6e776cb989f9f968f8606eb7185c5c3cfcf3ba08534e37e1e0e1c118ac67080610333f56baa4f7376c99b5f1143d
languageName: node
linkType: hard
"@babel/helper-replace-supers@npm:^7.12.1":
version: 7.12.5
resolution: "@babel/helper-replace-supers@npm:7.12.5"
@@ -1760,6 +1857,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/helpers@npm:^7.28.6":
version: 7.29.2
resolution: "@babel/helpers@npm:7.29.2"
dependencies:
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
checksum: 10c0/dab0e65b9318b2502a62c58bc0913572318595eec0482c31f0ad416b72636e6698a1d7c57cd2791d4528eb8c548bca88d338dc4d2a55a108dc1f6702f9bc5512
languageName: node
linkType: hard
"@babel/highlight@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/highlight@npm:7.10.4"
@@ -1922,6 +2029,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0":
version: 7.29.2
resolution: "@babel/parser@npm:7.29.2"
dependencies:
"@babel/types": "npm:^7.29.0"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317
languageName: node
linkType: hard
"@babel/plugin-proposal-class-properties@npm:^7.0.0":
version: 7.12.1
resolution: "@babel/plugin-proposal-class-properties@npm:7.12.1"
@@ -2278,6 +2396,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-transform-modules-commonjs@npm:^7.24.8":
version: 7.28.6
resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6"
dependencies:
"@babel/helper-module-transforms": "npm:^7.28.6"
"@babel/helper-plugin-utils": "npm:^7.28.6"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/7c45992797c6150644c8552feff4a016ba7bd6d59ff2b039ed969a9c5b20a6804cd9d21db5045fc8cca8ca7f08262497e354e93f8f2be6a1cdf3fbfa8c31a9b6
languageName: node
linkType: hard
"@babel/plugin-transform-object-super@npm:^7.0.0":
version: 7.12.1
resolution: "@babel/plugin-transform-object-super@npm:7.12.1"
@@ -2457,6 +2587,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/template@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/template@npm:7.28.6"
dependencies:
"@babel/code-frame": "npm:^7.28.6"
"@babel/parser": "npm:^7.28.6"
"@babel/types": "npm:^7.28.6"
checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5
languageName: node
linkType: hard
"@babel/traverse@npm:^7.12.1, @babel/traverse@npm:^7.12.5, @babel/traverse@npm:^7.14.0, @babel/traverse@npm:^7.16.5, @babel/traverse@npm:^7.16.7, @babel/traverse@npm:^7.16.8, @babel/traverse@npm:^7.17.10, @babel/traverse@npm:^7.17.3":
version: 7.23.2
resolution: "@babel/traverse@npm:7.23.2"
@@ -2508,6 +2649,21 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/traverse@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-globals": "npm:^7.28.0"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
debug: "npm:^4.3.1"
checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.4, @babel/types@npm:^7.10.5, @babel/types@npm:^7.11.0, @babel/types@npm:^7.12.1, @babel/types@npm:^7.12.5, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3":
version: 7.12.6
resolution: "@babel/types@npm:7.12.6"
@@ -2623,7 +2779,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.8.3":
"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.8.3":
version: 7.29.0
resolution: "@babel/types@npm:7.29.0"
dependencies:
@@ -2691,12 +2847,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/apple-utils@npm:2.1.15":
version: 2.1.15
resolution: "@expo/apple-utils@npm:2.1.15"
"@expo/apple-utils@npm:2.1.16":
version: 2.1.16
resolution: "@expo/apple-utils@npm:2.1.16"
bin:
apple-utils: bin.js
checksum: 10c0/956abdb77d1d3bcb09d42b70745705305d23b9aa4ed50e3da795e93e50dc3ada126cc16f73a55b49538f0d793ccdc455bad0caf7aaadbadd5ee7a3d0f3c69465
checksum: 10c0/9d3915f3f25c32e1f1ab323676cabccc2998046be6f30deebc68a0706836bd50bc5145662fa13441e0283f82c56e50f55002d50dcc43aa78e9fa7327d1676a54
languageName: node
linkType: hard
@@ -2704,8 +2860,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:10.0.6"
"@expo/config-plugins": "npm:9.0.12"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/env": "npm:^0.4.0"
@@ -2792,48 +2948,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-plugins@npm:8.0.8":
version: 8.0.8
resolution: "@expo/config-plugins@npm:8.0.8"
"@expo/config-plugins@npm:55.0.7, @expo/config-plugins@npm:~55.0.7":
version: 55.0.7
resolution: "@expo/config-plugins@npm:55.0.7"
dependencies:
"@expo/config-types": "npm:^51.0.0-unreleased"
"@expo/json-file": "npm:~8.3.0"
"@expo/plist": "npm:^0.1.0"
"@expo/sdk-runtime-versions": "npm:^1.0.0"
chalk: "npm:^4.1.2"
debug: "npm:^4.3.1"
find-up: "npm:~5.0.0"
getenv: "npm:^1.0.0"
glob: "npm:7.1.6"
resolve-from: "npm:^5.0.0"
semver: "npm:^7.5.4"
slash: "npm:^3.0.0"
slugify: "npm:^1.6.6"
xcode: "npm:^3.0.1"
xml2js: "npm:0.6.0"
checksum: 10c0/4d403ce9df0cd694cd30f1a914c4e4529c529e9b5f572ddc3cdf88f80d4c47a8ba8336da78ae3f9c67966a7fa1e02d87ea2dff95102a0a337004d118ebc32d63
languageName: node
linkType: hard
"@expo/config-plugins@npm:9.0.12":
version: 9.0.12
resolution: "@expo/config-plugins@npm:9.0.12"
dependencies:
"@expo/config-types": "npm:^52.0.0"
"@expo/json-file": "npm:~9.0.0"
"@expo/plist": "npm:^0.2.0"
"@expo/config-types": "npm:^55.0.5"
"@expo/json-file": "npm:~10.0.12"
"@expo/plist": "npm:^0.5.2"
"@expo/sdk-runtime-versions": "npm:^1.0.0"
chalk: "npm:^4.1.2"
debug: "npm:^4.3.5"
getenv: "npm:^1.0.0"
glob: "npm:^10.4.2"
getenv: "npm:^2.0.0"
glob: "npm:^13.0.0"
resolve-from: "npm:^5.0.0"
semver: "npm:^7.5.4"
slash: "npm:^3.0.0"
slugify: "npm:^1.6.6"
xcode: "npm:^3.0.1"
xml2js: "npm:0.6.0"
checksum: 10c0/a27f085caf3d9ce29ca8b532425dd409054d8ef449b5d5338db2a2b1ead8051073e4f155691f6c2dc40b46816229fffbd09ef51c84eb87aa95eefaea9c810d40
checksum: 10c0/46cea118d9a780ac367862ef136aafa65063a3bc9a9864ece2a18d4b9e66e64d2bb19f0c0972e07f1106a58203f65671208bebcf5e207953f54217210807746c
languageName: node
linkType: hard
@@ -2859,7 +2991,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-plugins@npm:~9.0.0, @expo/config-plugins@npm:~9.0.10, @expo/config-plugins@npm:~9.0.17":
"@expo/config-plugins@npm:~9.0.0, @expo/config-plugins@npm:~9.0.17":
version: 9.0.17
resolution: "@expo/config-plugins@npm:9.0.17"
dependencies:
@@ -2881,17 +3013,10 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-types@npm:52.0.1":
version: 52.0.1
resolution: "@expo/config-types@npm:52.0.1"
checksum: 10c0/f06287eb20d599fbc422bf58dc97a803d8f9242699ce43188b90bfd92d54da27f83ca1225bf1ec61a5c30e74c57e47e7bfba2ef3cada38983622657658fafe4e
languageName: node
linkType: hard
"@expo/config-types@npm:^51.0.0-unreleased":
version: 51.0.3
resolution: "@expo/config-types@npm:51.0.3"
checksum: 10c0/bd87a729da985b0097ab29367c0473a2bced5ff211d416f342729e1b2631a7c00d62878e05cdee49ece7e3b65d3296957917f24380d57e2faef2cf220194fdec
"@expo/config-types@npm:55.0.5, @expo/config-types@npm:^55.0.5":
version: 55.0.5
resolution: "@expo/config-types@npm:55.0.5"
checksum: 10c0/24ce0481cc465ddd3b53cfdde099ef4e899b1f8fff224a0f249b88c93e6c98930e99a55f3929eb53d08138b1b66102ece7b76e16f4e5fadcdf5bbac26c9c3d7e
languageName: node
linkType: hard
@@ -2909,27 +3034,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/config@npm:10.0.6":
version: 10.0.6
resolution: "@expo/config@npm:10.0.6"
dependencies:
"@babel/code-frame": "npm:~7.10.4"
"@expo/config-plugins": "npm:~9.0.10"
"@expo/config-types": "npm:^52.0.0"
"@expo/json-file": "npm:^9.0.0"
deepmerge: "npm:^4.3.1"
getenv: "npm:^1.0.0"
glob: "npm:^10.4.2"
require-from-string: "npm:^2.0.2"
resolve-from: "npm:^5.0.0"
resolve-workspace-root: "npm:^2.0.0"
semver: "npm:^7.6.0"
slugify: "npm:^1.3.4"
sucrase: "npm:3.35.0"
checksum: 10c0/1fdf47ea0e268b475e30e02ebdc1f6739c0b426a4c6c695a7b4ee680109258b4ccfbd3010d3bd031a6efcd7a861670be84d10cd0019d0f5d14dc669a5039b860
languageName: node
linkType: hard
"@expo/config@npm:11.0.10":
version: 11.0.10
resolution: "@expo/config@npm:11.0.10"
@@ -2951,6 +3055,25 @@ __metadata:
languageName: node
linkType: hard
"@expo/config@npm:55.0.10":
version: 55.0.10
resolution: "@expo/config@npm:55.0.10"
dependencies:
"@expo/config-plugins": "npm:~55.0.7"
"@expo/config-types": "npm:^55.0.5"
"@expo/json-file": "npm:^10.0.12"
"@expo/require-utils": "npm:^55.0.3"
deepmerge: "npm:^4.3.1"
getenv: "npm:^2.0.0"
glob: "npm:^13.0.0"
resolve-from: "npm:^5.0.0"
resolve-workspace-root: "npm:^2.0.0"
semver: "npm:^7.6.0"
slugify: "npm:^1.3.4"
checksum: 10c0/a2e0a100c8d472e56e9ad649a8852eddca76ab64503475c803b4fc372df08e303169243b6a782fe150c54212ba02f47c40c4f0e365c13e77e70f5d777aa8d704
languageName: node
linkType: hard
"@expo/config@npm:~10.0.4":
version: 10.0.11
resolution: "@expo/config@npm:10.0.11"
@@ -3074,7 +3197,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:8.3.3, @expo/json-file@npm:~8.3.0":
"@expo/json-file@npm:8.3.3":
version: 8.3.3
resolution: "@expo/json-file@npm:8.3.3"
dependencies:
@@ -3085,6 +3208,16 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:^10.0.12, @expo/json-file@npm:~10.0.12":
version: 10.0.12
resolution: "@expo/json-file@npm:10.0.12"
dependencies:
"@babel/code-frame": "npm:^7.20.0"
json5: "npm:^2.2.3"
checksum: 10c0/52131a6426e96208ff1b295d580fc70eebb8e292b29fde1db016b2f21a0942a7521feec96b3f58efe5b32dcc1642d569b4211d651146fcdb9bf7e5f08b635878
languageName: node
linkType: hard
"@expo/json-file@npm:^10.0.8":
version: 10.0.8
resolution: "@expo/json-file@npm:10.0.8"
@@ -3126,7 +3259,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:~9.0.0, @expo/json-file@npm:~9.0.2":
"@expo/json-file@npm:~9.0.2":
version: 9.0.2
resolution: "@expo/json-file@npm:9.0.2"
dependencies:
@@ -3205,17 +3338,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/plist@npm:^0.1.0":
version: 0.1.3
resolution: "@expo/plist@npm:0.1.3"
dependencies:
"@xmldom/xmldom": "npm:~0.7.7"
base64-js: "npm:^1.2.3"
xmlbuilder: "npm:^14.0.0"
checksum: 10c0/134315260a7828bc1ce4563e2af67499b498feae46c39c5c2cab9d72082402a42d3b7575f13e269022bcf3c28668948ea960dd4943bd38f52f9c01154317aac5
languageName: node
linkType: hard
"@expo/plist@npm:^0.2.0, @expo/plist@npm:^0.2.2":
version: 0.2.2
resolution: "@expo/plist@npm:0.2.2"
@@ -3238,6 +3360,17 @@ __metadata:
languageName: node
linkType: hard
"@expo/plist@npm:^0.5.2":
version: 0.5.2
resolution: "@expo/plist@npm:0.5.2"
dependencies:
"@xmldom/xmldom": "npm:^0.8.8"
base64-js: "npm:^1.5.1"
xmlbuilder: "npm:^15.1.1"
checksum: 10c0/19adae2a365ac1a12db93682fb310ff8be03c711f9173bebe5841cbe60cdfb749247bc1a95fa0977b5bac3aa6a078a0fceeafe4ff6c66d1ed67cce496679e310
languageName: node
linkType: hard
"@expo/plugin-help@npm:5.1.23":
version: 5.1.23
resolution: "@expo/plugin-help@npm:5.1.23"
@@ -3294,6 +3427,22 @@ __metadata:
languageName: node
linkType: hard
"@expo/require-utils@npm:^55.0.3":
version: 55.0.3
resolution: "@expo/require-utils@npm:55.0.3"
dependencies:
"@babel/code-frame": "npm:^7.20.0"
"@babel/core": "npm:^7.25.2"
"@babel/plugin-transform-modules-commonjs": "npm:^7.24.8"
peerDependencies:
typescript: ^5.0.0 || ^5.0.0-0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10c0/562a2dc71f983fba2215295bbcc376d6911217c3a98b96484331112ff98c7a2e979fb1904ac293008ac557114c6658c1deb9b2f441bd246764b507103d2560cd
languageName: node
linkType: hard
"@expo/results@npm:1.0.0, @expo/results@npm:^1.0.0":
version: 1.0.0
resolution: "@expo/results@npm:1.0.0"
@@ -3406,8 +3555,8 @@ __metadata:
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.4.0"
"@expo/config-plugins": "npm:8.0.8"
"@expo/config-types": "npm:52.0.1"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/logger": "npm:18.0.1"
@@ -5053,7 +5202,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/gen-mapping@npm:^0.3.12":
"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5":
version: 0.3.13
resolution: "@jridgewell/gen-mapping@npm:0.3.13"
dependencies:
@@ -5074,6 +5223,16 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/remapping@npm:^2.3.5":
version: 2.3.5
resolution: "@jridgewell/remapping@npm:2.3.5"
dependencies:
"@jridgewell/gen-mapping": "npm:^0.3.5"
"@jridgewell/trace-mapping": "npm:^0.3.24"
checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194
languageName: node
linkType: hard
"@jridgewell/resolve-uri@npm:^3.0.3":
version: 3.0.7
resolution: "@jridgewell/resolve-uri@npm:3.0.7"
@@ -10839,10 +10998,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli@workspace:packages/eas-cli"
dependencies:
"@expo/apple-utils": "npm:2.1.15"
"@expo/apple-utils": "npm:2.1.16"
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:10.0.6"
"@expo/config-plugins": "npm:9.0.12"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/eas-json": "npm:18.4.0"
"@expo/env": "npm:^1.0.0"
@@ -11801,7 +11960,7 @@ __metadata:
languageName: node
linkType: hard
"find-up@npm:^5.0.0, find-up@npm:~5.0.0":
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
dependencies:
@@ -12273,6 +12432,13 @@ __metadata:
languageName: node
linkType: hard
"getenv@npm:^2.0.0":
version: 2.0.0
resolution: "getenv@npm:2.0.0"
checksum: 10c0/397ff641dd70cd78e414430258651e9a2228d3c5553a8cf15ae7840f75d3f10dfcb83f668f84829e84ea665b0fce2f08a9eddda3c9dcd7faa2d3da1c182c1854
languageName: node
linkType: hard
"git-hooks-list@npm:^3.0.0":
version: 3.2.0
resolution: "git-hooks-list@npm:3.2.0"
@@ -12377,20 +12543,6 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:7.1.6":
version: 7.1.6
resolution: "glob@npm:7.1.6"
dependencies:
fs.realpath: "npm:^1.0.0"
inflight: "npm:^1.0.4"
inherits: "npm:2"
minimatch: "npm:^3.0.4"
once: "npm:^1.3.0"
path-is-absolute: "npm:^1.0.0"
checksum: 10c0/2575cce9306ac534388db751f0aa3e78afedb6af8f3b529ac6b2354f66765545145dba8530abf7bff49fb399a047d3f9b6901c38ee4c9503f592960d9af67763
languageName: node
linkType: hard
"glob@npm:^10.2.2":
version: 10.3.10
resolution: "glob@npm:10.3.10"