Skip to content

Commit

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

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

Changelog: [Internal]

Differential Revision: D57067037
  • Loading branch information
blakef authored and facebook-github-bot committed May 8, 2024
1 parent 774c01a commit 23e5a10
Show file tree
Hide file tree
Showing 4 changed files with 875 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);
});
});
}
56 changes: 56 additions & 0 deletions packages/helloworld/lib/filesystem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* 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');

type WatchmanWatchProject = {
version: string,
watcher: string,
watch: string,
relative_path: string,
};

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}: WatchmanWatchProject = 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');
}
}
}
139 changes: 139 additions & 0 deletions packages/helloworld/lib/ios.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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 allSimIPhones: IOSDevice[] = allAvailableDevices.filter(device =>
/SimDeviceType\.iPhone/.test(device.deviceTypeIdentifier),
);

// Pick anything that is booted, otherwise get your user to help out
const available = allSimIPhones.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',
);
}

const booted = allSimIPhones
.filter(device => device.state === 'Booted')
.pop();

if (booted != null) {
return booted;
}

return allSimIPhones[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,
]);
}

export async function launchApp(
udid: string,
bundleId: string,
): Promise<Result> {
return execa('xcrun', ['simctl', 'launch', udid, bundleId]);
}

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);
}

0 comments on commit 23e5a10

Please sign in to comment.