create-netsuite-react-app
Version:
CLI tool to create a production-ready NetSuite React TypeScript app with full API integration
298 lines (254 loc) • 11.3 kB
JavaScript
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import chalk from 'chalk';
import inquirer from 'inquirer';
import ora from 'ora';
import {
checkDirectoryExists,
createDirectoryIfNotExists,
copyTemplateFiles,
replaceInFile
} from './utils.js';
// Get __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_PROJECT_NAME = 'ns_react_app_template';
const DEFAULT_DISPLAY_NAME = 'NetSuite React App Template';
export async function createApp(projectDirectory, options = {}) {
const { verbose = false } = options;
// Create the absolute path for the new project
const targetDir = path.resolve(process.cwd(), projectDirectory);
// Check if target directory exists and if it's not empty
const dirCheck = checkDirectoryExists(targetDir);
if (dirCheck.exists && !dirCheck.empty) {
const { proceed } = await inquirer.prompt({
type: 'confirm',
name: 'proceed',
message: `Directory ${projectDirectory} already exists and is not empty. Continue?`,
default: false
});
if (!proceed) {
console.log(chalk.yellow('Operation cancelled.'));
process.exit(0);
}
}
// Create the directory if it doesn't exist
createDirectoryIfNotExists(targetDir);
// Get project configuration details
const answers = await inquirer.prompt([
{
type: 'input',
name: 'displayName',
message: 'Enter display name for your app:',
default: projectDirectory.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
},
{
type: 'input',
name: 'idPrependValue',
message: 'Enter ID prepend value (optional, e.g., "edr" → customscript_edr_project_name):',
default: '',
validate: (input) => {
if (!input) return true; // Allow empty
if (!/^[a-z0-9]+$/.test(input)) {
return 'Prepend value must be lowercase letters and numbers only.';
}
return true;
}
}
]);
const { displayName, idPrependValue } = answers;
// Try different paths to find the template directory
let templateDir;
const possibleTemplatePaths = [
path.resolve(__dirname, '../../template'), // Local development path
path.resolve(__dirname, '../template'), // Published package path
path.resolve(__dirname, '../../../template'), // Global installed path
path.join(process.cwd(), 'node_modules', 'create-netsuite-react-app', 'template') // Installed in node_modules
];
for (const potentialPath of possibleTemplatePaths) {
if (verbose) {
console.log(`Checking for template at: ${potentialPath}`);
}
if (fs.existsSync(potentialPath)) {
templateDir = potentialPath;
if (verbose) {
console.log(`Found template at: ${templateDir}`);
}
break;
}
}
if (!templateDir) {
console.error(chalk.red('Error: Template directory not found.'));
console.error(chalk.yellow('Searched in:'));
possibleTemplatePaths.forEach(p => console.error(chalk.yellow(`- ${p}`)));
throw new Error('Template directory not found. The package may be corrupted.');
}
// Copy the template to the target directory
const copySpinner = ora('Copying template files...').start();
try {
await copyTemplateFiles(templateDir, targetDir);
// Ensure .gitignore is created (npm may ignore it during packaging)
const gitignoreTarget = path.join(targetDir, '.gitignore');
const gitignoreTemplate = path.join(templateDir, 'gitignore.template');
const gitignoreSource = path.join(templateDir, '.gitignore');
if (!fs.existsSync(gitignoreTarget)) {
if (fs.existsSync(gitignoreSource)) {
await fs.copy(gitignoreSource, gitignoreTarget);
} else if (fs.existsSync(gitignoreTemplate)) {
await fs.copy(gitignoreTemplate, gitignoreTarget);
}
}
copySpinner.succeed('Template files copied successfully');
} catch (error) {
copySpinner.fail(`Failed to copy template: ${error.message || error}`);
throw error;
}
// Update project configuration
const updateSpinner = ora('Updating project configuration...').start();
try {
// Create basic project.json for SuiteCloud (without custom fields to avoid conflicts)
const configPath = path.join(targetDir, 'project.json');
const projectConfig = {
defaultAuthId: ""
};
if (verbose) {
console.log(`Writing basic project.json for SuiteCloud:`, projectConfig);
}
try {
await fs.writeFile(
configPath,
JSON.stringify(projectConfig, null, 2)
);
if (verbose) {
console.log(`✓ Created project.json at ${configPath}`);
}
} catch (error) {
console.error(`❌ Error writing project.json:`, error);
throw error;
}
// Update files with placeholders
const filesToUpdate = [
{
path: path.join(targetDir, 'reactApp', 'index.html'),
replacements: [
['<title>.*?</title>', `<title>${displayName}</title>`]
]
},
{
path: path.join(targetDir, 'reactApp', 'src', 'App.tsx'),
replacements: [
[/<h1>.*?<\/h1>/, `<h1>${displayName}</h1>`]
]
},
{
path: path.join(targetDir, 'package.json'),
replacements: [
['"name":\\s*".*?"', `"name": "${projectDirectory}"`],
['"description":\\s*".*?"', `"description": "${displayName}"`],
// Add idPrependValue if provided
...(idPrependValue ? [['"license":\\s*".*?"', `"idPrependValue": "${idPrependValue}",\n "license": "ISC"`]] : [])
]
},
{
path: path.join(targetDir, 'vite.config.ts'),
replacements: [
[/const buildRootFolderPath = '\.\/FileCabinet\/SuiteScripts\/.*?';/, `const buildRootFolderPath = './FileCabinet/SuiteScripts/${projectDirectory}';`]
]
},
// Update NetSuite manifest with the project name
{
path: path.join(targetDir, 'manifest.xml'),
replacements: [
[/<projectname>.*?<\/projectname>/, `<projectname>${projectDirectory}</projectname>`]
]
},
// Update script deployment files to use the new project name
{
path: path.join(targetDir, 'Objects', 'customscript_ns_react_app_template.xml'),
replacements: (() => {
const scriptIdBase = idPrependValue ? `${idPrependValue}_${projectDirectory}` : projectDirectory;
return [
[/customscript_ns_react_app_template/g, `customscript_${scriptIdBase}`],
[/customdeploy_ns_react_app_template/g, `customdeploy_${scriptIdBase}`],
[/NS React App Template/g, displayName], // Fixed: Changed from "NetSuite React App Template"
[/\/SuiteScripts\/ns_react_app_template\//g, `/SuiteScripts/${projectDirectory}/`]
];
})()
}
];
for (const file of filesToUpdate) {
const updated = await replaceInFile(file.path, file.replacements);
if (verbose) {
console.log(`${updated ? '✓' : '⚠'} ${file.path}: ${updated ? 'Updated' : 'No changes made'}`);
// For package.json, verify the content after replacement
if (file.path.includes('package.json') && updated) {
try {
const verifyContent = await fs.readFile(file.path, 'utf8');
const packageData = JSON.parse(verifyContent);
console.log(`✓ Verified package.json name: "${packageData.name}", description: "${packageData.description}"`);
} catch (error) {
console.error(`❌ Error verifying package.json:`, error);
}
}
}
}
// Rename Object XML file to match the new project name (with prepend if provided)
const scriptIdBase = idPrependValue ? `${idPrependValue}_${projectDirectory}` : projectDirectory;
const oldXmlPath = path.join(targetDir, 'Objects', 'customscript_ns_react_app_template.xml');
const newXmlPath = path.join(targetDir, 'Objects', `customscript_${scriptIdBase}.xml`);
if (fs.existsSync(oldXmlPath)) {
await fs.rename(oldXmlPath, newXmlPath);
}
// Rename SuiteScript directories
const oldScriptDir = path.join(targetDir, 'FileCabinet', 'SuiteScripts', 'ns_react_app_template');
const newScriptDir = path.join(targetDir, 'FileCabinet', 'SuiteScripts', projectDirectory);
if (fs.existsSync(oldScriptDir)) {
await fs.rename(oldScriptDir, newScriptDir);
// Update the react_app_handler.js file content (keep the same filename)
const handlerPath = path.join(newScriptDir, 'react_app_handler.js');
if (fs.existsSync(handlerPath)) {
// Update references inside the file
let handlerContent = fs.readFileSync(handlerPath, 'utf8');
handlerContent = handlerContent.replace(/FRONTEND_APP_FOLDER_NAME: ['"].*?['"]/, `FRONTEND_APP_FOLDER_NAME: '${projectDirectory}'`);
handlerContent = handlerContent.replace(/FRONTEND_APP_TITLE: ['"].*?['"]/, `FRONTEND_APP_TITLE: '${displayName}'`);
handlerContent = handlerContent.replace(/FRONTEND_APP_ID: ['"].*?['"]/, `FRONTEND_APP_ID: '${projectDirectory}'`);
handlerContent = handlerContent.replace(/const TAG = ['"].*?['"]/, `const TAG = '${projectDirectory.toUpperCase()}'`);
fs.writeFileSync(handlerPath, handlerContent);
}
// Update any template scripts in the scripts directory to use the project name
const scriptsDir = path.join(newScriptDir, 'scripts');
const libDir = path.join(newScriptDir, 'lib');
const configDir = path.join(newScriptDir, 'config');
// Update constants.js with project-specific values
const constantsPath = path.join(configDir, 'constants.js');
if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
constantsContent = constantsContent.replace(/APP_NAME: 'ns_react_app_template'/, `APP_NAME: '${projectDirectory}'`);
fs.writeFileSync(constantsPath, constantsContent);
}
}
updateSpinner.succeed('Project configuration updated successfully');
} catch (error) {
updateSpinner.fail(`Failed to update project configuration: ${error.message || error}`);
throw error;
}
// Success message
const scriptIdBase = idPrependValue ? `${idPrependValue}_${projectDirectory}` : projectDirectory;
console.log(`
${chalk.green('Success!')} Created ${projectDirectory} at ${targetDir}
${chalk.yellow('✓')} .gitignore file included for proper version control
${chalk.yellow('✓')} NetSuite project structure ready for deployment
${chalk.yellow('✓')} React development environment configured
${idPrependValue ? chalk.yellow(`✓ Script IDs will use prepend: customscript_${scriptIdBase}`) : ''}
We suggest that you begin by typing:
${chalk.cyan(`cd ${projectDirectory}`)}
${chalk.cyan('npm install')}
${chalk.cyan('npm run dev')}
To configure your project, run:
${chalk.cyan('npm run setup')}
${chalk.red('IMPORTANT:')} Before deploying to NetSuite:
${chalk.cyan('npm run build')} - Build the React app first!
Happy coding!
`);
}