Skip to content

Commit

Permalink
cli helper methods (#44463)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #44463

Helper methods to help the cli grab system state, devices and run react-native/core-cli-utils tasks using Listr.

Changelog: [Internal]

Reviewed By: cipolleschi

Differential Revision: D57067037

fbshipit-source-id: 28cb4239f3a93558b88417f366a2146f696cc411
  • Loading branch information
blakef authored and facebook-github-bot committed May 13, 2024
1 parent 9b0072a commit fedb145
Show file tree
Hide file tree
Showing 4 changed files with 378 additions and 0 deletions.
148 changes: 148 additions & 0 deletions packages/helloworld/lib/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import type {Task} from '@react-native/core-cli-utils';
import type {Result} from 'execa';
import type {ExecaPromise} from 'execa';
import type {TaskSpec} from 'listr';

import chalk from 'chalk';
import Listr from 'listr';
import {Observable} from 'rxjs';

export function trim(
line: string,
// $FlowFixMe[prop-missing]
maxLength: number = Math.min(process.stdout?.columns, 120),
): string {
const flattened = line.replaceAll('\n', ' ').trim();
return flattened.length >= maxLength
? flattened.slice(0, maxLength - 3) + '...'
: flattened.trim();
}

type ExecaPromiseMetaized = Promise<Result> & child_process$ChildProcess;

export function observe(result: ExecaPromiseMetaized): Observable<string> {
return new Observable(observer => {
result.stderr.on('data', (data: Buffer) =>
data
.toString('utf8')
.split('\n')
.filter(line => line.length > 0)
.forEach(line => observer.next('🟢 ' + trim(line))),
);
result.stdout.on('data', (data: Buffer) =>
data
.toString('utf8')
.split('\n')
.filter(line => line.length > 0)
.forEach(line => observer.next('🟠 ' + trim(line))),
);

// Terminal events
result.stdout.on('error', error => observer.error(error.trim()));
result.then(
(_: Result) => observer.complete(),
error =>
observer.error(
new Error(
`${chalk.red.bold(error.shortMessage)}\n${
error.stderr || error.stdout
}`,
),
),
);

return () => {
for (const out of [result.stderr, result.stdout]) {
out.destroy();
out.removeAllListeners();
}
};
});
}

type MixedTasks = Task<ExecaPromise> | Task<void>;
type Tasks = {
+[label: string]: MixedTasks,
};

export function run(
tasks: Tasks,
exclude: {[label: string]: boolean} = {},
): Promise<void> {
let ordered: MixedTasks[] = [];
for (const [label, task] of Object.entries(tasks)) {
if (label in exclude) {
continue;
}
ordered.push(task);
}
ordered = ordered.sort((a, b) => a.order - b.order);

const spec: TaskSpec<void, Observable<string> | Promise<void> | void>[] =
ordered.map(task => ({
title: task.label,
task: () => {
const action = task.action();
if (action != null) {
return observe(action);
}
},
}));
return new Listr(spec).run();
}

export function handlePipedArgs(): Promise<string[]> {
return new Promise(resolve => {
if (process.stdin.isTTY == null) {
return resolve([]);
}

const args: string[] = [];
const msg: string[] = [];
let count = 0;
const assignment = /^(.+?)=(.*)$/;

function processLine(line: string) {
const match = assignment.exec(line);
if (!match) {
msg.push(chalk.red(line));
count++;
return;
}
const [key, value] = match.slice(1);
if (value == null) {
msg.push(chalk.bold(line) + chalk.red('<missing value>'));
count++;
return;
}
msg.push(chalk.dim(line));
args.push(`--${key} ${value}`);
}

process.stdout.on('line', processLine);
process.stdout.on('close', () => {
process.stdout.removeListener('line', processLine);
if (count > 0) {
process.stderr.write(
`The config piped into ${chalk.bold(
'helloword/cli',
)} contained errors:\n\n` +
msg.map(line => ' ' + line).join('\n') +
'\n',
);
}
resolve(args);
});
});
}
49 changes: 49 additions & 0 deletions packages/helloworld/lib/filesystem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import {execSync, spawn} from 'child_process';
import debug from 'debug';

const logWatchman = debug('helloworld:cli:watchman');

export async function pauseWatchman(command: () => Promise<mixed | void>) {
let p: ReturnType<typeof spawn> | null = null;
try {
const raw: string = execSync('watchman watch-project .', {
cwd: process.cwd(),
}).toString();
const {watch} = JSON.parse(raw);

p = spawn('watchman', [
'--no-pretty',
'--persistent',
'state-enter',
watch,
'yarn-install',
]);
logWatchman(`[PID:${p.pid}] started`);
} catch (e) {
logWatchman(
`Unable to pause watchman: ${e.message}, running command anyway`,
);
} finally {
try {
// Always run our user, if watchman has problems or doesn't exist proceed.
await command();
} finally {
if (p?.killed || p?.exitCode != null) {
return;
}
logWatchman(`[PID:${p?.pid ?? '????'}] killing with SIGTERM`);
p?.kill('SIGTERM');
}
}
}
148 changes: 148 additions & 0 deletions packages/helloworld/lib/ios.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import type {XcodeBuildSettings} from './xcode';
import type {Result} from 'execa';

import execa from 'execa';

export type IOSDevice = {
lastBootedAt: Date,
dataPath: string,
dataPathSize: number,
logPath: string,
udid: string,
isAvailable: boolean,
availabilityError: string,
logPathSize: number,
deviceTypeIdentifier: string,
state: 'Shutdown' | 'Booted' | 'Creating',
name: string,
};

export async function getSimulatorDetails(
nameOrUDID: string,
): Promise<IOSDevice> {
const {stdout} = execa.sync('xcrun', [
'simctl',
'list',
'devices',
'iPhone',
'available',
'--json',
]);
const json = JSON.parse(stdout);

const allAvailableDevices: IOSDevice[] = Object.values(json.devices)
.flatMap(devices => devices)
.filter(device => device.isAvailable)
.map(device => ({
...device,
lastBootedAt: new Date(device.lastBootedAt),
}));

if (nameOrUDID.length > 0 && nameOrUDID.toLowerCase() !== 'simulator') {
const namedDevice = allAvailableDevices.find(
device => device.udid === nameOrUDID || device.name === nameOrUDID,
);
if (namedDevice == null) {
const devices = allAvailableDevices
.map(device => `- ${device.name}: ${device.udid}`)
.join('\n - ');
throw new Error(
`Unable to find device with name or UDID: '${nameOrUDID}', found:\n\n${devices}`,
);
}
return namedDevice;
}

const booted: IOSDevice[] = allAvailableDevices.filter(
device =>
device.state === 'Booted' &&
/SimDeviceType\.iPhone/.test(device.deviceTypeIdentifier),
);
// Pick anything that is booted, otherwise get your user to help out
const available = booted.sort(
(a, b) => a.lastBootedAt.getTime() - b.lastBootedAt.getTime(),
);

if (available.length === 0) {
throw new Error(
'No simulator is available, please create on using the Simulator',
);
}

return available[0];
}

export async function bootSimulator(
device: IOSDevice,
): Promise<Result | string> {
if (device.state === 'Shutdown') {
return execa('xcrun', ['simctl', 'boot', device.udid]);
}
return Promise.resolve('Already booted');
}

export async function launchSimulator(device: IOSDevice): Promise<Result> {
return execa('open', [
'-a',
'Simulator',
'--args',
'-CurrentDeviceUDID',
device.udid,
]);
}

/**
* Launches the app on the simulator.
*
* @param udid The UDID of the simulator
* @param bundleId The bundle ID of the app
* @param env The environment variables to set in the app environment (optional)
*/
export async function launchApp(
udid: string,
bundleId: string,
env: {[string]: string} | null = null,
): Promise<Result> {
const _env: {[string]: string | void} = {};
if (env) {
for (const [key, value] of Object.entries(env)) {
_env['SIMCTL_CHILD_' + key] = value;
}
}
return execa('xcrun', ['simctl', 'launch', udid, bundleId], {
env: env == null ? _env : process.env,
});
}

export function getXcodeBuildSettings(
iosProjectFolder: string,
): XcodeBuildSettings[] {
const {stdout} = execa.sync(
'xcodebuild',
[
'-workspace',
'HelloWorld.xcworkspace',
'-scheme',
'HelloWorld',
'-configuration',
'Debug',
'-sdk',
'iphonesimulator',
'-showBuildSettings',
'-json',
],
{cwd: iosProjectFolder},
);
return JSON.parse(stdout);
}
33 changes: 33 additions & 0 deletions packages/helloworld/lib/xcode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

// For the complete list:
//
// xcodebuild \
// -workspace HelloWorld.xcworkspace \
// -scheme HelloWorld \
// -configuration Debug \
// -sdk iphonesimulator \
// -showBuildSettings
// -json

export type XcodeBuildSettings = {
action: string,
buildSettings: {
CONFIGURATION_BUILD_DIR: string,
EXECUTABLE_FOLDER_PATH: string,
PRODUCT_BUNDLE_IDENTIFIER: string,
TARGET_BUILD_DIR: string,
UNLOCALIZED_RESOURCES_FOLDER_PATH: string,
...
},
target: string,
};

0 comments on commit fedb145

Please sign in to comment.