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
22 changes: 18 additions & 4 deletions script/command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as crypto from "crypto";
import debugCommand from "./commands/debug";
import * as fs from "fs";
import * as hashUtils from "./hash-utils";
import { appPlatformLabel, assertReleasePlatform, checkReleaseProjectKind } from "./ota-runtime";
import * as chalk from "chalk";
const g2js = require("gradle-to-js/lib/parser");
import * as moment from "moment";
Expand Down Expand Up @@ -204,7 +205,7 @@ function accessKeyRemove(command: cli.IAccessKeyRemoveCommand): Promise<void> {
}

function appAdd(command: cli.IAppAddCommand): Promise<void> {
return sdk.addApp(command.appName).then((app: App): Promise<void> => {
return sdk.addApp(command.appName, command.platform).then((app: App): Promise<void> => {
log('Successfully added the "' + command.appName + '" app, along with the following default deployments:');
const deploymentListCommand: cli.IDeploymentListCommand = {
type: cli.CommandType.deploymentList,
Expand Down Expand Up @@ -249,6 +250,14 @@ export function signatureManifestBase(filePath: string): string {
return path.dirname(filePath);
}

// undefined = the app couldn't be fetched (the release itself will report why); null = no platform set.
function getAppPlatform(appName: string): Promise<string | null | undefined> {
return sdk.getApp(appName).then(
(app: App): string | null => (app && app.platform) || null,
(): undefined => undefined
);
}

export function resolvePrivateKey(value: string): string {
return value.trimStart().startsWith("-----BEGIN")
? value // inline PEM content
Expand Down Expand Up @@ -754,10 +763,10 @@ function printAppList(format: string, apps: App[]): void {
if (format === "json") {
printJson(apps);
} else if (format === "table") {
const headers = ["Name", "Deployments"];
const headers = ["Name", "Platform", "Deployments"];
printTable(headers, (dataSource: any[]): void => {
apps.forEach((app: App, index: number): void => {
const row = [app.name, wordwrap(50)(app.deployments.join(", "))];
const row = [app.name, appPlatformLabel(app.platform), wordwrap(50)(app.deployments.join(", "))];
dataSource.push(row);
});
});
Expand Down Expand Up @@ -1337,7 +1346,12 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise<void> =
return (
sdk
.getDeployment(command.appName, command.deploymentName)
.then((): any => {
.then(() => getAppPlatform(command.appName))
.then((appPlatform: string | null | undefined): any => {
assertReleasePlatform({ appName: command.appName, deploymentName: command.deploymentName, appPlatform, releasePlatform: platform });
const projectWarning = checkReleaseProjectKind({ appName: command.appName, appPlatform, projectRoot: process.cwd() });
if (projectWarning) console.warn(chalk.yellow("[Warning] " + projectWarning));

releaseCommand.package = outputFolder;

switch (platform) {
Expand Down
19 changes: 18 additions & 1 deletion script/command-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,21 @@ yargs
yargs
.usage(USAGE_PREFIX + " app add <appName>")
.demand(/*count*/ 1, /*max*/ 1) // Require exactly one non-option arguments
.example("app add MyApp --platform ios", 'Adds a React Native iOS app named "MyApp"')
.option("platform", {
alias: "p",
default: null,
demand: false,
description: 'Target platform: "ios", "android", "expo-cng-ios" or "expo-cng-android". Cannot be changed after creation.',
type: "string",
})
.check((argv: any): any => {
const validPlatforms = ["ios", "android", "expo-cng-ios", "expo-cng-android"];
if (argv.platform && !validPlatforms.includes(argv.platform)) {
throw new Error("--platform must be one of: " + validPlatforms.join(", "));
}
return true;
})
.example("app add MyApp", 'Adds app "MyApp"');

addCommonConfiguration(yargs);
Expand Down Expand Up @@ -1050,7 +1065,9 @@ export function createCommand(): cli.ICommand {
if (arg2) {
cmd = { type: cli.CommandType.appAdd };

(<cli.IAppAddCommand>cmd).appName = arg2;
const appAddCommand = <cli.IAppAddCommand>cmd;
appAddCommand.appName = arg2;
appAddCommand.platform = argv["platform"] as any;
}
break;

Expand Down
9 changes: 6 additions & 3 deletions script/management-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,12 @@ class AccountManager {
return this.get(urlEncode([`/apps/${appName}`])).then((res: JsonResponse) => res.body.app);
}

public addApp(appName: string): Promise<App> {
const app: App = { name: appName };
return this.post(urlEncode(["/apps/"]), JSON.stringify(app), /*expectResponseBody=*/ false).then(() => app);
public addApp(appName: string, platform?: string): Promise<App> {
const app: App = { name: appName, ...(platform ? { platform } : {}) };
// Resolve with the server's app, not the one sent: only its copy carries generated fields.
return this.post(urlEncode(["/apps/"]), JSON.stringify(app), /*expectResponseBody=*/ true).then(
(res: JsonResponse) => res.body.app as App
);
}

public removeApp(appName: string): Promise<void> {
Expand Down
106 changes: 106 additions & 0 deletions script/ota-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// App platforms, and the guards that keep a release-react bundle going to the app it was built for.
// The server rejects a mismatch too, but only after a minute of bundling and without naming the right
// command.

import * as fs from "fs";
import * as path from "path";

// Kept in step with the dashboard's platform.ts, so both name an app type the same way.
const PLATFORM_LABELS: { [platform: string]: string } = {
ios: "React Native (iOS)",
android: "React Native (Android)",
"expo-cng-ios": "Expo CNG (iOS)",
"expo-cng-android": "Expo CNG (Android)",
"expo-v1": "Expo Updates v1",
};

/** Human-readable app type. Apps created before platforms existed are plain React Native apps. */
export function appPlatformLabel(platform: string | null | undefined): string {
return (platform && PLATFORM_LABELS[platform]) || "React Native";
}

const quoteArg = (value: string): string => (/\s/.test(value) ? `"${value}"` : value);

function releaseReactHint(appName: string, deploymentName: string, appPlatform: string | null | undefined): string {
const platform = appPlatform && /ios$/.test(appPlatform) ? "ios" : appPlatform && /android$/.test(appPlatform) ? "android" : "<ios|android>";
return `dpctl release-react ${quoteArg(appName)} ${platform} -d ${quoteArg(deploymentName)}`;
}

/** "ios" or "android" for a CodePush app's platform; null for apps with no platform or no OS in it. */
export function osForAppPlatform(platform: string | null | undefined): "ios" | "android" | null {
if (platform === "ios" || platform === "expo-cng-ios") return "ios";
if (platform === "android" || platform === "expo-cng-android") return "android";
return null;
}

// `release-react MyApp-iOS android` would ship Android JavaScript to every iOS device, and the server
// cannot tell. Apps with no platform are not checked.
export function assertReleasePlatform(opts: {
appName: string;
deploymentName: string;
appPlatform: string | null | undefined;
releasePlatform: string;
}): void {
const { appName, deploymentName, appPlatform } = opts;
const expectedOs = osForAppPlatform(appPlatform);
const releaseOs = opts.releasePlatform.toLowerCase();
if (!expectedOs || releaseOs === expectedOs) return;
throw new Error(
`"${appName}" is a ${appPlatformLabel(appPlatform)} app, so it can't take a bundle built for ${releaseOs}. ` +
`Run this instead:\n ${releaseReactHint(appName, deploymentName, appPlatform)}`
);
}

const EXPO_CONFIG_FILES = ["app.json", "app.config.js", "app.config.ts", "app.config.mjs", "app.config.cjs"];

// "expo-cng" when the app config registers the CodePush config plugin, "bare" for React Native with no
// `expo` package, null otherwise. The `expo` dependency alone proves nothing: bare apps use Expo modules.
export function detectCodePushProjectKind(projectRoot: string = process.cwd()): "expo-cng" | "bare" | null {
let pkg: any;
try {
pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
} catch {
return null;
}
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
const usesConfigPlugin = EXPO_CONFIG_FILES.some((file: string) => {
try {
// Both plugin forms count. `<pkg>/expo` is the subpath the docs used before the SDK shipped an
// app.plugin.js; the bare package name is what they use now. Any fork matches, same as the
// dependency check above.
return /react-native-code-push/.test(fs.readFileSync(path.join(projectRoot, file), "utf8"));
} catch {
return false;
}
});
if (usesConfigPlugin) return "expo-cng";
if (!deps["expo"] && deps["react-native"]) return "bare";
return null;
}

// Throws for an Expo CNG app released from a non-Expo folder. The reverse only warns: the bundle is still
// valid, and an app's platform can't be changed, so a bare app that moved to CNG must still release.
export function checkReleaseProjectKind(opts: {
appName: string;
appPlatform: string | null | undefined;
projectRoot?: string;
}): string | null {
const { appName, appPlatform } = opts;
if (!osForAppPlatform(appPlatform)) return null;
const kind = detectCodePushProjectKind(opts.projectRoot);
const appIsExpoCng = appPlatform === "expo-cng-ios" || appPlatform === "expo-cng-android";

if (appIsExpoCng && kind === "bare") {
throw new Error(
`"${appName}" is an ${appPlatformLabel(appPlatform)} app, but this project doesn't use Expo, so it's ` +
`almost certainly the wrong folder. Run release-react from the Expo project this app belongs to.`
);
}
if (!appIsExpoCng && kind === "expo-cng") {
return (
`This is an Expo CNG project, but "${appName}" was created as a ${appPlatformLabel(appPlatform)} app. ` +
`The release will still work. If this isn't the app you meant, stop now and check the app name.`
);
}
return null;
}
2 changes: 2 additions & 0 deletions script/types/rest-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export interface CollaboratorMap {
/*inout*/
export interface App {
/*generated*/ id?: string;
// ios, android, expo-cng-ios or expo-cng-android. Unset on apps created before platforms existed.
platform?: string | null;
/*generated*/ collaborators?: CollaboratorMap;
/*key*/ name: string;
/*generated*/ deployments?: string[];
Expand Down
Loading
Loading