Skip to content

Commit a3cd9cd

Browse files
authored
Merge pull request #294 from unisol1020/main
Add init command for project setup
2 parents 89af21d + 0db61d8 commit a3cd9cd

File tree

8 files changed

+544
-95
lines changed

8 files changed

+544
-95
lines changed

apps/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@react-native-reusables/cli",
3-
"version": "0.1.2",
3+
"version": "0.2.2",
44
"description": "Add react-native-reusables to your project.",
55
"publishConfig": {
66
"access": "public"
@@ -72,6 +72,7 @@
7272
},
7373
"devDependencies": {
7474
"@rnr/reusables": "workspace:*",
75+
"@rnr/starter-base": "workspace:*",
7576
"@types/babel__core": "^7.20.1",
7677
"@types/diff": "^5.0.3",
7778
"@types/fs-extra": "^11.0.1",

apps/cli/scripts/generate-source-files.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
import { existsSync, promises as fs } from 'fs';
44
import path from 'path';
55
import { COMPONENTS } from '../src/items/components';
6+
import { TEMPLATES } from '../src/items/templates';
67

78
async function main() {
9+
for (const template of TEMPLATES) {
10+
await copyFolder(template.path);
11+
}
812
for (const comp of COMPONENTS) {
913
if (Array.isArray(comp.paths)) {
1014
await writeFiles(comp.paths);
@@ -30,3 +34,33 @@ async function writeFiles(paths: Array<{ from: string; to: { folder: string; fil
3034
}
3135
}
3236
}
37+
38+
async function copyFolder(src: string, destPath?: string) {
39+
if (!existsSync(src)) {
40+
throw new Error(`Source folder does not exist: ${src}`);
41+
}
42+
43+
const paths = src.split('/');
44+
const folderName = paths[paths.length - 1];
45+
46+
const dest = destPath ?? path.join('__generated', folderName);
47+
48+
if (!existsSync(dest)) {
49+
await fs.mkdir(dest, { recursive: true });
50+
}
51+
52+
const entries = await fs.readdir(src, { withFileTypes: true });
53+
54+
for (const entry of entries) {
55+
const srcPath = path.join(src, entry.name);
56+
const destPath = path.join(dest, entry.name);
57+
58+
if (entry.isDirectory()) {
59+
// Recursively copy subdirectories
60+
await copyFolder(srcPath, destPath);
61+
} else if (entry.isFile()) {
62+
// Copy files
63+
await fs.copyFile(srcPath, destPath);
64+
}
65+
}
66+
}

apps/cli/src/commands/init.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import {
2+
DEFAULT_COMPONENTS,
3+
DEFAULT_LIB,
4+
getConfig,
5+
rawConfigSchema,
6+
resolveConfigPaths,
7+
} from '@/src/utils/get-config';
8+
import { handleError } from '@/src/utils/handle-error';
9+
import { logger } from '@/src/utils/logger';
10+
import chalk from 'chalk';
11+
import { execSync } from 'child_process';
12+
import { Command } from 'commander';
13+
import { execa } from 'execa';
14+
import glob from 'fast-glob';
15+
import { existsSync, promises as fs } from 'fs';
16+
import ora, { Ora } from 'ora';
17+
import path from 'path';
18+
import prompts from 'prompts';
19+
import { fileURLToPath } from 'url';
20+
import { z } from 'zod';
21+
22+
const filePath = fileURLToPath(import.meta.url);
23+
const fileDir = path.dirname(filePath);
24+
25+
const initOptionsSchema = z.object({
26+
cwd: z.string(),
27+
overwrite: z.boolean(),
28+
});
29+
30+
const REQUIRED_DEPENDENCIES = [
31+
'nativewind',
32+
'expo-navigation-bar',
33+
'tailwindcss-animate',
34+
'class-variance-authority',
35+
'clsx',
36+
'tailwind-merge',
37+
'react-native-svg',
38+
'lucide-react-native',
39+
'@rn-primitives/portal',
40+
] as const;
41+
42+
const TEMPLATE_FILES = [
43+
'tailwind.config.js',
44+
'nativewind-env.d.ts',
45+
'global.css',
46+
'babel.config.js',
47+
'metro.config.js',
48+
'lib/utils.ts',
49+
'lib/useColorScheme.tsx',
50+
'lib/constants.ts',
51+
'lib/android-navigation-bar.ts',
52+
'lib/icons/iconWithClassName.ts',
53+
] as const;
54+
55+
async function installDependencies(cwd: string, spinner: Ora) {
56+
try {
57+
spinner.text = 'Installing dependencies...';
58+
await execa('npx', ['expo', 'install', ...REQUIRED_DEPENDENCIES], {
59+
cwd,
60+
stdio: 'inherit',
61+
});
62+
spinner.text = 'Dependencies installed successfully';
63+
} catch (error) {
64+
spinner.fail('Failed to install dependencies');
65+
handleError(error);
66+
process.exit(1);
67+
}
68+
}
69+
70+
async function promptForConfig(cwd: string) {
71+
const highlight = (text: string) => chalk.cyan(text);
72+
73+
try {
74+
const options = await prompts([
75+
{
76+
type: 'text',
77+
name: 'components',
78+
message: `Configure the import alias for ${highlight('components')}:`,
79+
initial: DEFAULT_COMPONENTS,
80+
},
81+
{
82+
type: 'text',
83+
name: 'lib',
84+
message: `Configure the import alias for ${highlight('lib')}:`,
85+
initial: DEFAULT_LIB,
86+
},
87+
]);
88+
89+
const components = options.components || DEFAULT_COMPONENTS;
90+
const lib = options.lib || DEFAULT_LIB;
91+
92+
const config = rawConfigSchema.parse({
93+
aliases: {
94+
components,
95+
lib,
96+
},
97+
});
98+
99+
const { proceed } = await prompts({
100+
type: 'confirm',
101+
name: 'proceed',
102+
message: `Write configuration to ${highlight('components.json')}. Proceed?`,
103+
initial: true,
104+
});
105+
106+
if (proceed) {
107+
logger.info('');
108+
const spinner = ora(`Writing components.json...`).start();
109+
const targetPath = path.resolve(cwd, 'components.json');
110+
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), 'utf8');
111+
spinner.succeed();
112+
}
113+
114+
return await resolveConfigPaths(cwd, config);
115+
} catch (error) {
116+
logger.error('Failed to configure project.');
117+
process.exit(1);
118+
}
119+
}
120+
121+
const NON_PATH_ALIAS_BASES = ['', '.', '/'];
122+
123+
async function updateTsConfig(cwd: string, config: any, spinner: Ora) {
124+
try {
125+
const tsconfigPath = path.join(cwd, 'tsconfig.json');
126+
const tsconfig = existsSync(tsconfigPath)
127+
? JSON.parse(await fs.readFile(tsconfigPath, 'utf8'))
128+
: {};
129+
130+
const componentBase = config.aliases.components.split('/')[0];
131+
const libBase = config.aliases.lib.split('/')[0];
132+
133+
if (NON_PATH_ALIAS_BASES.includes(componentBase) || NON_PATH_ALIAS_BASES.includes(libBase)) {
134+
return;
135+
}
136+
137+
const tsconfigPaths = tsconfig.compilerOptions?.paths ?? {};
138+
139+
if (
140+
tsconfigPaths[`${componentBase}/*`]?.[0] === '*' &&
141+
tsconfigPaths[`${libBase}/*`]?.[0] === '*'
142+
) {
143+
spinner.succeed('Path aliases already configured');
144+
return;
145+
}
146+
147+
spinner.text = 'Updating path aliases...';
148+
149+
tsconfig.compilerOptions = {
150+
...tsconfig.compilerOptions,
151+
baseUrl: '.',
152+
paths: {
153+
[`${componentBase}/*`]: ['*'],
154+
[`${libBase}/*`]: ['*'],
155+
...tsconfig.compilerOptions?.paths,
156+
},
157+
};
158+
159+
await fs.writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2));
160+
} catch (error) {
161+
spinner.fail('Failed to update tsconfig.json');
162+
handleError(error);
163+
}
164+
}
165+
166+
async function copyTemplateFile(
167+
file: string,
168+
templatesDir: string,
169+
targetDir: string,
170+
spinner: Ora,
171+
overwriteFlag: boolean
172+
) {
173+
const targetPath = path.join(targetDir, file);
174+
spinner.stop();
175+
176+
if (existsSync(targetPath)) {
177+
if (!overwriteFlag) {
178+
logger.info(
179+
`File already exists: ${chalk.bgCyan(
180+
file
181+
)} was skipped. To overwrite, run with the ${chalk.green('--overwrite')} flag.`
182+
);
183+
return;
184+
}
185+
186+
const { overwrite } = await prompts({
187+
type: 'confirm',
188+
name: 'overwrite',
189+
message: `File already exists: ${chalk.yellow(file)}. Would you like to overwrite?`,
190+
initial: false,
191+
});
192+
193+
if (!overwrite) {
194+
logger.info(`Skipped`);
195+
return;
196+
}
197+
}
198+
199+
spinner.start(`Installing ${file}...`);
200+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
201+
await fs.copyFile(path.join(templatesDir, file), targetPath);
202+
}
203+
204+
async function updateLayoutFile(cwd: string, spinner: Ora) {
205+
try {
206+
const layoutFiles = await glob(
207+
['app/_layout.{ts,tsx,js,jsx}', '(app)/_layout.{ts,tsx,js,jsx}'],
208+
{
209+
cwd,
210+
ignore: ['node_modules/**'],
211+
}
212+
);
213+
214+
if (!layoutFiles.length) {
215+
spinner.warn('Could not find the root _layout file');
216+
return;
217+
}
218+
219+
const layoutPath = path.join(cwd, layoutFiles[0]);
220+
const content = await fs.readFile(layoutPath, 'utf8');
221+
222+
if (!content.includes('import "../global.css"')) {
223+
spinner.text = 'Updating layout file...';
224+
await fs.writeFile(layoutPath, `import "../global.css";\n${content}`);
225+
spinner.succeed(`Updated ${layoutFiles[0]} with global CSS import`);
226+
}
227+
} catch (error) {
228+
spinner.fail('Failed to update layout file');
229+
handleError(error);
230+
}
231+
}
232+
233+
async function shouldPromptGitWarning(cwd: string): Promise<boolean> {
234+
try {
235+
execSync('git rev-parse --is-inside-work-tree', { cwd });
236+
const status = execSync('git status --porcelain', { cwd }).toString();
237+
return !!status;
238+
} catch (error) {
239+
return false;
240+
}
241+
}
242+
243+
async function validateProjectDirectory(cwd: string) {
244+
if (!existsSync(cwd)) {
245+
logger.error(`The path ${cwd} does not exist. Please try again.`);
246+
process.exit(1);
247+
}
248+
249+
if (!existsSync(path.join(cwd, 'package.json'))) {
250+
logger.error(
251+
'No package.json found. Please run this command in a React Native project directory.'
252+
);
253+
process.exit(1);
254+
}
255+
}
256+
257+
async function checkGitStatus(cwd: string) {
258+
if (await shouldPromptGitWarning(cwd)) {
259+
const { proceed } = await prompts({
260+
type: 'confirm',
261+
name: 'proceed',
262+
message:
263+
'The Git repository is dirty (uncommitted changes). It is recommended to commit your changes before proceeding. Do you want to continue?',
264+
initial: false,
265+
});
266+
267+
if (!proceed) {
268+
logger.info('Installation cancelled.');
269+
process.exit(0);
270+
}
271+
}
272+
}
273+
274+
async function initializeProject(cwd: string, overwrite: boolean) {
275+
const spinner = ora(`Initializing project...`).start();
276+
277+
try {
278+
let config = await getConfig(cwd);
279+
280+
if (!config) {
281+
spinner.stop();
282+
config = await promptForConfig(cwd);
283+
spinner.start();
284+
}
285+
286+
const templatesDir = path.join(fileDir, '../__generated/starter-base');
287+
288+
await installDependencies(cwd, spinner);
289+
await updateTsConfig(cwd, config, spinner);
290+
291+
spinner.text = 'Adding config and utility files...';
292+
for (const file of TEMPLATE_FILES) {
293+
await copyTemplateFile(file, templatesDir, cwd, spinner, overwrite);
294+
}
295+
296+
await updateLayoutFile(cwd, spinner);
297+
298+
spinner.succeed('Initialization completed successfully!');
299+
} catch (error) {
300+
spinner.fail('Initialization failed');
301+
handleError(error);
302+
process.exit(1);
303+
}
304+
}
305+
306+
export const init = new Command()
307+
.name('init')
308+
.description('Initialize the required configuration for your React Native project')
309+
.option(
310+
'-c, --cwd <cwd>',
311+
'the working directory. defaults to the current directory.',
312+
process.cwd()
313+
)
314+
.option('-o, --overwrite', 'overwrite existing files', false)
315+
.action(async (opts) => {
316+
try {
317+
const options = initOptionsSchema.parse(opts);
318+
const cwd = path.resolve(options.cwd);
319+
320+
await validateProjectDirectory(cwd);
321+
await checkGitStatus(cwd);
322+
await initializeProject(cwd, options.overwrite);
323+
} catch (error) {
324+
handleError(error);
325+
}
326+
});

0 commit comments

Comments
 (0)