diff --git a/docs/init.md b/docs/init.md index 59a936366..45877e32c 100644 --- a/docs/init.md +++ b/docs/init.md @@ -73,6 +73,8 @@ module.exports = { // 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). postInitScript: './script.js', + // 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. + platformName: 'visionos', }; ``` @@ -91,12 +93,16 @@ new Promise((resolve) => { spinner.start(); // do something resolve(); -}).then(() => { - spinner.succeed(); -}).catch(() => { - spinner.fail(); - throw new Error('Something went wrong during the post init script execution'); -}); +}) + .then(() => { + spinner.succeed(); + }) + .catch(() => { + spinner.fail(); + throw new Error( + 'Something went wrong during the post init script execution', + ); + }); ``` You can find example custom template [here](https://github.com/Esemesek/react-native-new-template). diff --git a/packages/cli/package.json b/packages/cli/package.json index 24d15f165..67ce20824 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,6 +38,7 @@ "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", + "npm-registry-fetch": "^16.1.0", "prompts": "^2.4.2", "semver": "^7.5.2" }, @@ -45,6 +46,7 @@ "@types/fs-extra": "^8.1.0", "@types/graceful-fs": "^4.1.3", "@types/hapi__joi": "^17.1.6", + "@types/npm-registry-fetch": "^8.0.7", "@types/prompts": "^2.4.4", "@types/semver": "^6.0.2", "slash": "^3.0.0", diff --git a/packages/cli/src/commands/addPlatform/addPlatform.ts b/packages/cli/src/commands/addPlatform/addPlatform.ts new file mode 100644 index 000000000..5488e3640 --- /dev/null +++ b/packages/cli/src/commands/addPlatform/addPlatform.ts @@ -0,0 +1,262 @@ +import { + CLIError, + getLoader, + logger, + prompt, +} from '@react-native-community/cli-tools'; +import {Config} from '@react-native-community/cli-types'; +import {join} from 'path'; +import {readFileSync} from 'fs'; +import chalk from 'chalk'; +import {install, PackageManager} from './../../tools/packageManager'; +import npmFetch from 'npm-registry-fetch'; +import semver from 'semver'; +import {checkGitInstallation, isGitTreeDirty} from '../init/git'; +import {changePlaceholderInTemplate} from '../init/editTemplate'; +import { + copyTemplate, + executePostInitScript, + getTemplateConfig, + installTemplatePackage, +} from '../init/template'; +import {tmpdir} from 'os'; +import {mkdtempSync} from 'graceful-fs'; +import {existsSync} from 'fs'; +import {getNpmRegistryUrl} from '../../tools/npm'; + +type Options = { + packageName: string; + version: string; + pm: PackageManager; + title: string; +}; + +const NPM_REGISTRY_URL = getNpmRegistryUrl(); + +const getAppName = async (root: string) => { + logger.log(`Reading ${chalk.cyan('name')} from package.json…`); + const pkgJsonPath = join(root, 'package.json'); + + if (!pkgJsonPath) { + throw new CLIError(`Unable to find package.json inside ${root}`); + } + + let name; + + try { + name = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).name; + } catch (e) { + throw new CLIError(`Failed to read ${pkgJsonPath} file.`, e as Error); + } + + if (!name) { + const appJson = join(root, 'app.json'); + if (appJson) { + logger.log(`Reading ${chalk.cyan('name')} from app.json…`); + try { + name = JSON.parse(readFileSync(appJson, 'utf8')).name; + } catch (e) { + throw new CLIError(`Failed to read ${pkgJsonPath} file.`, e as Error); + } + } + + if (!name) { + throw new CLIError('Please specify name in package.json or app.json.'); + } + } + + return name; +}; + +const getPackageMatchingVersion = async ( + packageName: string, + version: string, +) => { + const npmResponse = await npmFetch.json(packageName, { + registry: NPM_REGISTRY_URL, + }); + + if ('dist-tags' in npmResponse) { + const distTags = npmResponse['dist-tags'] as Record; + if (version in distTags) { + return distTags[version]; + } + } + + if ('versions' in npmResponse) { + const versions = Object.keys( + npmResponse.versions as Record, + ); + if (versions.length > 0) { + const candidates = versions + .filter((v) => semver.satisfies(v, version)) + .sort(semver.rcompare); + + if (candidates.length > 0) { + return candidates[0]; + } + } + } + + throw new Error( + `Cannot find matching version of ${packageName} to react-native${version}, please provide version manually with --version flag.`, + ); +}; + +// From React Native 0.75 template is not longer inside `react-native` core, +// so we need to map package name (fork) to template name + +const getTemplateNameFromPackageName = (packageName: string) => { + switch (packageName) { + case '@callstack/react-native-visionos': + case 'react-native-visionos': + return '@callstack/visionos-template'; + default: + return packageName; + } +}; + +async function addPlatform( + [packageName]: string[], + {root, reactNativeVersion}: Config, + {version, pm, title}: Options, +) { + if (!packageName) { + throw new CLIError('Please provide package name e.g. react-native-macos'); + } + + const templateName = getTemplateNameFromPackageName(packageName); + const isGitAvailable = await checkGitInstallation(); + + if (isGitAvailable) { + const dirty = await isGitTreeDirty(root); + + if (dirty) { + logger.warn( + 'Your git tree is dirty. We recommend committing or stashing changes first.', + ); + const {proceed} = await prompt({ + type: 'confirm', + name: 'proceed', + message: 'Would you like to proceed?', + }); + + if (!proceed) { + return; + } + + logger.info('Proceeding with the installation'); + } + } + + const projectName = await getAppName(root); + + const matchingVersion = await getPackageMatchingVersion( + packageName, + version ?? reactNativeVersion, + ); + + logger.log( + `Found matching version ${chalk.cyan(matchingVersion)} for ${chalk.cyan( + packageName, + )}`, + ); + + const loader = getLoader({ + text: `Installing ${packageName}@${matchingVersion}`, + }); + + loader.start(); + + try { + await install([`${packageName}@${matchingVersion}`], { + packageManager: pm, + silent: true, + root, + }); + loader.succeed(); + } catch (error) { + loader.fail(); + throw new CLIError( + `Failed to install package ${packageName}@${matchingVersion}`, + (error as Error).message, + ); + } + + loader.start( + `Installing template packages from ${templateName}@0${matchingVersion}`, + ); + + const templateSourceDir = mkdtempSync(join(tmpdir(), 'rncli-init-template-')); + + try { + await installTemplatePackage( + `${templateName}@0${matchingVersion}`, + templateSourceDir, + pm, + ); + loader.succeed(); + } catch (error) { + loader.fail(); + throw new CLIError( + `Failed to install template packages from ${templateName}@0${matchingVersion}`, + (error as Error).message, + ); + } + + loader.start('Copying template files'); + + const templateConfig = getTemplateConfig(templateName, templateSourceDir); + + if (!templateConfig.platforms) { + throw new CLIError( + `Template ${templateName} is missing "platforms" in its "template.config.js"`, + ); + } + + for (const platform of templateConfig.platforms) { + if (existsSync(join(root, platform))) { + loader.fail(); + throw new CLIError( + `Platform ${platform} already exists in the project. Directory ${join( + root, + platform, + )} is not empty.`, + ); + } + + await copyTemplate( + templateName, + templateConfig.templateDir, + templateSourceDir, + platform, + ); + } + + loader.succeed(); + loader.start('Processing template'); + + for (const platform of templateConfig.platforms) { + await changePlaceholderInTemplate({ + projectName, + projectTitle: title, + placeholderName: templateConfig.placeholderName, + placeholderTitle: templateConfig.titlePlaceholder, + projectPath: join(root, platform), + }); + } + + loader.succeed(); + + const {postInitScript} = templateConfig; + if (postInitScript) { + logger.debug('Executing post init script '); + await executePostInitScript( + templateName, + postInitScript, + templateSourceDir, + ); + } +} + +export default addPlatform; diff --git a/packages/cli/src/commands/addPlatform/index.ts b/packages/cli/src/commands/addPlatform/index.ts new file mode 100644 index 000000000..c8de652f1 --- /dev/null +++ b/packages/cli/src/commands/addPlatform/index.ts @@ -0,0 +1,23 @@ +import addPlatform from './addPlatform'; + +export default { + func: addPlatform, + name: 'add-platform [packageName]', + description: 'Add new platform to your React Native project.', + options: [ + { + name: '--version ', + description: 'Pass version of the platform to be added to the project.', + }, + { + name: '--pm ', + description: + 'Use specific package manager to initialize the project. Available options: `yarn`, `npm`, `bun`. Default: `yarn`', + default: 'yarn', + }, + { + name: '--title ', + description: 'Uses a custom app title name for application', + }, + ], +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 306fd5449..6a70b0f27 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -3,11 +3,13 @@ import {commands as cleanCommands} from '@react-native-community/cli-clean'; import {commands as doctorCommands} from '@react-native-community/cli-doctor'; import {commands as configCommands} from '@react-native-community/cli-config'; import init from './init'; +import addPlatform from './addPlatform'; export const projectCommands = [ ...configCommands, cleanCommands.clean, doctorCommands.info, + addPlatform, ] as Command[]; export const detachedCommands = [ diff --git a/packages/cli/src/commands/init/editTemplate.ts b/packages/cli/src/commands/init/editTemplate.ts index d3bb39f23..9692a4387 100644 --- a/packages/cli/src/commands/init/editTemplate.ts +++ b/packages/cli/src/commands/init/editTemplate.ts @@ -13,6 +13,7 @@ interface PlaceholderConfig { placeholderTitle?: string; projectTitle?: string; packageName?: string; + projectPath?: string; } /** @@ -145,11 +146,12 @@ export async function replacePlaceholderWithPackageName({ placeholderName, placeholderTitle, packageName, + projectPath = process.cwd(), }: Omit, 'projectTitle'>) { validatePackageName(packageName); const cleanPackageName = packageName.replace(/[^\p{L}\p{N}.]+/gu, ''); - for (const filePath of walk(process.cwd()).reverse()) { + for (const filePath of walk(projectPath).reverse()) { if (shouldIgnoreFile(filePath)) { continue; } @@ -232,6 +234,7 @@ export async function changePlaceholderInTemplate({ placeholderTitle = DEFAULT_TITLE_PLACEHOLDER, projectTitle = projectName, packageName, + projectPath = process.cwd(), }: PlaceholderConfig) { logger.debug(`Changing ${placeholderName} for ${projectName} in template`); @@ -242,12 +245,13 @@ export async function changePlaceholderInTemplate({ placeholderName, placeholderTitle, packageName, + projectPath, }); } catch (error) { throw new CLIError((error as Error).message); } } else { - for (const filePath of walk(process.cwd()).reverse()) { + for (const filePath of walk(projectPath).reverse()) { if (shouldIgnoreFile(filePath)) { continue; } @@ -269,3 +273,22 @@ export async function changePlaceholderInTemplate({ } } } + +export function getTemplateName(cwd: string) { + // We use package manager to infer the name of the template module for us. + // That's why we get it from temporary package.json, where the name is the + // first and only dependency (hence 0). + let name; + try { + name = Object.keys( + JSON.parse(fs.readFileSync(path.join(cwd, './package.json'), 'utf8')) + .dependencies, + )[0]; + } catch { + throw new CLIError( + 'Failed to read template name from package.json. Please make sure that the template you are using has a valid package.json file.', + ); + } + + return name; +} diff --git a/packages/cli/src/commands/init/git.ts b/packages/cli/src/commands/init/git.ts index 10eee2411..00058dde9 100644 --- a/packages/cli/src/commands/init/git.ts +++ b/packages/cli/src/commands/init/git.ts @@ -68,3 +68,14 @@ export const createGitRepository = async (folder: string) => { ); } }; + +export const isGitTreeDirty = async (folder: string) => { + try { + const {stdout} = await execa('git', ['status', '--porcelain'], { + cwd: folder, + }); + return stdout !== ''; + } catch { + return false; + } +}; diff --git a/packages/cli/src/commands/init/init.ts b/packages/cli/src/commands/init/init.ts index f4da03370..e906945ea 100644 --- a/packages/cli/src/commands/init/init.ts +++ b/packages/cli/src/commands/init/init.ts @@ -19,7 +19,7 @@ import { copyTemplate, executePostInitScript, } from './template'; -import {changePlaceholderInTemplate} from './editTemplate'; +import {changePlaceholderInTemplate, getTemplateName} from './editTemplate'; import * as PackageManager from '../../tools/packageManager'; import banner from './banner'; import TemplateAndVersionError from './errors/TemplateAndVersionError'; @@ -181,17 +181,6 @@ async function setProjectDirectory( return process.cwd(); } -function getTemplateName(cwd: string) { - // We use package manager to infer the name of the template module for us. - // That's why we get it from temporary package.json, where the name is the - // first and only dependency (hence 0). - const name = Object.keys( - JSON.parse(fs.readFileSync(path.join(cwd, './package.json'), 'utf8')) - .dependencies, - )[0]; - return name; -} - //set cache to empty string to prevent installing cocoapods on freshly created project function setEmptyHashForCachedDependencies(projectName: string) { cacheManager.set( diff --git a/packages/cli/src/commands/init/template.ts b/packages/cli/src/commands/init/template.ts index ee2adb537..1fd39ad16 100644 --- a/packages/cli/src/commands/init/template.ts +++ b/packages/cli/src/commands/init/template.ts @@ -1,5 +1,5 @@ import execa from 'execa'; -import path from 'path'; +import path, {join} from 'path'; import {logger, CLIError} from '@react-native-community/cli-tools'; import * as PackageManager from '../../tools/packageManager'; import copyFiles from '../../tools/copyFiles'; @@ -14,6 +14,7 @@ export type TemplateConfig = { templateDir: string; postInitScript?: string; titlePlaceholder?: string; + platforms?: string[]; }; export async function installTemplatePackage( @@ -91,6 +92,7 @@ export async function copyTemplate( templateName: string, templateDir: string, templateSourceDir: string, + platform: string = '', ) { const templatePath = path.resolve( templateSourceDir, @@ -101,7 +103,7 @@ export async function copyTemplate( logger.debug(`Copying template from ${templatePath}`); let regexStr = path.resolve(templatePath, 'node_modules'); - await copyFiles(templatePath, process.cwd(), { + await copyFiles(join(templatePath, platform), join(process.cwd(), platform), { exclude: [new RegExp(replacePathSepForRegex(regexStr))], }); }