diff --git a/src/app.ts b/src/app.ts index 77ead4d..7fd51b3 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 { @@ -10,12 +11,29 @@ interface AppSummary { platform: Platform; } +export interface AppTargetOptions { + appId?: string; + config?: string; + platform?: Platform | ''; +} + +/** 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. */ 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 })); @@ -23,27 +41,34 @@ 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, ): Promise<{ appId: string; appKey: string; platform: Platform }> { assertPlatform(platform); - let updateInfo: Partial> = - {}; + const resolvedConfigPath = configPath || updateJson; + let raw: string; try { - updateInfo = JSON.parse( - await fs.promises.readFile(configPath || 'update.json', '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), @@ -52,6 +77,25 @@ export async function getSelectedApp( }; } +/** + * 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 { + if (options.platform) { + assertPlatform(options.platform); + } + if (options.appId) { + return String(options.appId); + } + const platform = await getPlatform(options.platform || undefined); + return (await getSelectedApp(platform, options.config)).appId; +} + +/** 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[]; @@ -77,6 +121,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); @@ -89,28 +134,27 @@ export async function chooseApp(platform: Platform) { } } +/** Persist an app selection in the requested brand-aware config file. */ async function selectApp({ args, 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'), - ); + 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; } } @@ -120,18 +164,25 @@ async function selectApp({ appKey, }; await fs.promises.writeFile( - configPath || 'update.json', + configPath, JSON.stringify(updateInfo, null, 4), 'utf8', ); } +/** 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, }: { - 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,9 +191,10 @@ export function getAppCommands() { console.log(t('createAppSuccess', { id })); await selectApp({ args: [String(id)], - options: { platform }, + options: { platform, config: options.config }, }); }, + /** Delete the specified app, or prompt for one when no ID is supplied. */ deleteApp: async ({ args, options, @@ -159,6 +211,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); diff --git a/src/bundle.ts b/src/bundle.ts index fc764ae..b804b4c 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { getPlatform, getSelectedApp } from './app'; +import { AppNotSelectedError, getPlatform, resolveAppId } from './app'; import { packBundle } from './bundle-pack'; import { copyDebugidForSentry, @@ -41,6 +41,8 @@ type NormalizedBundleOptions = { verifyHermesBase: boolean; resetCache: boolean; cacheMaxMb?: number; + appId?: string; + config?: string; name?: string; description?: string; metaInfo?: string; @@ -55,12 +57,14 @@ 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; } type PublishBundlePayload = { + appId: string; name?: string; description?: string; metaInfo?: string; @@ -74,6 +78,7 @@ type PublishBundlePayload = { hermesBase?: HermesBaseMeta; }; +/** Read either spelling of an aliased optional CLI string option. */ function getAliasedOptionalStringOption( options: Record, key: string, @@ -85,6 +90,7 @@ function getAliasedOptionalStringOption( ); } +/** Normalize translated CLI values into the bundle command's typed options. */ export function normalizeBundleOptions( translatedOptions: Record, platform: string, @@ -121,6 +127,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'), @@ -156,6 +164,7 @@ export function normalizeBundleOptions( }; } +/** Upload generated Sentry artifacts when the detected plugin requires them. */ async function uploadSentryArtifactsIfNeeded( shouldUpload: boolean, bundleName: string, @@ -178,6 +187,7 @@ async function uploadSentryArtifactsIfNeeded( ); } +/** Publish a packed bundle through the version command implementation. */ async function publishBundleVersion( outputPath: string, platform: Platform, @@ -193,6 +203,7 @@ async function publishBundleVersion( } export const bundleCommands = { + /** Build a bundle and optionally publish it to one operation-scoped app. */ bundle: async ({ options, }: { @@ -210,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`, @@ -229,23 +278,6 @@ export const bundleCommands = { console.log(t('bundlingWithRN', { version: depVersions['react-native'] })); - 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; - if (!appIdForBase && normalized.hermesBase === 'auto') { - try { - appIdForBase = ( - await getSelectedApp(platform, options.config as string | undefined) - ).appId; - } catch { - appIdForBase = undefined; - } - } - const hermesResult = await runReactNativeBundleCommand({ bundleName: normalized.bundleName, dev: normalized.dev, @@ -255,15 +287,7 @@ export const bundleCommands = { sourcemapOutput: normalized.sourcemap || bundleParams.sourcemap ? sourcemapOutput : '', forceHermes: normalized.hermes, - hermesBase: - normalized.dev === 'true' - ? undefined - : { - option: normalized.hermesBase, - appId: appIdForBase, - verify: normalized.verifyHermesBase, - cacheMaxMb: normalized.cacheMaxMb, - }, + hermesBase: hermesBase ? { ...hermesBase, appId } : undefined, resetCache: normalized.resetCache, cli: { taro: normalized.taro, @@ -284,6 +308,7 @@ export const bundleCommands = { if (normalized.name) { await publishBundleVersion(realOutput, platform, { + appId: await getAppId(), name: normalized.name, description: normalized.description, metaInfo: normalized.metaInfo, @@ -314,6 +339,7 @@ export const bundleCommands = { const v = await question(t('uploadBundlePrompt')); if (v.toLowerCase() === 'y') { await publishBundleVersion(realOutput, platform, { + 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 e20ee41..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,6 +580,7 @@ export const versionCommands = { options: { versionId: id, platform, + appId, packageId, packageVersion, packageVersionRange, @@ -603,6 +599,7 @@ export const versionCommands = { options: { versionId: id, platform, + appId, versionDeps: depVersions, warnDepsChanges: true, }, @@ -612,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; @@ -663,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 })); @@ -739,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) { @@ -755,7 +737,7 @@ export const versionCommands = { if (options.warnDepsChanges && versionId) { await printDepsChangesForPublish({ - appId: String(appId), + appId, versionId: String(versionId), pkgs: pkgsToBind, providedVersionDeps: options.versionDeps, @@ -763,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, @@ -778,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 = {}; @@ -802,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') ?? @@ -824,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 new file mode 100644 index 0000000..a8e2af4 --- /dev/null +++ b/tests/target-context.test.ts @@ -0,0 +1,326 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import fs from 'fs'; +import * as api from '../src/api'; +import { + AppNotSelectedError, + getAppCommands, + getSelectedApp, + resolveAppId, +} from '../src/app'; +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', () => { + 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(() => {}), + ); + }); + + 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', + 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('uses update.json when no config is given', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('android', 7), + ); + + 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('an explicit appId skips the config and reaches publish', async () => { + readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent()); + + await bundleCommands.bundle({ + options: { platform: 'ios', appId: '777', name: 'v1' }, + }); + + 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', () => { + const restore: Array<{ mockRestore: () => void }> = []; + + afterEach(() => { + for (const spy of restore.splice(0)) { + spy.mockRestore(); + } + }); + + test('getSelectedApp reads the explicit config path', async () => { + const readFileSpy = spyOn(fs.promises, 'readFile').mockResolvedValue( + selection('ios', 42), + ); + restore.push(readFileSpy); + + 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 () => { + 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: { + 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', + ); + }); +}); diff --git a/tests/versions.test.ts b/tests/versions.test.ts index 43306e9..1d5d2e6 100644 --- a/tests/versions.test.ts +++ b/tests/versions.test.ts @@ -161,12 +161,41 @@ 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'],