@rnx-kit/cli 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ import type { Config as CLIConfig } from "@react-native-community/cli-types";
2
+ import { error, info, warn } from "@rnx-kit/console";
3
+ import { isNonEmptyArray } from "@rnx-kit/tools-language/array";
4
+ import type { PackageManifest } from "@rnx-kit/tools-node/package";
5
+ import { findPackageDir, readPackage } from "@rnx-kit/tools-node/package";
6
+ import type { AllPlatforms } from "@rnx-kit/tools-react-native";
7
+ import { parsePlatform } from "@rnx-kit/tools-react-native";
8
+ import { spawnSync } from "child_process";
9
+ import * as fs from "fs-extra";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+
13
+ export type AndroidArchive = {
14
+ targetName: string;
15
+ version?: string;
16
+ output?: string;
17
+ };
18
+
19
+ export type NativeAssets = {
20
+ assets?: string[];
21
+ strings?: string[];
22
+ aar?: AndroidArchive & {
23
+ env?: Record<string, string | number>;
24
+ dependencies?: Record<string, AndroidArchive>;
25
+ };
26
+ xcassets?: string[];
27
+ };
28
+
29
+ export type Options = {
30
+ platform: AllPlatforms;
31
+ assetsDest: string;
32
+ bundleAar: boolean;
33
+ xcassetsDest?: string;
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ export type Context = {
38
+ projectRoot: string;
39
+ manifest: PackageManifest;
40
+ options: Options;
41
+ };
42
+
43
+ export type AssetsConfig = {
44
+ getAssets?: (context: Context) => Promise<NativeAssets>;
45
+ };
46
+
47
+ function ensureOption(options: Options, opt: string, flag = opt) {
48
+ if (options[opt] == null) {
49
+ error(`Missing required option: --${flag}`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+
54
+ function findGradleProject(projectRoot: string): string | undefined {
55
+ if (fs.existsSync(path.join(projectRoot, "android", "build.gradle"))) {
56
+ return path.join(projectRoot, "android");
57
+ }
58
+ if (fs.existsSync(path.join(projectRoot, "build.gradle"))) {
59
+ return projectRoot;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function isAssetsConfig(config: unknown): config is AssetsConfig {
65
+ return typeof config === "object" && config !== null && "getAssets" in config;
66
+ }
67
+
68
+ function keysOf(record: Record<string, unknown> | undefined): string[] {
69
+ return record ? Object.keys(record) : [];
70
+ }
71
+
72
+ function versionOf(pkgName: string): string {
73
+ const { version } = readPackage(`${pkgName}/package.json`);
74
+ return version;
75
+ }
76
+
77
+ function getAndroidPaths(
78
+ context: Context,
79
+ packageName: string,
80
+ { targetName, version, output }: AndroidArchive
81
+ ) {
82
+ const projectRoot = path.dirname(
83
+ require.resolve(`${packageName}/package.json`)
84
+ );
85
+
86
+ switch (packageName) {
87
+ case "hermes-engine":
88
+ return {
89
+ projectRoot,
90
+ output: path.join(projectRoot, "android", "hermes-release.aar"),
91
+ destination: path.join(
92
+ context.options.assetsDest,
93
+ "aar",
94
+ `hermes-release-${versionOf(packageName)}.aar`
95
+ ),
96
+ };
97
+
98
+ case "react-native":
99
+ return {
100
+ projectRoot,
101
+ output: path.join(projectRoot, "android"),
102
+ destination: path.join(
103
+ context.options.assetsDest,
104
+ "aar",
105
+ "react-native"
106
+ ),
107
+ };
108
+
109
+ default: {
110
+ const androidProject = findGradleProject(projectRoot);
111
+ return {
112
+ projectRoot,
113
+ androidProject,
114
+ output:
115
+ output ||
116
+ (androidProject &&
117
+ path.join(
118
+ androidProject,
119
+ "build",
120
+ "outputs",
121
+ "aar",
122
+ `${targetName}-release.aar`
123
+ )),
124
+ destination: path.join(
125
+ context.options.assetsDest,
126
+ "aar",
127
+ `${targetName}-${version || versionOf(packageName)}.aar`
128
+ ),
129
+ };
130
+ }
131
+ }
132
+ }
133
+
134
+ async function assembleAarBundle(
135
+ context: Context,
136
+ packageName: string,
137
+ { aar }: NativeAssets
138
+ ): Promise<void> {
139
+ if (!aar) {
140
+ return;
141
+ }
142
+
143
+ const findUp = require("find-up");
144
+ const gradlew = await findUp(
145
+ os.platform() === "win32" ? "gradlew.bat" : "gradlew"
146
+ );
147
+ if (!gradlew) {
148
+ warn(`Skipped \`${packageName}\`: cannot find \`gradlew\``);
149
+ return;
150
+ }
151
+
152
+ const { androidProject, output } = getAndroidPaths(context, packageName, aar);
153
+ if (!androidProject || !output) {
154
+ warn(`Skipped \`${packageName}\`: cannot find \`build.gradle\``);
155
+ return;
156
+ }
157
+
158
+ const { targetName, version, env, dependencies } = aar;
159
+ const targets = [`:${targetName}:assembleRelease`];
160
+ const targetsToCopy: [string, string][] = [];
161
+ if (dependencies) {
162
+ for (const [dependencyName, aar] of Object.entries(dependencies)) {
163
+ const { output, destination } = getAndroidPaths(
164
+ context,
165
+ dependencyName,
166
+ aar
167
+ );
168
+ if (output) {
169
+ if (!fs.existsSync(output)) {
170
+ targets.push(`:${aar.targetName}:assembleRelease`);
171
+ targetsToCopy.push([output, destination]);
172
+ } else if (!fs.existsSync(destination)) {
173
+ targetsToCopy.push([output, destination]);
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ // Run only one Gradle task at a time
180
+ spawnSync(gradlew, targets, {
181
+ cwd: androidProject,
182
+ stdio: "inherit",
183
+ env: {
184
+ ENABLE_HERMES: "true",
185
+ NODE_MODULES_PATH: path.join(process.cwd(), "node_modules"),
186
+ REACT_NATIVE_VERSION: versionOf("react-native"),
187
+ ...process.env,
188
+ ...env,
189
+ },
190
+ });
191
+
192
+ const destination = path.join(context.options.assetsDest, "aar");
193
+ await fs.ensureDir(destination);
194
+
195
+ const aarVersion = version || versionOf(packageName);
196
+ const dest = path.join(destination, `${targetName}-${aarVersion}.aar`);
197
+ await Promise.all([
198
+ fs.copy(output, dest),
199
+ ...targetsToCopy.map(([src, dest]) => fs.copy(src, dest)),
200
+ ]);
201
+ }
202
+
203
+ async function copyFiles(files: unknown, destination: string): Promise<void> {
204
+ if (!isNonEmptyArray<string>(files)) {
205
+ return;
206
+ }
207
+
208
+ await fs.ensureDir(destination);
209
+ await Promise.all(
210
+ files.map((file) => {
211
+ const basename = path.basename(file);
212
+ return fs.copy(file, `${destination}/${basename}`);
213
+ })
214
+ );
215
+ }
216
+
217
+ async function copyXcodeAssets(
218
+ xcassets: unknown,
219
+ destination: string
220
+ ): Promise<void> {
221
+ if (!isNonEmptyArray<string>(xcassets)) {
222
+ return;
223
+ }
224
+
225
+ await fs.ensureDir(destination);
226
+ await Promise.all(
227
+ xcassets.map((catalog) => {
228
+ const dest = `${destination}/${path.basename(catalog)}`;
229
+ return fs.copy(catalog, dest);
230
+ })
231
+ );
232
+ }
233
+
234
+ export async function copyAssets(
235
+ { options: { assetsDest, xcassetsDest } }: Context,
236
+ packageName: string,
237
+ { assets, strings, xcassets }: NativeAssets
238
+ ): Promise<void> {
239
+ const tasks = [
240
+ copyFiles(assets, `${assetsDest}/assets/${packageName}`),
241
+ copyFiles(strings, `${assetsDest}/strings/${packageName}`),
242
+ ];
243
+
244
+ if (typeof xcassetsDest === "string") {
245
+ tasks.push(copyXcodeAssets(xcassets, xcassetsDest));
246
+ }
247
+
248
+ await Promise.all(tasks);
249
+ }
250
+
251
+ export async function gatherConfigs({
252
+ projectRoot,
253
+ manifest,
254
+ }: Context): Promise<Record<string, AssetsConfig | null> | undefined> {
255
+ const { dependencies, devDependencies } = manifest;
256
+ const packages = [...keysOf(dependencies), ...keysOf(devDependencies)];
257
+ if (packages.length === 0) {
258
+ return;
259
+ }
260
+
261
+ const resolveOptions = { paths: [projectRoot] };
262
+ const assetsConfigs: Record<string, AssetsConfig | null> = {};
263
+
264
+ for (const pkg of packages) {
265
+ try {
266
+ const pkgPath = path.dirname(
267
+ require.resolve(`${pkg}/package.json`, resolveOptions)
268
+ );
269
+ const reactNativeConfig = `${pkgPath}/react-native.config.js`;
270
+ if (fs.existsSync(reactNativeConfig)) {
271
+ const { nativeAssets } = require(reactNativeConfig);
272
+ if (nativeAssets) {
273
+ assetsConfigs[pkg] = nativeAssets;
274
+ }
275
+ }
276
+ } catch (err) {
277
+ warn(err);
278
+ }
279
+ }
280
+
281
+ // Overrides from project config
282
+ const reactNativeConfig = `${projectRoot}/react-native.config.js`;
283
+ if (fs.existsSync(reactNativeConfig)) {
284
+ const { nativeAssets } = require(reactNativeConfig);
285
+ const overrides = Object.entries(nativeAssets);
286
+ for (const [pkgName, config] of overrides) {
287
+ if (config === null || isAssetsConfig(config)) {
288
+ assetsConfigs[pkgName] = config;
289
+ }
290
+ }
291
+ }
292
+
293
+ return assetsConfigs;
294
+ }
295
+
296
+ /**
297
+ * Copies additional assets not picked by bundlers into desired directory.
298
+ *
299
+ * The way this works is by scanning all direct dependencies of the current
300
+ * project for a file, `react-native.config.js`, whose contents include a
301
+ * field, `nativeAssets`, and a function that returns assets to copy:
302
+ *
303
+ * ```js
304
+ * // react-native.config.js
305
+ * module.exports = {
306
+ * nativeAssets: {
307
+ * getAssets: (context) => {
308
+ * return {
309
+ * assets: [],
310
+ * strings: [],
311
+ * xcassets: [],
312
+ * };
313
+ * }
314
+ * }
315
+ * };
316
+ * ```
317
+ *
318
+ * We also allow the project itself to override this where applicable. The
319
+ * format is similar and looks like this:
320
+ *
321
+ * ```js
322
+ * // react-native.config.js
323
+ * module.exports = {
324
+ * nativeAssets: {
325
+ * "some-library": {
326
+ * getAssets: (context) => {
327
+ * return {
328
+ * assets: [],
329
+ * strings: [],
330
+ * xcassets: [],
331
+ * };
332
+ * }
333
+ * },
334
+ * "another-library": {
335
+ * getAssets: (context) => {
336
+ * return {
337
+ * assets: [],
338
+ * strings: [],
339
+ * xcassets: [],
340
+ * };
341
+ * }
342
+ * }
343
+ * }
344
+ * };
345
+ * ```
346
+ *
347
+ * @param options Options dictate what gets copied where
348
+ */
349
+ export async function copyProjectAssets(options: Options): Promise<void> {
350
+ const projectRoot = findPackageDir() || process.cwd();
351
+ const content = await fs.readFile(`${projectRoot}/package.json`, {
352
+ encoding: "utf-8",
353
+ });
354
+ const manifest: PackageManifest = JSON.parse(content);
355
+ const context = { projectRoot, manifest, options };
356
+ const assetConfigs = await gatherConfigs(context);
357
+ if (!assetConfigs) {
358
+ return;
359
+ }
360
+
361
+ const dependencies = Object.entries(assetConfigs);
362
+ for (const [packageName, config] of dependencies) {
363
+ if (!isAssetsConfig(config)) {
364
+ continue;
365
+ }
366
+
367
+ const { getAssets } = config;
368
+ if (typeof getAssets !== "function") {
369
+ warn(`Skipped \`${packageName}\`: getAssets is not a function`);
370
+ continue;
371
+ }
372
+
373
+ const assets = await getAssets(context);
374
+ if (options.bundleAar && assets.aar) {
375
+ info(`Assembling "${packageName}"`);
376
+ await assembleAarBundle(context, packageName, assets);
377
+ } else {
378
+ info(`Copying assets for "${packageName}"`);
379
+ await copyAssets(context, packageName, assets);
380
+ }
381
+ }
382
+
383
+ if (options.bundleAar) {
384
+ const dummyAar = { targetName: "dummy" };
385
+ const copyTasks = [];
386
+ for (const dependencyName of ["hermes-engine", "react-native"]) {
387
+ const { output, destination } = getAndroidPaths(
388
+ context,
389
+ dependencyName,
390
+ dummyAar
391
+ );
392
+ if (
393
+ output &&
394
+ (!fs.existsSync(destination) || fs.statSync(destination).isDirectory())
395
+ ) {
396
+ info(`Copying Android Archive of "${dependencyName}"`);
397
+ copyTasks.push(fs.copy(output, destination));
398
+ }
399
+ }
400
+ await Promise.all(copyTasks);
401
+ }
402
+ }
403
+
404
+ export const rnxCopyAssetsCommand = {
405
+ name: "rnx-copy-assets",
406
+ description:
407
+ "Copies additional assets not picked by bundlers into desired directory.",
408
+ func: (_argv: string[], _config: CLIConfig, options: Options) => {
409
+ ensureOption(options, "platform");
410
+ ensureOption(options, "assetsDest", "assets-dest");
411
+ return copyProjectAssets(options);
412
+ },
413
+ options: [
414
+ {
415
+ name: "--platform <string>",
416
+ description: "platform to target",
417
+ parse: parsePlatform,
418
+ },
419
+ {
420
+ name: "--assets-dest <string>",
421
+ description: "path of the directory to copy assets into",
422
+ },
423
+ {
424
+ name: "--bundle-aar <boolean>",
425
+ description: "whether to bundle AARs of dependencies",
426
+ default: false,
427
+ },
428
+ {
429
+ name: "--xcassets-dest <string>",
430
+ description:
431
+ "path of the directory to copy Xcode asset catalogs into. Asset catalogs will only be copied if a destination path is specified.",
432
+ },
433
+ ],
434
+ };
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { rnxBundle } from "./bundle";
2
+ export { copyProjectAssets, rnxCopyAssetsCommand } from "./copy-assets";
2
3
  export { rnxDepCheck, rnxDepCheckCommand } from "./dep-check";
3
4
  export { rnxStart } from "./start";
4
5
  export { rnxTest, rnxTestCommand } from "./test";
@@ -109,7 +109,7 @@ const emptySerializerHook = (_graph: Graph, _delta: DeltaResult): void => {
109
109
  * @param detectCyclicDependencies When true, cyclic dependency checking is enabled with a default set of options. Otherwise the object allows for fine-grained control over the detection process.
110
110
  * @param detectDuplicateDependencies When true, duplicate dependency checking is enabled with a default set of options. Otherwise, the object allows for fine-grained control over the detection process.
111
111
  * @param typescriptValidation When true, TypeScript type-checking is enabled with a default set of options. Otherwise, the object allows for fine-grained control over the type-checking process.
112
- * @param experimental_treeShake When true, experimental tree-shaking is enabled.
112
+ * @param experimental_treeShake When true, experimental tree shaking is enabled.
113
113
  */
114
114
  export function customizeMetroConfig(
115
115
  metroConfigReadonly: InputConfigT,
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ const fs = jest.createMockFromModule("fs-extra");
4
+
5
+ const { vol } = require("memfs");
6
+
7
+ /** @type {(newMockFiles: { [filename: string]: string }) => void} */
8
+ fs.__setMockFiles = (files) => {
9
+ vol.reset();
10
+ vol.fromJSON(files);
11
+ };
12
+
13
+ fs.__toJSON = () => vol.toJSON();
14
+
15
+ fs.copy = (...args) => vol.promises.copyFile(...args);
16
+ fs.ensureDir = (dir) => vol.promises.mkdir(dir, { recursive: true });
17
+ fs.pathExists = (...args) => Promise.resolve(vol.existsSync(...args));
18
+ fs.readFile = (...args) => vol.promises.readFile(...args);
19
+
20
+ module.exports = fs;
@@ -0,0 +1,113 @@
1
+ import fs from "fs-extra";
2
+ import * as path from "path";
3
+ import { copyAssets, gatherConfigs } from "../src/copy-assets";
4
+
5
+ const options = {
6
+ platform: "ios" as const,
7
+ assetsDest: "dist",
8
+ bundleAar: false,
9
+ xcassetsDest: "xcassets",
10
+ };
11
+
12
+ const context = {
13
+ projectRoot: path.resolve(__dirname, ".."),
14
+ manifest: {
15
+ name: "@rnx-kit/cli",
16
+ version: "0.0.0-dev",
17
+ },
18
+ options,
19
+ };
20
+
21
+ function findFiles() {
22
+ // @ts-ignore `__toJSON`
23
+ return Object.entries(fs.__toJSON());
24
+ }
25
+
26
+ function mockFiles(files: Record<string, string> = {}) {
27
+ // @ts-ignore `__setMockFiles`
28
+ fs.__setMockFiles(files);
29
+ }
30
+
31
+ describe("copyAssets", () => {
32
+ afterEach(() => {
33
+ mockFiles();
34
+ });
35
+
36
+ afterAll(() => {
37
+ jest.clearAllMocks();
38
+ });
39
+
40
+ test("returns early if there is nothing to copy", async () => {
41
+ await copyAssets(context, "test", {});
42
+ expect(findFiles()).toEqual([]);
43
+ });
44
+
45
+ test("copies assets", async () => {
46
+ const filename = "arnolds-greatest-movies.md";
47
+ const content = "all of them";
48
+ mockFiles({ [filename]: content });
49
+
50
+ await copyAssets(context, "test", { assets: [filename] });
51
+
52
+ expect(findFiles()).toEqual([
53
+ [expect.stringContaining(filename), content],
54
+ [
55
+ expect.stringMatching(
56
+ `dist[/\\\\]assets[/\\\\]test[/\\\\]${filename}$`
57
+ ),
58
+ content,
59
+ ],
60
+ ]);
61
+ });
62
+
63
+ test("copies strings", async () => {
64
+ const filename = "arnolds-greatest-lines.md";
65
+ const content = "all of them";
66
+ mockFiles({ [filename]: content });
67
+
68
+ await copyAssets(context, "test", { strings: [filename] });
69
+
70
+ expect(findFiles()).toEqual([
71
+ [expect.stringContaining(filename), content],
72
+ [
73
+ expect.stringMatching(
74
+ `dist[/\\\\]strings[/\\\\]test[/\\\\]${filename}$`
75
+ ),
76
+ content,
77
+ ],
78
+ ]);
79
+ });
80
+
81
+ test("copies Xcode asset catalogs", async () => {
82
+ const filename = "arnolds-greatest-assets.xcassets";
83
+ const content = "all of them";
84
+ mockFiles({ [filename]: content });
85
+
86
+ await copyAssets(context, "test", { xcassets: [filename] });
87
+
88
+ expect(findFiles()).toEqual([
89
+ [expect.stringContaining(filename), content],
90
+ [expect.stringMatching(`xcassets[/\\\\]${filename}$`), content],
91
+ ]);
92
+ });
93
+
94
+ test("does not copy Xcode asset catalogs if destination path is unset", async () => {
95
+ const filename = "arnolds-greatest-assets.xcassets";
96
+ const content = "all of them";
97
+ mockFiles({ [filename]: content });
98
+
99
+ await copyAssets(
100
+ { ...context, options: { ...options, xcassetsDest: undefined } },
101
+ "test",
102
+ { xcassets: [filename] }
103
+ );
104
+
105
+ expect(findFiles()).toEqual([[expect.stringContaining(filename), content]]);
106
+ });
107
+ });
108
+
109
+ describe("gatherConfigs", () => {
110
+ test("returns early if there is nothing to copy", async () => {
111
+ expect(await gatherConfigs(context)).toBeUndefined();
112
+ });
113
+ });