Skip to content

Commit

Permalink
feat: add add-platform command
Browse files Browse the repository at this point in the history
  • Loading branch information
szymonrybczak committed Feb 14, 2024
1 parent 2602f83 commit fda027b
Show file tree
Hide file tree
Showing 10 changed files with 475 additions and 24 deletions.
18 changes: 12 additions & 6 deletions docs/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
```

Expand All @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@
"find-up": "^4.1.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"
},
"devDependencies": {
"@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",
Expand Down
208 changes: 208 additions & 0 deletions packages/cli/src/commands/addPlatform/addPlatform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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,
getTemplateName,
} from '../init/editTemplate';
import {
copyTemplate,
executePostInitScript,
getTemplateConfig,
installTemplatePackage,
} from '../init/template';
import {tmpdir} from 'os';
import {mkdtempSync} from 'graceful-fs';

type Options = {
packageName: string;
version: string;
pm: PackageManager;
title: string;
};

const NPM_REGISTRY_URL = 'http://registry.npmjs.org'; // TODO: Support local registry

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} = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));

if (!name) {
const appJson = join(root, 'app.json');
if (appJson) {
logger.log(`Reading ${chalk.cyan('name')} from app.json…`);
name = JSON.parse(readFileSync(appJson, 'utf8')).name;
}

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<string, string>;
if (version in distTags) {
return distTags[version];
}
}

if ('versions' in npmResponse) {
const versions = Object.keys(
npmResponse.versions as Record<string, unknown>,
);
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.`,
);
};

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 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 (e) {
loader.fail();
throw e;
}

loader.start('Copying template files');
const templateSourceDir = mkdtempSync(join(tmpdir(), 'rncli-init-template-'));
await installTemplatePackage(
`${packageName}@${matchingVersion}`,
templateSourceDir,
pm,
);

const templateName = getTemplateName(templateSourceDir);
const templateConfig = getTemplateConfig(templateName, templateSourceDir);

if (!templateConfig.platformName) {
throw new CLIError(
`Template ${templateName} is missing platformName in its template.config.js`,
);
}

await copyTemplate(
templateName,
templateConfig.templateDir,
templateSourceDir,
templateConfig.platformName,
);

loader.succeed();
loader.start('Processing template');

await changePlaceholderInTemplate({
projectName,
projectTitle: title,
placeholderName: templateConfig.placeholderName,
placeholderTitle: templateConfig.titlePlaceholder,
projectPath: join(root, templateConfig.platformName),
});

loader.succeed();

const {postInitScript} = templateConfig;
if (postInitScript) {
logger.debug('Executing post init script ');
await executePostInitScript(
templateName,
postInitScript,
templateSourceDir,
);
}
}

export default addPlatform;
23 changes: 23 additions & 0 deletions packages/cli/src/commands/addPlatform/index.ts
Original file line number Diff line number Diff line change
@@ -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 <string>',
description: 'Pass version of the platform to be added to the project.',
},
{
name: '--pm <string>',
description:
'Use specific package manager to initialize the project. Available options: `yarn`, `npm`, `bun`. Default: `yarn`',
default: 'yarn',
},
{
name: '--title <string>',
description: 'Uses a custom app title name for application',
},
],
};
2 changes: 2 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {commands as configCommands} from '@react-native-community/cli-config';
import profileHermes from '@react-native-community/cli-hermes';
import upgrade from './upgrade/upgrade';
import init from './init';
import addPlatform from './addPlatform';

export const projectCommands = [
...configCommands,
cleanCommands.clean,
doctorCommands.info,
upgrade,
profileHermes,
addPlatform,
] as Command[];

export const detachedCommands = [
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/commands/init/editTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface PlaceholderConfig {
placeholderTitle?: string;
projectTitle?: string;
packageName?: string;
projectPath?: string;
}

/**
Expand Down Expand Up @@ -145,11 +146,12 @@ export async function replacePlaceholderWithPackageName({
placeholderName,
placeholderTitle,
packageName,
projectPath,
}: Omit<Required<PlaceholderConfig>, '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;
}
Expand Down Expand Up @@ -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`);

Expand All @@ -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;
}
Expand All @@ -269,3 +273,14 @@ 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).
const name = Object.keys(
JSON.parse(fs.readFileSync(path.join(cwd, './package.json'), 'utf8'))
.dependencies,
)[0];
return name;
}
11 changes: 11 additions & 0 deletions packages/cli/src/commands/init/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
13 changes: 1 addition & 12 deletions packages/cli/src/commands/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -169,17 +169,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(
Expand Down
Loading

0 comments on commit fda027b

Please sign in to comment.