Skip to content

Commit f55ce07

Browse files
committed
feat: add add-platform command
1 parent 2602f83 commit f55ce07

File tree

10 files changed

+474
-23
lines changed

10 files changed

+474
-23
lines changed

Diff for: docs/init.md

+12-6
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ module.exports = {
7373

7474
// Path to script, which will be executed after initialization process, but before installing all the dependencies specified in the template. This script runs as a shell script but you can change that (e.g. to Node) by using a shebang (see example custom template).
7575
postInitScript: './script.js',
76+
// We're also using `template.config.js` when adding new platforms to existing project in `add-platform` command. Thanks to value passed to `platformName` we know which folder we should copy to the project.
77+
platformName: 'visionos',
7678
};
7779
```
7880

@@ -91,12 +93,16 @@ new Promise((resolve) => {
9193
spinner.start();
9294
// do something
9395
resolve();
94-
}).then(() => {
95-
spinner.succeed();
96-
}).catch(() => {
97-
spinner.fail();
98-
throw new Error('Something went wrong during the post init script execution');
99-
});
96+
})
97+
.then(() => {
98+
spinner.succeed();
99+
})
100+
.catch(() => {
101+
spinner.fail();
102+
throw new Error(
103+
'Something went wrong during the post init script execution',
104+
);
105+
});
100106
```
101107

102108
You can find example custom template [here](https://github.com/Esemesek/react-native-new-template).

Diff for: packages/cli/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@
3939
"find-up": "^4.1.0",
4040
"fs-extra": "^8.1.0",
4141
"graceful-fs": "^4.1.3",
42+
"npm-registry-fetch": "^16.1.0",
4243
"prompts": "^2.4.2",
4344
"semver": "^7.5.2"
4445
},
4546
"devDependencies": {
4647
"@types/fs-extra": "^8.1.0",
4748
"@types/graceful-fs": "^4.1.3",
4849
"@types/hapi__joi": "^17.1.6",
50+
"@types/npm-registry-fetch": "^8.0.7",
4951
"@types/prompts": "^2.4.4",
5052
"@types/semver": "^6.0.2",
5153
"slash": "^3.0.0",

Diff for: packages/cli/src/commands/addPlatform/addPlatform.ts

+208
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import {
2+
CLIError,
3+
getLoader,
4+
logger,
5+
prompt,
6+
} from '@react-native-community/cli-tools';
7+
import {Config} from '@react-native-community/cli-types';
8+
import {join} from 'path';
9+
import {readFileSync} from 'fs';
10+
import chalk from 'chalk';
11+
import {install, PackageManager} from './../../tools/packageManager';
12+
import npmFetch from 'npm-registry-fetch';
13+
import semver from 'semver';
14+
import {checkGitInstallation, isGitTreeDirty} from '../init/git';
15+
import {
16+
changePlaceholderInTemplate,
17+
getTemplateName,
18+
} from '../init/editTemplate';
19+
import {
20+
copyTemplate,
21+
executePostInitScript,
22+
getTemplateConfig,
23+
installTemplatePackage,
24+
} from '../init/template';
25+
import {tmpdir} from 'os';
26+
import {mkdtempSync} from 'graceful-fs';
27+
28+
type Options = {
29+
packageName: string;
30+
version: string;
31+
pm: PackageManager;
32+
title: string;
33+
};
34+
35+
const NPM_REGISTRY_URL = 'http://registry.npmjs.org'; // TODO: Support local registry
36+
37+
const getAppName = async (root: string) => {
38+
logger.log(`Reading ${chalk.cyan('name')} from package.json…`);
39+
const pkgJsonPath = join(root, 'package.json');
40+
41+
if (!pkgJsonPath) {
42+
throw new CLIError(`Unable to find package.json inside ${root}`);
43+
}
44+
45+
let {name} = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
46+
47+
if (!name) {
48+
const appJson = join(root, 'app.json');
49+
if (appJson) {
50+
logger.log(`Reading ${chalk.cyan('name')} from app.json…`);
51+
name = JSON.parse(readFileSync(appJson, 'utf8')).name;
52+
}
53+
54+
if (!name) {
55+
throw new CLIError('Please specify name in package.json or app.json.');
56+
}
57+
}
58+
59+
return name;
60+
};
61+
62+
const getPackageMatchingVersion = async (
63+
packageName: string,
64+
version: string,
65+
) => {
66+
const npmResponse = await npmFetch.json(packageName, {
67+
registry: NPM_REGISTRY_URL,
68+
});
69+
70+
if ('dist-tags' in npmResponse) {
71+
const distTags = npmResponse['dist-tags'] as Record<string, string>;
72+
if (version in distTags) {
73+
return distTags[version];
74+
}
75+
}
76+
77+
if ('versions' in npmResponse) {
78+
const versions = Object.keys(
79+
npmResponse.versions as Record<string, unknown>,
80+
);
81+
if (versions.length > 0) {
82+
const candidates = versions
83+
.filter((v) => semver.satisfies(v, version))
84+
.sort(semver.rcompare);
85+
86+
if (candidates.length > 0) {
87+
return candidates[0];
88+
}
89+
}
90+
}
91+
92+
throw new Error(
93+
`Cannot find matching version of ${packageName} to react-native${version}, please provide version manually with --version flag.`,
94+
);
95+
};
96+
97+
async function addPlatform(
98+
[packageName]: string[],
99+
{root, reactNativeVersion}: Config,
100+
{version, pm, title}: Options,
101+
) {
102+
if (!packageName) {
103+
throw new CLIError('Please provide package name e.g. react-native-macos');
104+
}
105+
106+
const isGitAvailable = await checkGitInstallation();
107+
108+
if (isGitAvailable) {
109+
const dirty = await isGitTreeDirty(root);
110+
111+
if (dirty) {
112+
logger.warn(
113+
'Your git tree is dirty. We recommend committing or stashing changes first.',
114+
);
115+
const {proceed} = await prompt({
116+
type: 'confirm',
117+
name: 'proceed',
118+
message: 'Would you like to proceed?',
119+
});
120+
121+
if (!proceed) {
122+
return;
123+
}
124+
125+
logger.info('Proceeding with the installation');
126+
}
127+
}
128+
129+
const projectName = await getAppName(root);
130+
131+
const matchingVersion = await getPackageMatchingVersion(
132+
packageName,
133+
version ?? reactNativeVersion,
134+
);
135+
136+
logger.log(
137+
`Found matching version ${chalk.cyan(matchingVersion)} for ${chalk.cyan(
138+
packageName,
139+
)}`,
140+
);
141+
142+
const loader = getLoader({
143+
text: `Installing ${packageName}@${matchingVersion}`,
144+
});
145+
146+
loader.start();
147+
148+
try {
149+
await install([`${packageName}@${matchingVersion}`], {
150+
packageManager: pm,
151+
silent: true,
152+
root,
153+
});
154+
loader.succeed();
155+
} catch (e) {
156+
loader.fail();
157+
throw e;
158+
}
159+
160+
loader.start('Copying template files');
161+
const templateSourceDir = mkdtempSync(join(tmpdir(), 'rncli-init-template-'));
162+
await installTemplatePackage(
163+
`${packageName}@${matchingVersion}`,
164+
templateSourceDir,
165+
pm,
166+
);
167+
168+
const templateName = getTemplateName(templateSourceDir);
169+
const templateConfig = getTemplateConfig(templateName, templateSourceDir);
170+
171+
if (!templateConfig.platformName) {
172+
throw new CLIError(
173+
`Template ${templateName} is missing platformName in its template.config.js`,
174+
);
175+
}
176+
177+
await copyTemplate(
178+
templateName,
179+
templateConfig.templateDir,
180+
templateSourceDir,
181+
templateConfig.platformName,
182+
);
183+
184+
loader.succeed();
185+
loader.start('Processing template');
186+
187+
await changePlaceholderInTemplate({
188+
projectName,
189+
projectTitle: title,
190+
placeholderName: templateConfig.placeholderName,
191+
placeholderTitle: templateConfig.titlePlaceholder,
192+
projectPath: join(root, templateConfig.platformName),
193+
});
194+
195+
loader.succeed();
196+
197+
const {postInitScript} = templateConfig;
198+
if (postInitScript) {
199+
logger.debug('Executing post init script ');
200+
await executePostInitScript(
201+
templateName,
202+
postInitScript,
203+
templateSourceDir,
204+
);
205+
}
206+
}
207+
208+
export default addPlatform;

Diff for: packages/cli/src/commands/addPlatform/index.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import addPlatform from './addPlatform';
2+
3+
export default {
4+
func: addPlatform,
5+
name: 'add-platform [packageName]',
6+
description: 'Add new platform to your React Native project.',
7+
options: [
8+
{
9+
name: '--version <string>',
10+
description: 'Pass version of the platform to be added to the project.',
11+
},
12+
{
13+
name: '--pm <string>',
14+
description:
15+
'Use specific package manager to initialize the project. Available options: `yarn`, `npm`, `bun`. Default: `yarn`',
16+
default: 'yarn',
17+
},
18+
{
19+
name: '--title <string>',
20+
description: 'Uses a custom app title name for application',
21+
},
22+
],
23+
};

Diff for: packages/cli/src/commands/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import {commands as configCommands} from '@react-native-community/cli-config';
55
import profileHermes from '@react-native-community/cli-hermes';
66
import upgrade from './upgrade/upgrade';
77
import init from './init';
8+
import addPlatform from './addPlatform';
89

910
export const projectCommands = [
1011
...configCommands,
1112
cleanCommands.clean,
1213
doctorCommands.info,
1314
upgrade,
1415
profileHermes,
16+
addPlatform,
1517
] as Command[];
1618

1719
export const detachedCommands = [

Diff for: packages/cli/src/commands/init/editTemplate.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface PlaceholderConfig {
1313
placeholderTitle?: string;
1414
projectTitle?: string;
1515
packageName?: string;
16+
projectPath?: string;
1617
}
1718

1819
/**
@@ -145,11 +146,12 @@ export async function replacePlaceholderWithPackageName({
145146
placeholderName,
146147
placeholderTitle,
147148
packageName,
149+
projectPath,
148150
}: Omit<Required<PlaceholderConfig>, 'projectTitle'>) {
149151
validatePackageName(packageName);
150152
const cleanPackageName = packageName.replace(/[^\p{L}\p{N}.]+/gu, '');
151153

152-
for (const filePath of walk(process.cwd()).reverse()) {
154+
for (const filePath of walk(projectPath).reverse()) {
153155
if (shouldIgnoreFile(filePath)) {
154156
continue;
155157
}
@@ -232,6 +234,7 @@ export async function changePlaceholderInTemplate({
232234
placeholderTitle = DEFAULT_TITLE_PLACEHOLDER,
233235
projectTitle = projectName,
234236
packageName,
237+
projectPath = process.cwd(),
235238
}: PlaceholderConfig) {
236239
logger.debug(`Changing ${placeholderName} for ${projectName} in template`);
237240

@@ -242,12 +245,13 @@ export async function changePlaceholderInTemplate({
242245
placeholderName,
243246
placeholderTitle,
244247
packageName,
248+
projectPath,
245249
});
246250
} catch (error) {
247251
throw new CLIError((error as Error).message);
248252
}
249253
} else {
250-
for (const filePath of walk(process.cwd()).reverse()) {
254+
for (const filePath of walk(projectPath).reverse()) {
251255
if (shouldIgnoreFile(filePath)) {
252256
continue;
253257
}
@@ -269,3 +273,14 @@ export async function changePlaceholderInTemplate({
269273
}
270274
}
271275
}
276+
277+
export function getTemplateName(cwd: string) {
278+
// We use package manager to infer the name of the template module for us.
279+
// That's why we get it from temporary package.json, where the name is the
280+
// first and only dependency (hence 0).
281+
const name = Object.keys(
282+
JSON.parse(fs.readFileSync(path.join(cwd, './package.json'), 'utf8'))
283+
.dependencies,
284+
)[0];
285+
return name;
286+
}

Diff for: packages/cli/src/commands/init/git.ts

+11
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,14 @@ export const createGitRepository = async (folder: string) => {
6868
);
6969
}
7070
};
71+
72+
export const isGitTreeDirty = async (folder: string) => {
73+
try {
74+
const {stdout} = await execa('git', ['status', '--porcelain'], {
75+
cwd: folder,
76+
});
77+
return stdout !== '';
78+
} catch {
79+
return false;
80+
}
81+
};

Diff for: packages/cli/src/commands/init/init.ts

-11
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,6 @@ async function setProjectDirectory(
169169
return process.cwd();
170170
}
171171

172-
function getTemplateName(cwd: string) {
173-
// We use package manager to infer the name of the template module for us.
174-
// That's why we get it from temporary package.json, where the name is the
175-
// first and only dependency (hence 0).
176-
const name = Object.keys(
177-
JSON.parse(fs.readFileSync(path.join(cwd, './package.json'), 'utf8'))
178-
.dependencies,
179-
)[0];
180-
return name;
181-
}
182-
183172
//set cache to empty string to prevent installing cocoapods on freshly created project
184173
function setEmptyHashForCachedDependencies(projectName: string) {
185174
cacheManager.set(

0 commit comments

Comments
 (0)