Skip to content
Merged
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
105 changes: 89 additions & 16 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,40 +11,64 @@ 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 }));
}
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<Record<Platform, { appId: number; appKey: string }>> =
{};
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<Record<Platform, { appId: number; appKey: string }>>;
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),
Expand All @@ -52,6 +77,45 @@ export async function getSelectedApp(
};
}

/**
* Fail fast when an explicit `--appId` names an app of another platform.
* The server accepts a bundle for any app the account owns and never sees
* the platform it was built for, so this is the only place the mistake can
* be caught before it reaches devices. A missing or foreign app fails here
* too (403/404) instead of after the expensive work.
*/
async function assertAppPlatform(appId: string, platform: Platform) {
const app = (await get(`/app/${appId}`)) as { platform?: Platform };
if (app.platform && app.platform !== platform) {
throw new Error(
t('appPlatformMismatch', { appId, appPlatform: app.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<string> {
if (options.platform) {
assertPlatform(options.platform);
}
if (options.appId) {
const appId = String(options.appId);
if (options.platform) {
await assertAppPlatform(appId, options.platform);
}
return 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[];
Expand All @@ -77,6 +141,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,28 +154,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<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'));
console.error(t('failedToParseUpdateJson', { configPath }));
throw e;
}
}
Expand All @@ -120,18 +184,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 +211,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 +231,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
Loading