From a431b0f887c76917839b89536d1449376132896c Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:00:21 +0800 Subject: [PATCH 01/14] fix(app): honor selected config path --- src/app.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/app.ts b/src/app.ts index 77ead4d..da3435c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import { doDelete, get, post } from './api'; import type { Platform } from './types'; import { loadTtyTable, question } from './utils'; +import { updateJson } from './utils/constants'; import { t } from './utils/i18n'; interface AppSummary { @@ -29,11 +30,12 @@ export async function getSelectedApp( ): Promise<{ appId: string; appKey: string; platform: Platform }> { assertPlatform(platform); + const resolvedConfigPath = configPath || updateJson; let updateInfo: Partial> = {}; try { updateInfo = JSON.parse( - await fs.promises.readFile(configPath || 'update.json', 'utf8'), + await fs.promises.readFile(resolvedConfigPath, 'utf8'), ); } catch (e: any) { if (e.code === 'ENOENT') { @@ -94,19 +96,19 @@ async function selectApp({ options, }: { args: string[]; - options: { platform?: Platform | '' }; + options: { platform?: Platform | ''; config?: string }; }) { const platform = await getPlatform(options.platform); const id = args[0] ? Number.parseInt(args[0], 10) : (await chooseApp(platform)).id; - const configPath = (options as any).config as string | undefined; + const configPath = options.config || updateJson; let updateInfo: Partial> = {}; try { updateInfo = JSON.parse( - await fs.promises.readFile(configPath || 'update.json', 'utf8'), + await fs.promises.readFile(configPath, 'utf8'), ); } catch (e: any) { if (e.code !== 'ENOENT') { @@ -120,7 +122,7 @@ async function selectApp({ appKey, }; await fs.promises.writeFile( - configPath || 'update.json', + configPath, JSON.stringify(updateInfo, null, 4), 'utf8', ); @@ -131,7 +133,12 @@ export function getAppCommands() { createApp: async ({ options, }: { - options: { name: string; downloadUrl: string; platform?: Platform | '' }; + options: { + name: string; + downloadUrl: string; + platform?: Platform | ''; + config?: string; + }; }) => { const name = options.name || (await question(t('appNameQuestion'))); const { downloadUrl } = options; @@ -140,7 +147,7 @@ export function getAppCommands() { console.log(t('createAppSuccess', { id })); await selectApp({ args: [String(id)], - options: { platform }, + options: { platform, config: options.config }, }); }, deleteApp: async ({ From 12e4c9e3a985fce473653226fbae536959444413 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:01:02 +0800 Subject: [PATCH 02/14] fix(bundle): preserve app target for publish --- src/bundle.ts | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/bundle.ts b/src/bundle.ts index fc764ae..35edff5 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -41,6 +41,8 @@ type NormalizedBundleOptions = { verifyHermesBase: boolean; resetCache: boolean; cacheMaxMb?: number; + appId?: string; + config?: string; name?: string; description?: string; metaInfo?: string; @@ -60,7 +62,9 @@ function parseCacheMaxMb(value: unknown): number | undefined { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } -type PublishBundlePayload = { +export type PublishBundlePayload = { + appId?: string; + config?: string; name?: string; description?: string; metaInfo?: string; @@ -121,6 +125,8 @@ export function normalizeBundleOptions( ), resetCache: getBooleanOption(translatedOptions, 'resetCache', true), cacheMaxMb: parseCacheMaxMb(translatedOptions.cacheMaxMb), + appId: getOptionalStringOption(translatedOptions, 'appId'), + config: getOptionalStringOption(translatedOptions, 'config'), name: getOptionalStringOption(translatedOptions, 'name'), description: getOptionalStringOption(translatedOptions, 'description'), metaInfo: getOptionalStringOption(translatedOptions, 'metaInfo'), @@ -178,18 +184,31 @@ async function uploadSentryArtifactsIfNeeded( ); } -async function publishBundleVersion( +export function createPublishBundleRequest( outputPath: string, platform: Platform, payload: PublishBundlePayload, -): Promise { - return versionCommands.publish({ +): { + args: string[]; + options: PublishBundlePayload & { platform: Platform }; +} { + return { args: [outputPath], options: { platform, ...payload, }, - }); + }; +} + +async function publishBundleVersion( + outputPath: string, + platform: Platform, + payload: PublishBundlePayload, +): Promise { + return versionCommands.publish( + createPublishBundleRequest(outputPath, platform, payload), + ); } export const bundleCommands = { @@ -231,15 +250,12 @@ export const bundleCommands = { await cleanStaleTmp().catch(() => {}); // the hermes base lookup needs the app; resolve it up front but never - // fail the bundle over it (publishing resolves it again and reports) - let appIdForBase: string | undefined = - typeof options.appId === 'string' && options.appId - ? options.appId - : undefined; + // fail the bundle over it (publishing reports target errors later) + let appIdForBase = normalized.appId; if (!appIdForBase && normalized.hermesBase === 'auto') { try { appIdForBase = ( - await getSelectedApp(platform, options.config as string | undefined) + await getSelectedApp(platform, normalized.config) ).appId; } catch { appIdForBase = undefined; @@ -284,6 +300,8 @@ export const bundleCommands = { if (normalized.name) { await publishBundleVersion(realOutput, platform, { + appId: normalized.appId, + config: normalized.config, name: normalized.name, description: normalized.description, metaInfo: normalized.metaInfo, @@ -314,6 +332,8 @@ export const bundleCommands = { const v = await question(t('uploadBundlePrompt')); if (v.toLowerCase() === 'y') { await publishBundleVersion(realOutput, platform, { + appId: normalized.appId, + config: normalized.config, hermesBase: baseMeta, }); await uploadSentryArtifactsIfNeeded( From 06486b86a08fdf375a9e7585b67d9800f4d27e91 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:02:36 +0800 Subject: [PATCH 03/14] fix(publish): bind using the resolved app id --- src/versions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/versions.ts b/src/versions.ts index e20ee41..adf02d5 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -585,6 +585,7 @@ export const versionCommands = { options: { versionId: id, platform, + appId: String(appId), packageId, packageVersion, packageVersionRange, @@ -603,6 +604,7 @@ export const versionCommands = { options: { versionId: id, platform, + appId: String(appId), versionDeps: depVersions, warnDepsChanges: true, }, From 48cf2c63caef92030d54eef252fbfcde22b33634 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:04:12 +0800 Subject: [PATCH 04/14] test(publish): cover app target propagation --- tests/versions.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/versions.test.ts b/tests/versions.test.ts index 43306e9..7704e1e 100644 --- a/tests/versions.test.ts +++ b/tests/versions.test.ts @@ -161,12 +161,45 @@ describe('versionCommands.publish', () => { options: { versionId: '200', platform: 'android', + appId: '100', versionDeps: expect.any(Object), warnDepsChanges: true, }, }); }); + test('keeps an explicit appId through version creation and binding', async () => { + await versionCommands.publish({ + args: ['bundle.ppk'], + options: { + platform: 'android', + appId: '777', + name: 'v1', + packageVersion: '1.0.0', + 'no-interactive': true, + }, + }); + + expect(getSelectedAppSpy).not.toHaveBeenCalled(); + expect(uploadFileSpy).toHaveBeenCalledWith( + 'bundle.ppk', + undefined, + '777', + ); + expect(postSpy).toHaveBeenCalledWith( + '/app/777/version/create', + expect.any(Object), + ); + expect(updateSpy).toHaveBeenCalledWith({ + options: expect.objectContaining({ + versionId: '200', + platform: 'android', + appId: '777', + packageVersion: '1.0.0', + }), + }); + }); + test('does not prompt for optional fields in no-interactive mode', async () => { await versionCommands.publish({ args: ['bundle.ppk'], From fe5f709d420c13c302140d376a8127df965c6a8c Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:04:40 +0800 Subject: [PATCH 05/14] test(target): cover config and bundle context --- tests/target-context.test.ts | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/target-context.test.ts diff --git a/tests/target-context.test.ts b/tests/target-context.test.ts new file mode 100644 index 0000000..5202d0b --- /dev/null +++ b/tests/target-context.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import fs from 'fs'; +import * as api from '../src/api'; +import { getAppCommands, getSelectedApp } from '../src/app'; +import { + createPublishBundleRequest, + normalizeBundleOptions, +} from '../src/bundle'; + +describe('bundle target context', () => { + test('preserves appId and config in the publish request', () => { + const normalized = normalizeBundleOptions( + { + appId: '42', + config: 'configs/release.update.json', + }, + 'ios', + ); + + expect(normalized.appId).toBe('42'); + expect(normalized.config).toBe('configs/release.update.json'); + expect( + createPublishBundleRequest('dist/ios.ppk', 'ios', { + appId: normalized.appId, + config: normalized.config, + name: 'v1', + }), + ).toEqual({ + args: ['dist/ios.ppk'], + options: { + platform: 'ios', + appId: '42', + config: 'configs/release.update.json', + name: 'v1', + }, + }); + }); +}); + +describe('app config target', () => { + let postSpy: ReturnType; + let getSpy: ReturnType; + let readFileSpy: ReturnType; + let writeFileSpy: ReturnType; + let consoleLogSpy: ReturnType; + + afterEach(() => { + postSpy?.mockRestore(); + getSpy?.mockRestore(); + readFileSpy?.mockRestore(); + writeFileSpy?.mockRestore(); + consoleLogSpy?.mockRestore(); + }); + + test('getSelectedApp reads the explicit config path', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + JSON.stringify({ ios: { appId: 42, appKey: 'key-42' } }), + ); + + await expect( + getSelectedApp('ios', 'configs/release.update.json'), + ).resolves.toEqual({ + appId: '42', + appKey: 'key-42', + platform: 'ios', + }); + expect(readFileSpy).toHaveBeenCalledWith( + 'configs/release.update.json', + 'utf8', + ); + }); + + test('createApp selects the new app in the explicit config file', async () => { + postSpy = spyOn(api, 'post').mockResolvedValue({ id: 10 }); + getSpy = spyOn(api, 'get').mockResolvedValue({ appKey: 'key-ios-10' }); + const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoentError); + writeFileSpy = spyOn(fs.promises, 'writeFile').mockResolvedValue(); + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + + await getAppCommands().createApp({ + options: { + name: 'SmallWOD', + downloadUrl: '', + platform: 'ios', + config: 'configs/release.update.json', + }, + }); + + expect(readFileSpy).toHaveBeenCalledWith( + 'configs/release.update.json', + 'utf8', + ); + expect(writeFileSpy).toHaveBeenCalledWith( + 'configs/release.update.json', + JSON.stringify( + { + ios: { + appId: 10, + appKey: 'key-ios-10', + }, + }, + null, + 4, + ), + 'utf8', + ); + }); +}); From 9f03393abc3a619b9853186ed2d6545fbfc8c6b7 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:09:50 +0800 Subject: [PATCH 06/14] style: apply biome formatting --- src/app.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index da3435c..783d2a0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -107,9 +107,7 @@ async function selectApp({ let updateInfo: Partial> = {}; try { - updateInfo = JSON.parse( - await fs.promises.readFile(configPath, 'utf8'), - ); + updateInfo = JSON.parse(await fs.promises.readFile(configPath, 'utf8')); } catch (e: any) { if (e.code !== 'ENOENT') { console.error(t('failedToParseUpdateJson')); From 6d2cb53ff078660abe5fc2373df44c7f18e49ce3 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:10:44 +0800 Subject: [PATCH 07/14] style: format bundle target lookup --- src/bundle.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bundle.ts b/src/bundle.ts index 35edff5..1aeb3b2 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -254,9 +254,8 @@ export const bundleCommands = { let appIdForBase = normalized.appId; if (!appIdForBase && normalized.hermesBase === 'auto') { try { - appIdForBase = ( - await getSelectedApp(platform, normalized.config) - ).appId; + appIdForBase = (await getSelectedApp(platform, normalized.config)) + .appId; } catch { appIdForBase = undefined; } From 1169b49c687d7adf62b10a554cc25ced74b102e2 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 29 Aug 2026 22:11:53 +0800 Subject: [PATCH 08/14] style: format publish regression test --- tests/versions.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/versions.test.ts b/tests/versions.test.ts index 7704e1e..1d5d2e6 100644 --- a/tests/versions.test.ts +++ b/tests/versions.test.ts @@ -181,11 +181,7 @@ describe('versionCommands.publish', () => { }); expect(getSelectedAppSpy).not.toHaveBeenCalled(); - expect(uploadFileSpy).toHaveBeenCalledWith( - 'bundle.ppk', - undefined, - '777', - ); + expect(uploadFileSpy).toHaveBeenCalledWith('bundle.ppk', undefined, '777'); expect(postSpy).toHaveBeenCalledWith( '/app/777/version/create', expect.any(Object), From 769ba6f39ba4a69c3b8f086a0857970901b21838 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 30 Aug 2026 00:40:48 +0800 Subject: [PATCH 09/14] refactor(target): add operation-scoped app resolver --- src/app.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/app.ts b/src/app.ts index 783d2a0..8f42799 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,18 @@ interface AppSummary { platform: Platform; } +export interface AppTargetOptions { + appId?: string; + config?: string; +} + +export interface ResolvedAppTarget { + appId: string; + appKey?: string; + platform: Platform; + configPath: string; +} + export async function getPlatform(platform?: string) { return assertPlatform( platform || (await question(t('platformQuestion'))), @@ -54,6 +66,44 @@ export async function getSelectedApp( }; } +/** Resolve an explicit or selected app into a stable operation target. */ +export async function resolveAppTarget( + platform: Platform, + options: AppTargetOptions = {}, +): Promise { + const configPath = options.config || updateJson; + if (options.appId) { + return { + appId: String(options.appId), + platform, + configPath, + }; + } + + return { + ...(await getSelectedApp(platform, configPath)), + configPath, + }; +} + +/** Cache app selection for one operation and retry after a failed lookup. */ +export function createAppTargetResolver( + platform: Platform, + options: AppTargetOptions = {}, +): () => Promise { + let pending: Promise | undefined; + + return () => { + if (!pending) { + pending = resolveAppTarget(platform, options).catch((error) => { + pending = undefined; + throw error; + }); + } + return pending; + }; +} + export async function listApp(platform: Platform | '' = '') { const { data } = await get('/app/list'); const allApps = data as AppSummary[]; From 78abd05394dcb40539c79a5f7d668a6248f12b33 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 30 Aug 2026 00:41:33 +0800 Subject: [PATCH 10/14] fix(target): reuse one resolved app across bundle and publish --- src/bundle.ts | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/bundle.ts b/src/bundle.ts index 1aeb3b2..b79688e 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -1,5 +1,9 @@ import path from 'path'; -import { getPlatform, getSelectedApp } from './app'; +import { + createAppTargetResolver, + getPlatform, + type ResolvedAppTarget, +} from './app'; import { packBundle } from './bundle-pack'; import { copyDebugidForSentry, @@ -246,21 +250,30 @@ export const bundleCommands = { throw new Error(t('platformRequired')); } - console.log(t('bundlingWithRN', { version: depVersions['react-native'] })); + const resolveTarget = createAppTargetResolver(platform, { + appId: normalized.appId, + config: normalized.config, + }); + let resolvedTarget: ResolvedAppTarget | undefined; + const shouldResolveTargetBeforeBundle = + Boolean(normalized.name) || + (normalized.dev !== 'true' && normalized.hermesBase === 'auto'); - await cleanStaleTmp().catch(() => {}); - // the hermes base lookup needs the app; resolve it up front but never - // fail the bundle over it (publishing reports target errors later) - let appIdForBase = normalized.appId; - if (!appIdForBase && normalized.hermesBase === 'auto') { + if (shouldResolveTargetBeforeBundle) { try { - appIdForBase = (await getSelectedApp(platform, normalized.config)) - .appId; - } catch { - appIdForBase = undefined; + resolvedTarget = await resolveTarget(); + } catch (error) { + // A bundle-only command may still fall back when no remote Hermes base + // is available. Named publishing must fail before doing expensive work. + if (normalized.name) { + throw error; + } } } + console.log(t('bundlingWithRN', { version: depVersions['react-native'] })); + + await cleanStaleTmp().catch(() => {}); const hermesResult = await runReactNativeBundleCommand({ bundleName: normalized.bundleName, dev: normalized.dev, @@ -275,7 +288,7 @@ export const bundleCommands = { ? undefined : { option: normalized.hermesBase, - appId: appIdForBase, + appId: resolvedTarget?.appId, verify: normalized.verifyHermesBase, cacheMaxMb: normalized.cacheMaxMb, }, @@ -298,9 +311,10 @@ export const bundleCommands = { : undefined; if (normalized.name) { + const target = resolvedTarget ?? (await resolveTarget()); await publishBundleVersion(realOutput, platform, { - appId: normalized.appId, - config: normalized.config, + appId: target.appId, + config: target.configPath, name: normalized.name, description: normalized.description, metaInfo: normalized.metaInfo, @@ -330,9 +344,10 @@ export const bundleCommands = { if (!getBooleanOption(options, 'no-interactive', false)) { const v = await question(t('uploadBundlePrompt')); if (v.toLowerCase() === 'y') { + const target = resolvedTarget ?? (await resolveTarget()); await publishBundleVersion(realOutput, platform, { - appId: normalized.appId, - config: normalized.config, + appId: target.appId, + config: target.configPath, hermesBase: baseMeta, }); await uploadSentryArtifactsIfNeeded( From 2db00092322a81befd100f1138fe1189dd6ab8b4 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 30 Aug 2026 00:42:05 +0800 Subject: [PATCH 11/14] test(target): cover operation-scoped app resolution --- tests/target-context.test.ts | 90 +++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/tests/target-context.test.ts b/tests/target-context.test.ts index 5202d0b..c776233 100644 --- a/tests/target-context.test.ts +++ b/tests/target-context.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import fs from 'fs'; import * as api from '../src/api'; -import { getAppCommands, getSelectedApp } from '../src/app'; +import { + createAppTargetResolver, + getAppCommands, + getSelectedApp, + resolveAppTarget, +} from '../src/app'; import { createPublishBundleRequest, normalizeBundleOptions, @@ -35,6 +40,71 @@ describe('bundle target context', () => { }, }); }); + + test('reuses one selected app for Hermes lookup and publishing', async () => { + const readFileSpy = spyOn(fs.promises, 'readFile') + .mockResolvedValueOnce( + JSON.stringify({ ios: { appId: 42, appKey: 'key-42' } }), + ) + .mockResolvedValueOnce( + JSON.stringify({ ios: { appId: 99, appKey: 'key-99' } }), + ); + + try { + const resolveTarget = createAppTargetResolver('ios', { + config: 'configs/release.update.json', + }); + const hermesTarget = await resolveTarget(); + const publishTarget = await resolveTarget(); + + expect(hermesTarget).toEqual(publishTarget); + expect(publishTarget.appId).toBe('42'); + expect(readFileSpy).toHaveBeenCalledTimes(1); + expect( + createPublishBundleRequest('dist/ios.ppk', 'ios', { + appId: publishTarget.appId, + config: publishTarget.configPath, + name: 'v1', + }), + ).toEqual({ + args: ['dist/ios.ppk'], + options: { + platform: 'ios', + appId: '42', + config: 'configs/release.update.json', + name: 'v1', + }, + }); + } finally { + readFileSpy.mockRestore(); + } + }); + + test('retries selection after a failed best-effort lookup', async () => { + const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + const readFileSpy = spyOn(fs.promises, 'readFile') + .mockRejectedValueOnce(enoentError) + .mockResolvedValueOnce( + JSON.stringify({ ios: { appId: 42, appKey: 'key-42' } }), + ); + + try { + const resolveTarget = createAppTargetResolver('ios', { + config: 'configs/release.update.json', + }); + + await expect(resolveTarget()).rejects.toThrow(); + await expect(resolveTarget()).resolves.toEqual({ + appId: '42', + appKey: 'key-42', + platform: 'ios', + configPath: 'configs/release.update.json', + }); + expect(readFileSpy).toHaveBeenCalledTimes(2); + } finally { + readFileSpy.mockRestore(); + } + }); }); describe('app config target', () => { @@ -70,6 +140,24 @@ describe('app config target', () => { ); }); + test('explicit appId does not read the selected-app config', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue( + new Error('config should not be read'), + ); + + await expect( + resolveAppTarget('android', { + appId: '777', + config: 'configs/release.update.json', + }), + ).resolves.toEqual({ + appId: '777', + platform: 'android', + configPath: 'configs/release.update.json', + }); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + test('createApp selects the new app in the explicit config file', async () => { postSpy = spyOn(api, 'post').mockResolvedValue({ id: 10 }); getSpy = spyOn(api, 'get').mockResolvedValue({ appKey: 'key-ios-10' }); From dc92eced00c7d90808c0002489d1849803bf59e9 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 30 Aug 2026 00:48:43 +0800 Subject: [PATCH 12/14] docs(target): document app resolution lifecycle --- src/app.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app.ts b/src/app.ts index 8f42799..e20b69e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,12 +23,14 @@ export interface ResolvedAppTarget { configPath: string; } +/** Resolve an explicit platform or prompt for one interactively. */ export async function getPlatform(platform?: string) { return assertPlatform( platform || (await question(t('platformQuestion'))), ) as Platform; } +/** Validate that a string names a platform supported by the update service. */ export function assertPlatform(platform: string): Platform { if (platform !== 'ios' && platform !== 'android' && platform !== 'harmony') { throw new Error(t('unsupportedPlatform', { platform })); @@ -36,6 +38,7 @@ export function assertPlatform(platform: string): Platform { return platform as Platform; } +/** Read the selected app for a platform from the requested config file. */ export async function getSelectedApp( platform: Platform, configPath?: string, @@ -104,6 +107,7 @@ export function createAppTargetResolver( }; } +/** List apps, optionally filtering them to one platform. */ export async function listApp(platform: Platform | '' = '') { const { data } = await get('/app/list'); const allApps = data as AppSummary[]; @@ -129,6 +133,7 @@ export async function listApp(platform: Platform | '' = '') { return list; } +/** Prompt until the user chooses an app belonging to the target platform. */ export async function chooseApp(platform: Platform) { const list = await listApp(platform); @@ -141,6 +146,7 @@ export async function chooseApp(platform: Platform) { } } +/** Persist an app selection in the requested brand-aware config file. */ async function selectApp({ args, options, @@ -176,8 +182,10 @@ async function selectApp({ ); } +/** Build the application-management command handlers used by the CLI. */ export function getAppCommands() { return { + /** Create an app and select it in the same configuration file. */ createApp: async ({ options, }: { @@ -198,6 +206,7 @@ export function getAppCommands() { options: { platform, config: options.config }, }); }, + /** Delete the specified app, or prompt for one when no ID is supplied. */ deleteApp: async ({ args, options, @@ -214,6 +223,7 @@ export function getAppCommands() { await doDelete(`/app/${id}`); console.log(t('operationSuccess')); }, + /** List apps through the command interface. */ apps: async ({ options }: { options: { platform?: Platform | '' } }) => { const { platform = '' } = options; return listApp(platform); From eeba089ee45304612e8fcda6001508f48b596c76 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sun, 30 Aug 2026 00:49:24 +0800 Subject: [PATCH 13/14] docs(target): document bundle target flow --- src/bundle.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/bundle.ts b/src/bundle.ts index b79688e..bb5bdc9 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -61,6 +61,7 @@ type NormalizedBundleOptions = { sentryDist?: string; }; +/** Parse a positive cache-size option expressed in megabytes. */ function parseCacheMaxMb(value: unknown): number | undefined { const parsed = typeof value === 'string' ? Number(value) : (value as number); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; @@ -82,6 +83,7 @@ export type PublishBundlePayload = { hermesBase?: HermesBaseMeta; }; +/** Read either spelling of an aliased optional CLI string option. */ function getAliasedOptionalStringOption( options: Record, key: string, @@ -93,6 +95,7 @@ function getAliasedOptionalStringOption( ); } +/** Normalize translated CLI values into the bundle command's typed options. */ export function normalizeBundleOptions( translatedOptions: Record, platform: string, @@ -166,6 +169,7 @@ export function normalizeBundleOptions( }; } +/** Upload generated Sentry artifacts when the detected plugin requires them. */ async function uploadSentryArtifactsIfNeeded( shouldUpload: boolean, bundleName: string, @@ -188,6 +192,7 @@ async function uploadSentryArtifactsIfNeeded( ); } +/** Build the version publish request while preserving its resolved app target. */ export function createPublishBundleRequest( outputPath: string, platform: Platform, @@ -205,6 +210,7 @@ export function createPublishBundleRequest( }; } +/** Publish a packed bundle through the version command implementation. */ async function publishBundleVersion( outputPath: string, platform: Platform, @@ -216,6 +222,7 @@ async function publishBundleVersion( } export const bundleCommands = { + /** Build a bundle and optionally publish it to one operation-scoped app. */ bundle: async ({ options, }: { From 7e89a299dc973f5db28e86a1f258a27c6cf4e75e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 30 Aug 2026 10:36:38 +0800 Subject: [PATCH 14/14] refactor(target): resolve one app id per operation; keep update.json for both brands Follow-up to the review of #74. - keep `update.json` as the selected-app file for cresc too: the cresc docs and the client SDK read that name, and `cresc.config.json` was never wired in, so switching the default would have broken every existing cresc project - replace the nine hand-rolled `options.appId || getSelectedApp(...)` blocks in bundle/versions/package with one `resolveAppId()` helper - bundle: resolve the app before any side effect (.gitignore edits, plugin probes) so a named bundle without a selected app fails immediately; a bundle-only run only tolerates a missing selection (typed AppNotSelectedError) and reports malformed configs instead of swallowing them; drop the dead `config` forwarding and the three-way cached target - SDK: `BundleOptions.appId/config` and `provider.getSelectedApp(platform, config)` so programmatic callers get the same single-app guarantee - messages: parse/mismatch errors name the file (or `--appId`) actually used - tests: exercise bundleCommands.bundle end to end (Hermes base + publish get the same app, fail-fast, bundle-only fallback, dev bundles) and the default file, instead of the removed wrapper helpers Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JCaS35vZG4DCtmM24MYaVR --- src/app.ts | 80 +++---- src/bundle.ts | 115 +++++------ src/locales/en.ts | 14 +- src/locales/zh.ts | 14 +- src/package.ts | 35 ++-- src/provider.ts | 5 +- src/types.ts | 5 + src/utils/constants.ts | 4 +- src/versions.ts | 73 ++----- tests/constants.test.ts | 2 +- tests/target-context.test.ts | 389 +++++++++++++++++++++++------------ 11 files changed, 401 insertions(+), 335 deletions(-) diff --git a/src/app.ts b/src/app.ts index e20b69e..7fd51b3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,13 +14,16 @@ interface AppSummary { export interface AppTargetOptions { appId?: string; config?: string; + platform?: Platform | ''; } -export interface ResolvedAppTarget { - appId: string; - appKey?: string; - platform: Platform; - configPath: string; +/** The selected-app config file was missing or has no entry for the platform. */ +export class AppNotSelectedError extends Error { + readonly code = 'APP_NOT_SELECTED'; + constructor(platform: Platform) { + super(t('appNotSelected', { platform })); + this.name = 'AppNotSelectedError'; + } } /** Resolve an explicit platform or prompt for one interactively. */ @@ -46,21 +49,26 @@ export async function getSelectedApp( assertPlatform(platform); const resolvedConfigPath = configPath || updateJson; - let updateInfo: Partial> = - {}; + let raw: string; try { - updateInfo = JSON.parse( - await fs.promises.readFile(resolvedConfigPath, 'utf8'), - ); + raw = await fs.promises.readFile(resolvedConfigPath, 'utf8'); } catch (e: any) { if (e.code === 'ENOENT') { - throw new Error(t('appNotSelected', { platform })); + throw new AppNotSelectedError(platform); } throw e; } + let updateInfo: Partial>; + try { + updateInfo = JSON.parse(raw); + } catch { + throw new Error( + t('failedToParseUpdateJson', { configPath: resolvedConfigPath }), + ); + } const info = updateInfo[platform]; if (!info) { - throw new Error(t('appNotSelected', { platform })); + throw new AppNotSelectedError(platform); } return { appId: String(info.appId), @@ -69,42 +77,22 @@ export async function getSelectedApp( }; } -/** Resolve an explicit or selected app into a stable operation target. */ -export async function resolveAppTarget( - platform: Platform, +/** + * Resolve the app an operation targets: an explicit `--appId` wins, otherwise + * the app selected for the platform in `--config` (default: update.json). + * Prompts for the platform only when it is needed and not given. + */ +export async function resolveAppId( options: AppTargetOptions = {}, -): Promise { - const configPath = options.config || updateJson; +): Promise { + if (options.platform) { + assertPlatform(options.platform); + } if (options.appId) { - return { - appId: String(options.appId), - platform, - configPath, - }; + return String(options.appId); } - - return { - ...(await getSelectedApp(platform, configPath)), - configPath, - }; -} - -/** Cache app selection for one operation and retry after a failed lookup. */ -export function createAppTargetResolver( - platform: Platform, - options: AppTargetOptions = {}, -): () => Promise { - let pending: Promise | undefined; - - return () => { - if (!pending) { - pending = resolveAppTarget(platform, options).catch((error) => { - pending = undefined; - throw error; - }); - } - return pending; - }; + const platform = await getPlatform(options.platform || undefined); + return (await getSelectedApp(platform, options.config)).appId; } /** List apps, optionally filtering them to one platform. */ @@ -166,7 +154,7 @@ async function selectApp({ updateInfo = JSON.parse(await fs.promises.readFile(configPath, 'utf8')); } catch (e: any) { if (e.code !== 'ENOENT') { - console.error(t('failedToParseUpdateJson')); + console.error(t('failedToParseUpdateJson', { configPath })); throw e; } } diff --git a/src/bundle.ts b/src/bundle.ts index bb5bdc9..b804b4c 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -1,9 +1,5 @@ import path from 'path'; -import { - createAppTargetResolver, - getPlatform, - type ResolvedAppTarget, -} from './app'; +import { AppNotSelectedError, getPlatform, resolveAppId } from './app'; import { packBundle } from './bundle-pack'; import { copyDebugidForSentry, @@ -67,9 +63,8 @@ function parseCacheMaxMb(value: unknown): number | undefined { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } -export type PublishBundlePayload = { - appId?: string; - config?: string; +type PublishBundlePayload = { + appId: string; name?: string; description?: string; metaInfo?: string; @@ -192,33 +187,19 @@ async function uploadSentryArtifactsIfNeeded( ); } -/** Build the version publish request while preserving its resolved app target. */ -export function createPublishBundleRequest( +/** Publish a packed bundle through the version command implementation. */ +async function publishBundleVersion( outputPath: string, platform: Platform, payload: PublishBundlePayload, -): { - args: string[]; - options: PublishBundlePayload & { platform: Platform }; -} { - return { +): Promise { + return versionCommands.publish({ args: [outputPath], options: { platform, ...payload, }, - }; -} - -/** Publish a packed bundle through the version command implementation. */ -async function publishBundleVersion( - outputPath: string, - platform: Platform, - payload: PublishBundlePayload, -): Promise { - return versionCommands.publish( - createPublishBundleRequest(outputPath, platform, payload), - ); + }); } export const bundleCommands = { @@ -240,10 +221,48 @@ export const bundleCommands = { }); const normalized = normalizeBundleOptions(translatedOptions, platform); + // One app per operation: the Hermes base lookup and the publish step must + // never see different apps, so the target is resolved once and reused. + let appId: string | undefined; + const getAppId = async () => + (appId ??= await resolveAppId({ + appId: normalized.appId, + config: normalized.config, + platform, + })); + const hermesBase = + normalized.dev === 'true' + ? undefined + : { + option: normalized.hermesBase, + verify: normalized.verifyHermesBase, + cacheMaxMb: normalized.cacheMaxMb, + }; + + // Resolve before any side effect or expensive work. A named bundle is + // published, so a missing app fails right here; a bundle-only run only + // needs the app for the remote Hermes base lookup and may go on without + // one (it then compiles a full bundle). Any other config error (e.g. + // malformed JSON) is reported immediately either way. + if (normalized.name) { + await getAppId(); + } else if (hermesBase?.option === 'auto') { + try { + await getAppId(); + } catch (error) { + if (!(error instanceof AppNotSelectedError)) { + throw error; + } + } + } + checkLockFiles(); addGitIgnore(); - const bundleParams = await checkPlugins(); + const [bundleParams] = await Promise.all([ + checkPlugins(), + cleanStaleTmp().catch(() => {}), + ]); const sourcemapOutput = path.join( normalized.intermediaDir, `${normalized.bundleName}.map`, @@ -257,30 +276,8 @@ export const bundleCommands = { throw new Error(t('platformRequired')); } - const resolveTarget = createAppTargetResolver(platform, { - appId: normalized.appId, - config: normalized.config, - }); - let resolvedTarget: ResolvedAppTarget | undefined; - const shouldResolveTargetBeforeBundle = - Boolean(normalized.name) || - (normalized.dev !== 'true' && normalized.hermesBase === 'auto'); - - if (shouldResolveTargetBeforeBundle) { - try { - resolvedTarget = await resolveTarget(); - } catch (error) { - // A bundle-only command may still fall back when no remote Hermes base - // is available. Named publishing must fail before doing expensive work. - if (normalized.name) { - throw error; - } - } - } - console.log(t('bundlingWithRN', { version: depVersions['react-native'] })); - await cleanStaleTmp().catch(() => {}); const hermesResult = await runReactNativeBundleCommand({ bundleName: normalized.bundleName, dev: normalized.dev, @@ -290,15 +287,7 @@ export const bundleCommands = { sourcemapOutput: normalized.sourcemap || bundleParams.sourcemap ? sourcemapOutput : '', forceHermes: normalized.hermes, - hermesBase: - normalized.dev === 'true' - ? undefined - : { - option: normalized.hermesBase, - appId: resolvedTarget?.appId, - verify: normalized.verifyHermesBase, - cacheMaxMb: normalized.cacheMaxMb, - }, + hermesBase: hermesBase ? { ...hermesBase, appId } : undefined, resetCache: normalized.resetCache, cli: { taro: normalized.taro, @@ -318,10 +307,8 @@ export const bundleCommands = { : undefined; if (normalized.name) { - const target = resolvedTarget ?? (await resolveTarget()); await publishBundleVersion(realOutput, platform, { - appId: target.appId, - config: target.configPath, + appId: await getAppId(), name: normalized.name, description: normalized.description, metaInfo: normalized.metaInfo, @@ -351,10 +338,8 @@ export const bundleCommands = { if (!getBooleanOption(options, 'no-interactive', false)) { const v = await question(t('uploadBundlePrompt')); if (v.toLowerCase() === 'y') { - const target = resolvedTarget ?? (await resolveTarget()); await publishBundleVersion(realOutput, platform, { - appId: target.appId, - config: target.configPath, + appId: await getAppId(), hermesBase: baseMeta, }); await uploadSentryArtifactsIfNeeded( diff --git a/src/locales/en.ts b/src/locales/en.ts index bf8b53f..b0f72ff 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -16,17 +16,17 @@ export default { aabParseResourcesError: 'Parser resources.arsc error: {{error}}', appId: 'App ID', appIdMismatchApk: - 'App ID mismatch! Current APK: {{appIdInPkg}}, current update.json: {{appId}}', + 'App ID mismatch! Current APK: {{appIdInPkg}}, current {{- source}}: {{appId}}', appIdMismatchApp: - 'App ID mismatch! Current APP: {{appIdInPkg}}, current update.json: {{appId}}', + 'App ID mismatch! Current APP: {{appIdInPkg}}, current {{- source}}: {{appId}}', appIdMismatchIpa: - 'App ID mismatch! Current IPA: {{appIdInPkg}}, current update.json: {{appId}}', + 'App ID mismatch! Current IPA: {{appIdInPkg}}, current {{- source}}: {{appId}}', appKeyMismatchApk: - 'App Key mismatch! Current APK: {{appKeyInPkg}}, current update.json: {{appKey}}', + 'App Key mismatch! Current APK: {{appKeyInPkg}}, current {{- source}}: {{appKey}}', appKeyMismatchApp: - 'App Key mismatch! Current APP: {{appKeyInPkg}}, current update.json: {{appKey}}', + 'App Key mismatch! Current APP: {{appKeyInPkg}}, current {{- source}}: {{appKey}}', appKeyMismatchIpa: - 'App Key mismatch! Current IPA: {{appKeyInPkg}}, current update.json: {{appKey}}', + 'App Key mismatch! Current IPA: {{appKeyInPkg}}, current {{- source}}: {{appKey}}', appName: 'App Name', appNameQuestion: 'App Name:', appNotSelected: @@ -57,7 +57,7 @@ export default { expiredStatus: '(Expired)', failedToParseIcon: '[Warning] failed to parse icon: {{error}}', failedToParseUpdateJson: - 'Failed to parse file `update.json`. Try to remove it manually.', + 'Failed to parse file `{{- configPath}}`. Try to remove it manually.', fileGenerated: '{{- file}} generated.', fileSizeExceeded: 'This file size is {{fileSize}} , exceeding the current quota {{maxSize}} . You may consider upgrading to a higher plan to increase this quota. Details can be found at: {{- pricingPageUrl}}', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index f777e82..18984d3 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -14,17 +14,17 @@ export default { aabParseResourcesError: '解析 resources.arsc 出错:{{error}}', appId: '应用 id', appIdMismatchApk: - 'appId不匹配!当前apk: {{appIdInPkg}}, 当前update.json: {{appId}}', + 'appId不匹配!当前apk: {{appIdInPkg}}, 当前{{- source}}: {{appId}}', appIdMismatchApp: - 'appId不匹配!当前app: {{appIdInPkg}}, 当前update.json: {{appId}}', + 'appId不匹配!当前app: {{appIdInPkg}}, 当前{{- source}}: {{appId}}', appIdMismatchIpa: - 'appId不匹配!当前ipa: {{appIdInPkg}}, 当前update.json: {{appId}}', + 'appId不匹配!当前ipa: {{appIdInPkg}}, 当前{{- source}}: {{appId}}', appKeyMismatchApk: - 'appKey不匹配!当前apk: {{appKeyInPkg}}, 当前update.json: {{appKey}}', + 'appKey不匹配!当前apk: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}', appKeyMismatchApp: - 'appKey不匹配!当前app: {{appKeyInPkg}}, 当前update.json: {{appKey}}', + 'appKey不匹配!当前app: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}', appKeyMismatchIpa: - 'appKey不匹配!当前ipa: {{appKeyInPkg}}, 当前update.json: {{appKey}}', + 'appKey不匹配!当前ipa: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}', appName: '应用名称', appNameQuestion: '应用名称:', appNotSelected: @@ -53,7 +53,7 @@ export default { errorInHarmonyApp: '获取 Harmony 应用入口时出错:{{error}}', expiredStatus: '(已过期)', failedToParseIcon: '[警告] 解析图标失败:{{error}}', - failedToParseUpdateJson: '无法解析文件 `update.json`。请手动删除它。', + failedToParseUpdateJson: '无法解析文件 `{{- configPath}}`。请手动删除它。', fileGenerated: '已生成 {{- file}}', fileSizeExceeded: '此文件大小 {{fileSize}} , 超出当前额度 {{maxSize}} 。您可以考虑升级付费业务以提升此额度。详情请访问: {{- pricingPageUrl}}', diff --git a/src/package.ts b/src/package.ts index 42f59ac..4030ce1 100644 --- a/src/package.ts +++ b/src/package.ts @@ -2,7 +2,7 @@ import * as fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { doDelete, getAllPackages, post, uploadFile } from './api'; -import { getPlatform, getSelectedApp } from './app'; +import { getSelectedApp, resolveAppId } from './app'; import { createSlimNativePackage } from './native-package'; import type { Package, Platform } from './types'; import { @@ -13,6 +13,7 @@ import { loadTtyTable, question, } from './utils'; +import { updateJson } from './utils/constants'; import { getDepVersions } from './utils/dep-versions'; import { getCommitInfo } from './utils/git'; import { bundleEntryMatcher, cachePut } from './utils/hermes-base'; @@ -23,6 +24,7 @@ import { bundleLocationFields, locateZipEntry } from './utils/zip-range'; type PackageCommandOptions = Record & { appId?: string; appKey?: string; + config?: string; platform?: Platform; version?: string; packageId?: string; @@ -130,18 +132,19 @@ async function uploadNativePackage( appId: String(options.appId), appKey: typeof options.appKey === 'string' ? options.appKey : undefined, } - : await getSelectedApp( - config.platform, - options.config as string | undefined, - ); + : await getSelectedApp(config.platform, options.config); const { appId, appKey } = selectedApp; + // where the expected app came from, for the mismatch messages + const source = options.appId ? '--appId' : options.config || updateJson; if (appIdInPkg && String(appIdInPkg) !== appId) { - throw new Error(t(config.appIdMismatchKey, { appIdInPkg, appId })); + throw new Error(t(config.appIdMismatchKey, { appIdInPkg, appId, source })); } if (appKeyInPkg && appKey && appKeyInPkg !== appKey) { - throw new Error(t(config.appKeyMismatchKey, { appKeyInPkg, appKey })); + throw new Error( + t(config.appKeyMismatchKey, { appKeyInPkg, appKey, source }), + ); } const customVersion = @@ -407,28 +410,14 @@ export const packageCommands = { }: { options: { platform: Platform; appId?: string; config?: string }; }) => { - let appId = options.appId; - if (!appId) { - const platform = await getPlatform(options.platform); - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } - await listPackage(String(appId)); + await listPackage(await resolveAppId(options)); }, deletePackage: async ({ options }: { options: PackageCommandOptions }) => { - let { appId } = options; + const appId = await resolveAppId(options); let packageIds = getStringListOption(options, 'packageIds') ?? getStringListOption(options, 'packageId'); - if (!appId) { - const platform = await getPlatform(options.platform); - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } - if (!packageIds) { const packageVersions = getStringListOption(options, 'packageVersion'); if (!packageVersions) { diff --git a/src/provider.ts b/src/provider.ts index 6390c27..2de5a93 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -95,6 +95,8 @@ export class CLIProviderImpl implements CLIProvider { verifyHermesBase: options.verifyHermesBase ?? true, sentryRelease: options.sentryRelease, sentryDist: options.sentryDist, + appId: options.appId, + config: options.config, }); const { bundleCommands } = await import('./bundle'); @@ -181,9 +183,10 @@ export class CLIProviderImpl implements CLIProvider { async getSelectedApp( platform?: Platform, + config?: string, ): Promise<{ appId: string; platform: Platform }> { const resolvedPlatform = await this.getPlatform(platform); - return getSelectedApp(resolvedPlatform); + return getSelectedApp(resolvedPlatform, config); } async listApps(platform?: Platform): Promise { diff --git a/src/types.ts b/src/types.ts index 11ec044..9177215 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,10 @@ export interface BundleOptions { resetCache?: boolean; sentryRelease?: string; sentryDist?: string; + /** publish to this app instead of the one selected in the config file */ + appId?: string; + /** selected-app config file (default: update.json) */ + config?: string; } export interface PublishOptions { @@ -113,6 +117,7 @@ export interface CLIProvider { listApps: (platform?: Platform) => Promise; getSelectedApp: ( platform?: Platform, + config?: string, ) => Promise<{ appId: string; platform: Platform }>; listVersions: (appId: string) => Promise; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 86a1ef8..c142b88 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -12,7 +12,9 @@ export const isPPKBundleFileName = (fileName: string) => ppkBundleFileNames.includes(fileName); export const credentialFile = IS_CRESC ? '.cresc.token' : '.update'; -export const updateJson = IS_CRESC ? 'cresc.config.json' : 'update.json'; +// Both brands select apps in update.json: the cresc docs and the client SDK +// (`import _updateConfig from './update.json'`) read that name too. +export const updateJson = 'update.json'; export const tempDir = IS_CRESC ? '.cresc.temp' : '.pushy'; export const pricingPageUrl = IS_CRESC ? 'https://cresc.dev/pricing' diff --git a/src/versions.ts b/src/versions.ts index adf02d5..7b44f3c 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { compare, satisfies } from 'compare-versions'; import { doDelete, get, getAllPackages, post, put, uploadFile } from './api'; -import { getPlatform, getSelectedApp } from './app'; +import { getPlatform, resolveAppId } from './app'; import { choosePackage } from './package'; import type { Package, Platform, Version } from './types'; import { isNonInteractive, loadTtyTable, question } from './utils'; @@ -524,12 +524,7 @@ export const versionCommands = { } const platform = await getPlatform(options.platform); - let appId = options.appId; - if (!appId) { - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } + const appId = await resolveAppId({ ...options, platform }); const nonInteractive = getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); @@ -585,7 +580,7 @@ export const versionCommands = { options: { versionId: id, platform, - appId: String(appId), + appId, packageId, packageVersion, packageVersionRange, @@ -604,7 +599,7 @@ export const versionCommands = { options: { versionId: id, platform, - appId: String(appId), + appId, versionDeps: depVersions, warnDepsChanges: true, }, @@ -614,38 +609,23 @@ export const versionCommands = { return versionName; }, versions: async ({ options }: { options: VersionCommandOptions }) => { - let appId = options.appId; - if (!appId) { - const platform = await getPlatform(options.platform); - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } + const appId = await resolveAppId(options); const interactive = !( getBooleanOption(options, 'no-interactive', false) || isNonInteractive() ); - await listVersions(String(appId), interactive); + await listVersions(appId, interactive); }, update: async ({ options }: { options: VersionCommandOptions }) => { const nonInteractive = getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); - let appId = options.appId; - let platform = options.platform; - if (!appId) { - platform = await getPlatform(platform); - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } else if (platform) { - platform = await getPlatform(platform); - } + const appId = await resolveAppId(options); let versionId: string | null | undefined = options.versionId; if (!versionId) { if (nonInteractive) { throw new Error(t('versionIdRequired')); } - versionId = String((await chooseVersion(String(appId))).id); + versionId = String((await chooseVersion(appId)).id); } if (versionId === 'null') { versionId = null; @@ -665,7 +645,7 @@ export const versionCommands = { } } - const allPkgs = await getAllPackages(String(appId)); + const allPkgs = await getAllPackages(appId); if (!allPkgs) { throw new Error(t('noPackagesFound', { appId })); @@ -741,7 +721,7 @@ export const versionCommands = { throw new Error(t('packageIdRequired')); } // the package list was already fetched above: no second round trip - pkgId = String((await choosePackage(String(appId), allPkgs)).id); + pkgId = String((await choosePackage(appId, allPkgs)).id); } if (!pkgId) { @@ -757,7 +737,7 @@ export const versionCommands = { if (options.warnDepsChanges && versionId) { await printDepsChangesForPublish({ - appId: String(appId), + appId, versionId: String(versionId), pkgs: pkgsToBind, providedVersionDeps: options.versionDeps, @@ -765,7 +745,7 @@ export const versionCommands = { } await bindVersionToPackages({ - appId: String(appId), + appId, // keep null as-is: `--versionId null` means unbinding the version versionId: versionId ?? null, pkgs: pkgsToBind, @@ -780,23 +760,14 @@ export const versionCommands = { }) => { const nonInteractive = getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); - let appId = options.appId; - let platform = options.platform; - if (!appId) { - platform = await getPlatform(platform); - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } else if (platform) { - await getPlatform(platform); - } + const appId = await resolveAppId(options); let versionId = options.versionId; if (!versionId) { if (nonInteractive) { throw new Error(t('versionIdRequired')); } - versionId = String((await chooseVersion(String(appId))).id); + versionId = String((await chooseVersion(appId)).id); } const updateParams: Record = {}; @@ -804,19 +775,13 @@ export const versionCommands = { if (options.description) updateParams.description = options.description; if (options.metaInfo) updateParams.metaInfo = options.metaInfo; - await put(`/app/${String(appId)}/version/${versionId}`, updateParams); + await put(`/app/${appId}/version/${versionId}`, updateParams); console.log(t('operationSuccess')); }, deleteVersion: async ({ options }: { options: VersionCommandOptions }) => { const nonInteractive = getBooleanOption(options, 'no-interactive', false) || isNonInteractive(); - let appId = options.appId; - if (!appId) { - const platform = await getPlatform(options.platform); - appId = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } + const appId = await resolveAppId(options); const parsedVersionIds = getStringListOption(options, 'versionIds') ?? @@ -826,16 +791,16 @@ export const versionCommands = { if (nonInteractive) { throw new Error(t('versionIdRequired')); } - versionIds = [String((await chooseVersion(String(appId))).id)]; + versionIds = [String((await chooseVersion(appId)).id)]; } try { if (versionIds.length === 1) { const [versionId] = versionIds; - await doDelete(`/app/${String(appId)}/version/${versionId}`); + await doDelete(`/app/${appId}/version/${versionId}`); console.log(t('deleteVersionSuccess', { versionId })); } else { - await doDelete(`/app/${String(appId)}/version`, { + await doDelete(`/app/${appId}/version`, { versionIds: toNumericIds(versionIds), }); console.log( diff --git a/tests/constants.test.ts b/tests/constants.test.ts index 78272d3..6740e8b 100644 --- a/tests/constants.test.ts +++ b/tests/constants.test.ts @@ -18,7 +18,7 @@ describe('constants', () => { expect(mod.scriptName).toBe('cresc'); expect(mod.IS_CRESC).toBe(true); expect(mod.credentialFile).toBe('.cresc.token'); - expect(mod.updateJson).toBe('cresc.config.json'); + expect(mod.updateJson).toBe('update.json'); expect(mod.tempDir).toBe('.cresc.temp'); expect(mod.pricingPageUrl).toBe('https://cresc.dev/pricing'); expect(mod.defaultEndpoints).toEqual([ diff --git a/tests/target-context.test.ts b/tests/target-context.test.ts index c776233..a8e2af4 100644 --- a/tests/target-context.test.ts +++ b/tests/target-context.test.ts @@ -1,131 +1,282 @@ -import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; import fs from 'fs'; import * as api from '../src/api'; import { - createAppTargetResolver, + AppNotSelectedError, getAppCommands, getSelectedApp, - resolveAppTarget, + resolveAppId, } from '../src/app'; -import { - createPublishBundleRequest, - normalizeBundleOptions, -} from '../src/bundle'; +import { bundleCommands } from '../src/bundle'; +import * as bundlePack from '../src/bundle-pack'; +import * as bundleRunner from '../src/bundle-runner'; +import * as utils from '../src/utils'; +import * as addGitIgnoreModule from '../src/utils/add-gitignore'; +import * as checkLockfileModule from '../src/utils/check-lockfile'; +import * as hermesBaseModule from '../src/utils/hermes-base'; +import { versionCommands } from '../src/versions'; + +const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); +const selection = (platform: string, appId: number) => + JSON.stringify({ [platform]: { appId, appKey: `key-${appId}` } }); + +describe('resolveAppId', () => { + let readFileSpy: Mock; + + afterEach(() => { + readFileSpy?.mockRestore(); + }); + + test('reads the selected app from update.json by default', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('ios', 42), + ); + + await expect(resolveAppId({ platform: 'ios' })).resolves.toBe('42'); + expect(readFileSpy).toHaveBeenCalledWith('update.json', 'utf8'); + }); + + test('reads the selected app from an explicit config path', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('android', 7), + ); + + await expect( + resolveAppId({ platform: 'android', config: 'configs/prod.json' }), + ).resolves.toBe('7'); + expect(readFileSpy).toHaveBeenCalledWith('configs/prod.json', 'utf8'); + }); + + test('an explicit appId wins and never reads the config', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue( + new Error('config should not be read'), + ); + + await expect( + resolveAppId({ appId: '777', config: 'configs/prod.json' }), + ).resolves.toBe('777'); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + + test('still validates the platform next to an explicit appId', async () => { + await expect( + resolveAppId({ appId: '777', platform: 'windows' as any }), + ).rejects.toThrow(); + }); + + test('a missing config is reported as app-not-selected', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent()); + + await expect(resolveAppId({ platform: 'ios' })).rejects.toBeInstanceOf( + AppNotSelectedError, + ); + }); + + test('a config without the platform is app-not-selected', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('android', 7), + ); + + await expect(resolveAppId({ platform: 'ios' })).rejects.toBeInstanceOf( + AppNotSelectedError, + ); + }); + + test('a malformed config names the file that failed to parse', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue('{ oops'); + + const error = await resolveAppId({ + platform: 'ios', + config: 'configs/prod.json', + }).catch((e: Error) => e); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(AppNotSelectedError); + expect((error as Error).message).toContain('configs/prod.json'); + }); +}); describe('bundle target context', () => { - test('preserves appId and config in the publish request', () => { - const normalized = normalizeBundleOptions( - { - appId: '42', - config: 'configs/release.update.json', - }, - 'ios', + let readFileSpy: Mock; + let runBundleSpy: Mock; + let publishSpy: Mock; + let addGitIgnoreSpy: Mock; + const restore: Array<{ mockRestore: () => void }> = []; + + beforeEach(() => { + runBundleSpy = spyOn( + bundleRunner, + 'runReactNativeBundleCommand', + ).mockResolvedValue(undefined as any); + publishSpy = spyOn(versionCommands, 'publish').mockResolvedValue('v1'); + addGitIgnoreSpy = spyOn( + addGitIgnoreModule, + 'addGitIgnore', + ).mockImplementation(() => {}); + restore.push( + runBundleSpy, + publishSpy, + addGitIgnoreSpy, + spyOn(checkLockfileModule, 'checkLockFiles').mockImplementation(() => {}), + spyOn(utils, 'checkPlugins').mockResolvedValue({ + sentry: false, + sourcemap: false, + } as any), + spyOn(hermesBaseModule, 'cleanStaleTmp').mockResolvedValue(undefined), + spyOn(bundlePack, 'packBundle').mockResolvedValue(undefined as any), + spyOn(console, 'log').mockImplementation(() => {}), ); + }); - expect(normalized.appId).toBe('42'); - expect(normalized.config).toBe('configs/release.update.json'); - expect( - createPublishBundleRequest('dist/ios.ppk', 'ios', { - appId: normalized.appId, - config: normalized.config, - name: 'v1', - }), - ).toEqual({ - args: ['dist/ios.ppk'], + afterEach(() => { + readFileSpy?.mockRestore(); + for (const spy of restore.splice(0)) { + spy.mockRestore(); + } + }); + + test('resolves the app once and reuses it for Hermes base and publish', async () => { + readFileSpy = spyOn(fs.promises, 'readFile') + .mockResolvedValueOnce(selection('ios', 42)) + .mockResolvedValueOnce(selection('ios', 99)); + + await bundleCommands.bundle({ options: { platform: 'ios', - appId: '42', - config: 'configs/release.update.json', name: 'v1', + config: 'configs/release.update.json', }, }); + + expect(readFileSpy).toHaveBeenCalledTimes(1); + expect(readFileSpy).toHaveBeenCalledWith( + 'configs/release.update.json', + 'utf8', + ); + expect(runBundleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + platform: 'ios', + hermesBase: expect.objectContaining({ option: 'auto', appId: '42' }), + }), + ); + expect(publishSpy).toHaveBeenCalledTimes(1); + const [request] = publishSpy.mock.calls[0]; + expect(request.options).toMatchObject({ + platform: 'ios', + appId: '42', + name: 'v1', + }); + expect(request.options).not.toHaveProperty('config'); }); - test('reuses one selected app for Hermes lookup and publishing', async () => { - const readFileSpy = spyOn(fs.promises, 'readFile') - .mockResolvedValueOnce( - JSON.stringify({ ios: { appId: 42, appKey: 'key-42' } }), - ) - .mockResolvedValueOnce( - JSON.stringify({ ios: { appId: 99, appKey: 'key-99' } }), - ); + test('uses update.json when no config is given', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('android', 7), + ); - try { - const resolveTarget = createAppTargetResolver('ios', { - config: 'configs/release.update.json', - }); - const hermesTarget = await resolveTarget(); - const publishTarget = await resolveTarget(); - - expect(hermesTarget).toEqual(publishTarget); - expect(publishTarget.appId).toBe('42'); - expect(readFileSpy).toHaveBeenCalledTimes(1); - expect( - createPublishBundleRequest('dist/ios.ppk', 'ios', { - appId: publishTarget.appId, - config: publishTarget.configPath, - name: 'v1', - }), - ).toEqual({ - args: ['dist/ios.ppk'], - options: { - platform: 'ios', - appId: '42', - config: 'configs/release.update.json', - name: 'v1', - }, - }); - } finally { - readFileSpy.mockRestore(); - } + await bundleCommands.bundle({ + options: { platform: 'android', name: 'v1' }, + }); + + expect(readFileSpy).toHaveBeenCalledWith('update.json', 'utf8'); + expect(publishSpy.mock.calls[0][0].options).toMatchObject({ + appId: '7', + }); }); - test('retries selection after a failed best-effort lookup', async () => { - const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - const readFileSpy = spyOn(fs.promises, 'readFile') - .mockRejectedValueOnce(enoentError) - .mockResolvedValueOnce( - JSON.stringify({ ios: { appId: 42, appKey: 'key-42' } }), - ); + test('an explicit appId skips the config and reaches publish', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent()); - try { - const resolveTarget = createAppTargetResolver('ios', { - config: 'configs/release.update.json', - }); + await bundleCommands.bundle({ + options: { platform: 'ios', appId: '777', name: 'v1' }, + }); - await expect(resolveTarget()).rejects.toThrow(); - await expect(resolveTarget()).resolves.toEqual({ - appId: '42', - appKey: 'key-42', - platform: 'ios', - configPath: 'configs/release.update.json', - }); - expect(readFileSpy).toHaveBeenCalledTimes(2); - } finally { - readFileSpy.mockRestore(); - } + expect(readFileSpy).not.toHaveBeenCalled(); + expect(runBundleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + hermesBase: expect.objectContaining({ appId: '777' }), + }), + ); + expect(publishSpy.mock.calls[0][0].options).toMatchObject({ + appId: '777', + }); + }); + + test('a named bundle without a selected app fails before any work', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent()); + + await expect( + bundleCommands.bundle({ options: { platform: 'ios', name: 'v1' } }), + ).rejects.toBeInstanceOf(AppNotSelectedError); + + expect(addGitIgnoreSpy).not.toHaveBeenCalled(); + expect(runBundleSpy).not.toHaveBeenCalled(); + expect(publishSpy).not.toHaveBeenCalled(); + }); + + test('a bundle-only run without a selected app still bundles', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent()); + + await bundleCommands.bundle({ + options: { platform: 'ios', 'no-interactive': true }, + }); + + expect(runBundleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + hermesBase: expect.objectContaining({ option: 'auto' }), + }), + ); + expect(runBundleSpy.mock.calls[0][0].hermesBase?.appId).toBeUndefined(); + expect(publishSpy).not.toHaveBeenCalled(); + }); + + test('a bundle-only run reports a malformed config before bundling', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue('{ oops'); + + await expect( + bundleCommands.bundle({ + options: { platform: 'ios', 'no-interactive': true }, + }), + ).rejects.toThrow('update.json'); + + expect(runBundleSpy).not.toHaveBeenCalled(); + }); + + test('a dev bundle never needs the app', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue( + new Error('config should not be read'), + ); + + await bundleCommands.bundle({ + options: { platform: 'ios', dev: true, 'no-interactive': true }, + }); + + expect(readFileSpy).not.toHaveBeenCalled(); + expect(runBundleSpy.mock.calls[0][0].hermesBase).toBeUndefined(); }); }); describe('app config target', () => { - let postSpy: ReturnType; - let getSpy: ReturnType; - let readFileSpy: ReturnType; - let writeFileSpy: ReturnType; - let consoleLogSpy: ReturnType; + const restore: Array<{ mockRestore: () => void }> = []; afterEach(() => { - postSpy?.mockRestore(); - getSpy?.mockRestore(); - readFileSpy?.mockRestore(); - writeFileSpy?.mockRestore(); - consoleLogSpy?.mockRestore(); + for (const spy of restore.splice(0)) { + spy.mockRestore(); + } }); test('getSelectedApp reads the explicit config path', async () => { - readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( - JSON.stringify({ ios: { appId: 42, appKey: 'key-42' } }), + const readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('ios', 42), ); + restore.push(readFileSpy); await expect( getSelectedApp('ios', 'configs/release.update.json'), @@ -140,31 +291,18 @@ describe('app config target', () => { ); }); - test('explicit appId does not read the selected-app config', async () => { - readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue( - new Error('config should not be read'), - ); - - await expect( - resolveAppTarget('android', { - appId: '777', - config: 'configs/release.update.json', - }), - ).resolves.toEqual({ - appId: '777', - platform: 'android', - configPath: 'configs/release.update.json', - }); - expect(readFileSpy).not.toHaveBeenCalled(); - }); - test('createApp selects the new app in the explicit config file', async () => { - postSpy = spyOn(api, 'post').mockResolvedValue({ id: 10 }); - getSpy = spyOn(api, 'get').mockResolvedValue({ appKey: 'key-ios-10' }); - const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoentError); - writeFileSpy = spyOn(fs.promises, 'writeFile').mockResolvedValue(); - consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + const readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue( + enoent(), + ); + const writeFileSpy = spyOn(fs.promises, 'writeFile').mockResolvedValue(); + restore.push( + spyOn(api, 'post').mockResolvedValue({ id: 10 }), + spyOn(api, 'get').mockResolvedValue({ appKey: 'key-ios-10' }), + readFileSpy, + writeFileSpy, + spyOn(console, 'log').mockImplementation(() => {}), + ); await getAppCommands().createApp({ options: { @@ -181,16 +319,7 @@ describe('app config target', () => { ); expect(writeFileSpy).toHaveBeenCalledWith( 'configs/release.update.json', - JSON.stringify( - { - ios: { - appId: 10, - appKey: 'key-ios-10', - }, - }, - null, - 4, - ), + JSON.stringify({ ios: { appId: 10, appKey: 'key-ios-10' } }, null, 4), 'utf8', ); });