Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nxignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
templates/**
3 changes: 3 additions & 0 deletions packages/create-app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ module.exports = [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'],
// TODO: @nx/dependency-checks incorrectly reports unused dependencies
// See: https://github.com/callstack/rnef/issues/8
checkObsoleteDependencies: false,
},
],
},
Expand Down
14 changes: 11 additions & 3 deletions packages/create-app/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
{
"name": "@callstack/create-rnef-app",
"version": "0.0.1",
"type": "commonjs",
"main": "./src/index.js",
"typings": "./src/index.d.ts",
"bin": {
"create-rnef-app": "./src/bin.js"
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"minimist": "^1.2.8",
"tslib": "^2.3.0"
},
"type": "commonjs",
"main": "./src/index.js",
"typings": "./src/index.d.ts"
"devDependencies": {
"@types/minimist": "^1.2.5"
}
}
8 changes: 7 additions & 1 deletion packages/create-app/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/packages/create-app",
"outputPath": "packages/create-app/dist",
"main": "packages/create-app/src/index.ts",
"tsConfig": "packages/create-app/tsconfig.lib.json",
"assets": ["packages/create-app/*.md"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["{projectRoot}/src/*.{ts,js,tsx,jsx}"]
}
},
"nx-release-publish": {
"options": {
"packageRoot": "dist/{projectRoot}"
Expand Down
2 changes: 2 additions & 0 deletions packages/create-app/src/bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
export * from './lib/create-app';
89 changes: 89 additions & 0 deletions packages/create-app/src/lib/__tests__/parsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { parsePackageInfo, parsePackageManagerFromUserAgent } from '../parsers';

describe('parsePackageInfo', () => {
test('handles simple package name: foo', () => {
const result = parsePackageInfo('foo');
expect(result).toEqual({
packageName: 'foo',
targetDir: 'foo',
});
});

test('handles nested package path: foo/bar', () => {
const result = parsePackageInfo('foo/bar');
expect(result).toEqual({
packageName: 'bar',
targetDir: 'foo/bar',
});
});

test('handles scoped package name: @scope/foo', () => {
const result = parsePackageInfo('@scope/foo');
expect(result).toEqual({
packageName: '@scope/foo',
targetDir: '@scope/foo',
});
});

test('handles relative path: ./foo/bar', () => {
const result = parsePackageInfo('./foo/bar');
expect(result).toEqual({
packageName: 'bar',
targetDir: './foo/bar',
});
});

test('handles absolute path: /root/path/to/foo', () => {
const result = parsePackageInfo('/root/path/to/foo');
expect(result).toEqual({
packageName: 'foo',
targetDir: '/root/path/to/foo',
});
});

test('trims trailing slashes: foo/bar/', () => {
const result = parsePackageInfo('foo/bar/');
expect(result).toEqual({
packageName: 'bar',
targetDir: 'foo/bar',
});
});
});

describe('parsePackageManagerFromUserAgent', () => {
it('should return undefined for undefined input', () => {
expect(parsePackageManagerFromUserAgent(undefined)).toBeUndefined();
});

it('should parse npm user agent correctly', () => {
const npmUserAgent = 'npm/10.1.0 node/v20.8.1 linux x64 workspaces/false';
const expected = { name: 'npm', version: '10.1.0' };
expect(parsePackageManagerFromUserAgent(npmUserAgent)).toEqual(expected);
});

it('should parse yarn user agent correctly', () => {
const yarnUserAgent = 'yarn/1.22.21 npm/? node/v20.8.1 linux x64';
const expected = { name: 'yarn', version: '1.22.21' };
expect(parsePackageManagerFromUserAgent(yarnUserAgent)).toEqual(expected);
});

it('should parse pnpm user agent correctly', () => {
const pnpmUserAgent = 'pnpm/8.10.5 npm/? node/v20.8.1 linux x64';
const expected = { name: 'pnpm', version: '8.10.5' };
expect(parsePackageManagerFromUserAgent(pnpmUserAgent)).toEqual(expected);
});

it('should parse bun user agent correctly', () => {
const bunUserAgent = 'bun/0.5.7 (linux-x64) node/v18.0.0';
const expected = { name: 'bun', version: '0.5.7' };
expect(parsePackageManagerFromUserAgent(bunUserAgent)).toEqual(expected);
});

it('should handle unknown package managers', () => {
const unknownUserAgent = 'unknown/1.0.0 node/v16.18.0 linux x64';
const expected = { name: 'unknown', version: '1.0.0' };
expect(parsePackageManagerFromUserAgent(unknownUserAgent)).toEqual(
expected
);
});
});
7 changes: 0 additions & 7 deletions packages/create-app/src/lib/create-app.spec.ts

This file was deleted.

73 changes: 71 additions & 2 deletions packages/create-app/src/lib/create-app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,72 @@
export function createApp(): string {
return 'create-app';
import fs from 'node:fs';
import path from 'node:path';
import editTemplate from './edit-template';
import { parsePackageInfo } from './parsers';
import {
cancelAndExit,
printHelpMessage,
printVersionMessage,
confirmOverrideFiles,
promptProjectName,
printWelcomeMessage,
printByeMessage,
promptTemplate,
} from './prompts';
import { copyDir, isEmptyDir, removeDir, resolveAbsolutePath } from './fs';
import { parseCliOptions } from './parse-cli-options';

const TEMPLATES = ['default'];

async function create() {
const options = parseCliOptions(process.argv.slice(2));

if (options.help) {
printHelpMessage(TEMPLATES);
return;
}

if (options.version) {
printVersionMessage();
return;
}

printWelcomeMessage();

const projectName =
(options.dir || options.name) ?? (await promptProjectName());
const { targetDir } = parsePackageInfo(projectName);
const absoluteTargetDir = resolveAbsolutePath(targetDir);

if (
!options.override &&
fs.existsSync(absoluteTargetDir) &&
!isEmptyDir(absoluteTargetDir)
) {
const confirmOverride = await confirmOverrideFiles(absoluteTargetDir);
if (!confirmOverride) {
cancelAndExit();
}
}

removeDir(absoluteTargetDir);

const templateName = options.template ?? (await promptTemplate(TEMPLATES));
const srcDir = path.join(
__dirname,
// Workaround for getting the template from within the monorepo
// TODO: implement downloading templates from NPM
'../../../../../templates',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: To start with, maybe we can use existing React Native template for compatibility and build on top of that? We want some sort of easy migration path for existing users, so having the ability to use community template sounds like a good idea.

Otherwise, with iOS and android folders here, we add another unnecessary layer of complexity at this point that we could otherwise avoid, at least for now.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can start with RNC template but we would have to do a "migration" step on top of that:

  • it is having references to RN CLI in it's scripts, we would have to replace it with our CLI
  • add our configs
  • potentially remove some RN CLI stuff

Pros:

  • Meta/Community would update template for new RN versions for us
  • Faster start

Cons:

  • Meta/Community changes could break our tool, as they will not care about our use case
  • Less control

`rnef-template-${templateName}`
);

if (!fs.existsSync(srcDir)) {
throw new Error(`Invalid template: template "${templateName}" not found.`);
}

copyDir(srcDir, absoluteTargetDir);
await editTemplate(projectName, absoluteTargetDir);

printByeMessage(absoluteTargetDir);
}

create();
72 changes: 72 additions & 0 deletions packages/create-app/src/lib/edit-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import path from 'node:path';
import fs from 'node:fs';

const DEFAULT_PLACEHOLDER_NAME = 'HelloWorld';

function replaceNameInUTF8File(
filePath: string,
projectName: string,
templateName: string
) {
const fileContent = fs.readFileSync(filePath, 'utf8');
const replacedFileContent = fileContent
.replace(new RegExp(templateName, 'g'), projectName)
.replace(
new RegExp(templateName.toLowerCase(), 'g'),
projectName.toLowerCase()
);

if (fileContent !== replacedFileContent) {
fs.writeFileSync(filePath, replacedFileContent, 'utf8');
}
}

async function renameFile(filePath: string, oldName: string, newName: string) {
const newFileName = path.join(
path.dirname(filePath),
path.basename(filePath).replace(new RegExp(oldName, 'g'), newName)
);

fs.renameSync(filePath, newFileName);
}

function shouldRenameFile(filePath: string, nameToReplace: string) {
return path.basename(filePath).includes(nameToReplace);
}

function walk(current: string): string[] {
if (!fs.lstatSync(current).isDirectory()) {
return [current];
}

const files = fs
.readdirSync(current)
.map((child) => walk(path.join(current, child)));
const result: string[] = [];
return result.concat.apply([current], files);
}


export default async function changePlaceholderInTemplate(
projectName: string,
directory: string,
) {
if (projectName === DEFAULT_PLACEHOLDER_NAME) {
return;
}

for (const filePath of walk(directory).reverse()) {
if (!fs.statSync(filePath).isDirectory()) {
replaceNameInUTF8File(filePath, projectName, DEFAULT_PLACEHOLDER_NAME);
}
if (shouldRenameFile(filePath, DEFAULT_PLACEHOLDER_NAME)) {
await renameFile(filePath, DEFAULT_PLACEHOLDER_NAME, projectName);
} else if (shouldRenameFile(filePath, DEFAULT_PLACEHOLDER_NAME.toLowerCase())) {
await renameFile(
filePath,
DEFAULT_PLACEHOLDER_NAME.toLowerCase(),
projectName.toLowerCase()
);
}
}
}
41 changes: 41 additions & 0 deletions packages/create-app/src/lib/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import fs from 'node:fs';
import nodePath from 'node:path';

export function isEmptyDir(path: string) {
const files = fs.readdirSync(path);
return files.length === 0 || (files.length === 1 && files[0] === '.git');
}

type CopyDirOptions = {
skipFiles?: string[];
};

export function copyDir(
from: string,
to: string,
{ skipFiles = [] }: CopyDirOptions = {}
) {
fs.mkdirSync(to, { recursive: true });

for (const file of fs.readdirSync(from)) {
const srcFile = nodePath.resolve(from, file);
const stat = fs.statSync(srcFile);
const distFile = nodePath.resolve(to, file);

if (stat.isDirectory()) {
copyDir(srcFile, distFile, { skipFiles });
} else {
fs.copyFileSync(srcFile, distFile);
}
}
}

export function removeDir(path: string) {
if (fs.existsSync(path)) {
fs.rmSync(path, { recursive: true });
}
}

export function resolveAbsolutePath(path: string) {
return nodePath.isAbsolute(path) ? path : nodePath.join(process.cwd(), path);
}
25 changes: 25 additions & 0 deletions packages/create-app/src/lib/parse-cli-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import minimist from 'minimist';

export type CliOptions = {
name?: string;
template?: string;
help?: boolean;
version?: boolean;
dir?: string;
override?: boolean;
};

export function parseCliOptions(argv: string[]): CliOptions {
const options = minimist<CliOptions>(argv, {
alias: { h: 'help', d: 'dir', v: 'version' },
});

return {
name: options._[0],
template: options.template,
help: options.help,
version: options.version,
dir: options.dir,
override: options.override,
};
}
Loading