Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 74 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -10,30 +11,46 @@ interface AppSummary {
platform: Platform;
}

export interface AppTargetOptions {
appId?: string;
config?: string;
}

export interface ResolvedAppTarget {
appId: string;
appKey?: string;
platform: Platform;
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 }));
}
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);

const resolvedConfigPath = configPath || updateJson;
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>> =
{};
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') {
Expand All @@ -52,6 +69,45 @@ export async function getSelectedApp(
};
}

/** Resolve an explicit or selected app into a stable operation target. */
export async function resolveAppTarget(
platform: Platform,
options: AppTargetOptions = {},
): Promise<ResolvedAppTarget> {
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<ResolvedAppTarget> {
let pending: Promise<ResolvedAppTarget> | undefined;

return () => {
if (!pending) {
pending = resolveAppTarget(platform, options).catch((error) => {
pending = undefined;
throw error;
});
}
return pending;
};
}

/** 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[];
Expand All @@ -77,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);

Expand All @@ -89,25 +146,24 @@ 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<Record<Platform, { appId: number; appKey: string }>> =
{};
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'));
Expand All @@ -120,18 +176,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;
Expand All @@ -140,9 +203,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,
Expand All @@ -159,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);
Expand Down
83 changes: 62 additions & 21 deletions src/bundle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -41,6 +45,8 @@ type NormalizedBundleOptions = {
verifyHermesBase: boolean;
resetCache: boolean;
cacheMaxMb?: number;
appId?: string;
config?: string;
name?: string;
description?: string;
metaInfo?: string;
Expand All @@ -55,12 +61,15 @@ 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 = {
export type PublishBundlePayload = {
appId?: string;
config?: string;
name?: string;
description?: string;
metaInfo?: string;
Expand All @@ -74,6 +83,7 @@ type PublishBundlePayload = {
hermesBase?: HermesBaseMeta;
};

/** Read either spelling of an aliased optional CLI string option. */
function getAliasedOptionalStringOption(
options: Record<string, unknown>,
key: string,
Expand All @@ -85,6 +95,7 @@ function getAliasedOptionalStringOption(
);
}

/** Normalize translated CLI values into the bundle command's typed options. */
export function normalizeBundleOptions(
translatedOptions: Record<string, unknown>,
platform: string,
Expand Down Expand Up @@ -121,6 +132,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'),
Expand Down Expand Up @@ -156,6 +169,7 @@ export function normalizeBundleOptions(
};
}

/** Upload generated Sentry artifacts when the detected plugin requires them. */
async function uploadSentryArtifactsIfNeeded(
shouldUpload: boolean,
bundleName: string,
Expand All @@ -178,21 +192,37 @@ async function uploadSentryArtifactsIfNeeded(
);
}

async function publishBundleVersion(
/** Build the version publish request while preserving its resolved app target. */
export function createPublishBundleRequest(
outputPath: string,
platform: Platform,
payload: PublishBundlePayload,
): Promise<string> {
return versionCommands.publish({
): {
args: string[];
options: PublishBundlePayload & { platform: Platform };
} {
return {
args: [outputPath],
options: {
platform,
...payload,
},
});
};
}

/** Publish a packed bundle through the version command implementation. */
async function publishBundleVersion(
outputPath: string,
platform: Platform,
payload: PublishBundlePayload,
): Promise<string> {
return versionCommands.publish(
createPublishBundleRequest(outputPath, platform, payload),
);
}

export const bundleCommands = {
/** Build a bundle and optionally publish it to one operation-scoped app. */
bundle: async ({
options,
}: {
Expand Down Expand Up @@ -227,25 +257,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 resolves it again and reports)
let appIdForBase: string | undefined =
typeof options.appId === 'string' && options.appId
? options.appId
: undefined;
if (!appIdForBase && normalized.hermesBase === 'auto') {
if (shouldResolveTargetBeforeBundle) {
try {
appIdForBase = (
await getSelectedApp(platform, options.config as string | undefined)
).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,
Expand All @@ -260,7 +295,7 @@ export const bundleCommands = {
? undefined
: {
option: normalized.hermesBase,
appId: appIdForBase,
appId: resolvedTarget?.appId,
verify: normalized.verifyHermesBase,
cacheMaxMb: normalized.cacheMaxMb,
},
Expand All @@ -283,7 +318,10 @@ export const bundleCommands = {
: undefined;

if (normalized.name) {
const target = resolvedTarget ?? (await resolveTarget());
await publishBundleVersion(realOutput, platform, {
appId: target.appId,
config: target.configPath,
name: normalized.name,
description: normalized.description,
metaInfo: normalized.metaInfo,
Expand Down Expand Up @@ -313,7 +351,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: target.appId,
config: target.configPath,
hermesBase: baseMeta,
});
await uploadSentryArtifactsIfNeeded(
Expand Down
2 changes: 2 additions & 0 deletions src/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export const versionCommands = {
options: {
versionId: id,
platform,
appId: String(appId),
packageId,
packageVersion,
packageVersionRange,
Expand All @@ -603,6 +604,7 @@ export const versionCommands = {
options: {
versionId: id,
platform,
appId: String(appId),
versionDeps: depVersions,
warnDepsChanges: true,
},
Expand Down
Loading