rwsdk-tools
Version:
A collection of utility tools for working with the RWSDK (Redwood SDK)
713 lines (591 loc) âĸ 22.4 kB
JavaScript
// Force the script to run from the project root directory
process.chdir(new URL('../../', import.meta.url).pathname);
/**
* RWSDK Add-on Installer
*
* This script automates the installation of RWSDK add-ons by reading their addon.jsonc file
* and performing the necessary installation steps.
*/
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
// Get the directory name of the current module
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Determine project root - this is important for running the script from any directory
let projectRoot;
try {
// Try to get the absolute path to the project root
projectRoot = path.resolve(__dirname, '../../');
// Verify that the path exists
if (!fs.existsSync(projectRoot)) {
throw new Error(`Project root directory not found: ${projectRoot}`);
}
// Log the project root for debugging
console.log(`Project root: ${projectRoot}`);
} catch (error) {
console.error(`Error determining project root: ${error.message}`);
console.error('Please run this script from within the project directory.');
process.exit(1);
}
// ANSI color codes for console output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
red: '\x1b[31m',
};
/**
* Log a message with emoji and color
*/
function log(emoji, message, color = colors.reset) {
console.log(`${emoji} ${color}${message}${colors.reset}`);
}
/**
* Check if a directory exists, create it if it doesn't
*/
function ensureDirectoryExists(dirPath) {
if (!fs.existsSync(dirPath)) {
log('đ', `Creating directory: ${dirPath}`, colors.yellow);
fs.mkdirSync(dirPath, { recursive: true });
return true;
}
return false;
}
/**
* Run a shell command and log the output
*/
function runCommand(command, cwd = projectRoot) {
log('đ', `Running: ${command}`, colors.blue);
log('đ', `Directory: ${cwd}`, colors.blue);
try {
// Verify the directory exists before attempting to run the command
if (!fs.existsSync(cwd)) {
log('â', `Directory does not exist: ${cwd}`, colors.red);
return false;
}
// Run the command with the specified working directory
execSync(command, { cwd, stdio: 'inherit' });
return true;
} catch (error) {
log('â', `Command failed: ${command}`, colors.red);
console.error(error.message);
return false;
}
}
/**
* Copy a directory recursively
*/
function copyDirectory(source, destination) {
if (!fs.existsSync(source)) {
log('â', `Source directory not found: ${source}`, colors.red);
return false;
}
// Create destination directory if it doesn't exist
if (!fs.existsSync(destination)) {
fs.mkdirSync(destination, { recursive: true });
}
// Read all files and directories in the source
const entries = fs.readdirSync(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
// Recursively copy directory
copyDirectory(sourcePath, destPath);
} else {
// Copy file only if it exists
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, destPath);
} else {
log('â ī¸', `Skipping non-existent file: ${sourcePath}`, colors.yellow);
}
}
}
log('đ', `Copied directory from ${source} to ${destination}`, colors.green);
return true;
}
/**
* Install an add-on from a local directory
*/
function installFromLocal(sourcePath, addonName, destinationDir) {
const sourceAddonDir = path.join(sourcePath, addonName);
const destinationAddonDir = path.join(destinationDir, addonName);
log('đ', `Copying add-on from ${sourceAddonDir} to ${destinationAddonDir}`, colors.blue);
return copyDirectory(sourceAddonDir, destinationAddonDir);
}
/**
* Install an add-on from a GitHub repository
*/
function installFromGitHub(repoUrl, addonName) {
const addonsDir = path.join(projectRoot, 'src/app/addons');
ensureDirectoryExists(addonsDir);
// Clone the repository using degit
const command = `npx degit ${repoUrl} ${addonName}`;
return runCommand(command, addonsDir);
}
/**
* Install npm packages
*/
function installPackages(packages) {
if (!packages || packages.length === 0) return true;
log('đĻ', `Installing packages: ${packages.join(', ')}`, colors.cyan);
return runCommand(`pnpm add ${packages.join(' ')}`);
}
/**
* Add environment variables to .env file
*/
function addEnvVariables(envVars) {
if (!envVars || envVars.length === 0) return true;
const envPath = path.join(projectRoot, '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
let updated = false;
for (const envVar of envVars) {
if (!envContent.includes(`${envVar}=`)) {
envContent += `\n${envVar}=`;
updated = true;
log('đ', `Added ${envVar} to .env file`, colors.yellow);
}
}
if (updated) {
fs.writeFileSync(envPath, envContent);
}
return true;
}
/**
* Inject CSS import into styles file
*/
function injectStyles(styles) {
if (!styles) return true;
const { source, injectInto, injectDirective } = styles;
if (!source || !injectInto || !injectDirective) {
log('â ī¸', 'Missing style information in addon.jsonc', colors.yellow);
return false;
}
const stylesPath = path.join(projectRoot, injectInto);
let stylesContent = '';
if (fs.existsSync(stylesPath)) {
stylesContent = fs.readFileSync(stylesPath, 'utf8');
} else {
ensureDirectoryExists(path.dirname(stylesPath));
}
if (!stylesContent.includes(injectDirective)) {
// Check if we need to handle Tailwind and shadcn imports
const isTailwindImport = stylesContent.includes('@import "tailwindcss";');
const isShadcnImport = stylesContent.includes('@import "tw-animate-css";');
if (stylesContent.trim() === '') {
// Empty file, add imports in the right order
if (!isTailwindImport) {
stylesContent += '@import "tailwindcss";\n';
}
if (!isShadcnImport) {
stylesContent += '@import "tw-animate-css";\n';
}
stylesContent += injectDirective + '\n';
} else {
// File has content, append the directive
stylesContent += '\n' + injectDirective;
}
fs.writeFileSync(stylesPath, stylesContent);
log('đ¨', `Added style import to ${injectInto}`, colors.magenta);
}
return true;
}
/**
* Add routes to worker.tsx
*/
function addRoutes(addonName, routes) {
if (!routes) return true;
const { file, prefix } = routes;
if (!file) {
log('â ī¸', 'Missing routes file information in addon.jsonc', colors.yellow);
return false;
}
const workerPath = path.join(projectRoot, 'src/worker.tsx');
if (!fs.existsSync(workerPath)) {
log('â', 'worker.tsx not found', colors.red);
return false;
}
let workerContent = fs.readFileSync(workerPath, 'utf8');
// Import path for the routes
// Make sure to use the correct file extension (.ts or .tsx)
const fileBase = file.replace(/\.(ts|tsx)$/, '');
const importPath = `./app/addons/${addonName}/${fileBase}`;
const importName = `${addonName.charAt(0).toUpperCase() + addonName.slice(1)}Routes`;
const importStatement = `import ${importName} from "${importPath}";`;
log('đ', `Adding import: ${importStatement}`, colors.blue);
// Check if import already exists
if (!workerContent.includes(importStatement) && !workerContent.includes(`import ${importName} from`)) {
// Find the last import statement
const lastImportIndex = workerContent.lastIndexOf('import ');
const lastImportEndIndex = workerContent.indexOf('\n', lastImportIndex);
if (lastImportIndex !== -1) {
// Insert the new import after the last import
workerContent =
workerContent.slice(0, lastImportEndIndex + 1) +
importStatement + '\n' +
workerContent.slice(lastImportEndIndex + 1);
log('â
', `Added import statement to worker.tsx`, colors.green);
} else {
// No imports found, add at the beginning
workerContent = importStatement + '\n' + workerContent;
log('â
', `Added import statement to worker.tsx`, colors.green);
}
} else {
log('âšī¸', `Import already exists in worker.tsx`, colors.blue);
}
// Add the route to the render function
// First check if the route is already added
const routeCheckPattern = prefix
? new RegExp(`prefix\\(\\s*["']${prefix.replace(/\//g, '\\/')}["']\\s*,\\s*${importName}`)
: new RegExp(`\.\.\.${importName}`);
if (!routeCheckPattern.test(workerContent)) {
// Find the render array
const renderIndex = workerContent.indexOf('render: [');
if (renderIndex !== -1) {
// Find the end of the render array
const renderEndIndex = workerContent.indexOf(']', renderIndex);
if (renderEndIndex !== -1) {
// Prepare the route statement
let routeStatement;
if (prefix) {
routeStatement = ` prefix("${prefix}", ${importName}),\n`;
log('đ', `Adding route with prefix: ${prefix}`, colors.blue);
} else {
routeStatement = ` ...${importName},\n`;
log('đ', `Adding route with spread syntax`, colors.blue);
}
// Insert the route before the end of the array
workerContent =
workerContent.slice(0, renderEndIndex) +
routeStatement +
workerContent.slice(renderEndIndex);
log('â
', `Added routes to worker.tsx render array`, colors.green);
} else {
log('â ī¸', `Could not find end of render array in worker.tsx`, colors.yellow);
}
} else {
log('â ī¸', `Could not find render array in worker.tsx`, colors.yellow);
}
} else {
log('âšī¸', `Routes already added to worker.tsx`, colors.blue);
}
fs.writeFileSync(workerPath, workerContent);
return true;
}
/**
* Prepare Prisma schema directory structure
*/
function preparePrismaSchema() {
const prismaDir = path.join(projectRoot, 'prisma');
const prismaSchemaDir = path.join(prismaDir, 'schema');
const mainSchemaPath = path.join(prismaDir, 'schema.prisma');
const schemaInSchemaDir = path.join(prismaSchemaDir, 'schema.prisma');
// Create prisma directory if it doesn't exist
ensureDirectoryExists(prismaDir);
// Create prisma/schema directory if it doesn't exist
ensureDirectoryExists(prismaSchemaDir);
// Check if prisma/schema/schema.prisma exists
if (!fs.existsSync(schemaInSchemaDir)) {
log('đ', 'Setting up prisma/schema/schema.prisma', colors.yellow);
// Check if prisma/schema.prisma exists
if (fs.existsSync(mainSchemaPath)) {
// Move prisma/schema.prisma to prisma/schema/schema.prisma
log('đ', 'Moving prisma/schema.prisma to prisma/schema/schema.prisma', colors.yellow);
const schemaContent = fs.readFileSync(mainSchemaPath, 'utf8');
fs.writeFileSync(schemaInSchemaDir, schemaContent);
} else {
// Create a basic schema.prisma file
log('đ', 'Creating basic prisma/schema/schema.prisma', colors.yellow);
const basicSchema = `// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
`;
fs.writeFileSync(schemaInSchemaDir, basicSchema);
}
}
return true;
}
/**
* Merge Prisma schema files
*/
function mergePrismaSchema() {
// First prepare the Prisma schema directory structure
preparePrismaSchema();
log('đ', 'Merging Prisma schema files', colors.blue);
const result = runCommand('npx rwsdk-tools merge');
if (result) {
log('đ', 'Creating migration for merged schema', colors.blue);
return runCommand('pnpm migrate:new "merge addon schema"');
}
return result;
}
/**
* Install and run the generate routes script
*/
function generateRoutes() {
log('đ', 'Installing generate routes script', colors.blue);
const installResult = runCommand('npx rwsdk-tools routes');
if (installResult) {
log('đ', 'Generating routes', colors.blue);
return runCommand('pnpm routes');
}
return installResult;
}
/**
* Install ShadCN components
*/
function installShadcnComponents(components) {
if (!components || components.length === 0) return true;
log('đ', 'Installing ShadCN components', colors.blue);
// First ensure shadcn/ui is installed
log('đ', 'Ensuring shadcn/ui is installed', colors.blue);
const shadcnInstalled = runCommand('npx rwsdk-tools shadcn');
if (!shadcnInstalled) {
log('â ī¸', 'Failed to install shadcn/ui base', colors.yellow);
return false;
}
// Install each component
let allSuccess = true;
for (const component of components) {
log('đž', `Installing ShadCN component: ${component}`, colors.blue);
// Check if the component already exists
const componentPath = path.join(projectRoot, 'src/app/components/ui', `${component}.tsx`);
if (fs.existsSync(componentPath)) {
log('âšī¸', `Component ${component} already exists, skipping`, colors.blue);
continue;
}
// Install the component
const success = runCommand(`npx shadcn-ui add ${component}`);
if (!success) {
log('â ī¸', `Failed to install component: ${component}`, colors.yellow);
allSuccess = false;
}
}
return allSuccess;
}
/**
* Main function to install an add-on
*/
async function installAddon(addonPath, sourceAddonPath = null) {
try {
log('đ', `Installing add-on from: ${addonPath}`, colors.green);
// Read the addon.jsonc file from the source project if provided, otherwise from the destination
const addonJsoncPath = sourceAddonPath
? path.join(sourceAddonPath, 'addon.jsonc')
: path.join(addonPath, 'addon.jsonc');
if (!fs.existsSync(addonJsoncPath)) {
log('â', `addon.jsonc not found at ${addonJsoncPath}`, colors.red);
return false;
}
// Parse the addon.jsonc file (handle comments in jsonc)
const addonJsoncContent = fs.readFileSync(addonJsoncPath, 'utf8')
.replace(/\/\/.*$/gm, '') // Remove single-line comments
.replace(/\/\*[\s\S]*?\*\//g, ''); // Remove multi-line comments
const addonConfig = JSON.parse(addonJsoncContent);
const addonName = addonConfig.name || path.basename(addonPath);
log('đ', `Installing add-on: ${addonName}`, colors.green);
// Check if Tailwind is required
if (addonConfig.tailwind) {
log('đ', 'Installing Tailwind', colors.blue);
runCommand('npx rwsdk-tools tailwind');
}
// Check if shadcn/ui is required
if (addonConfig.shadcn) {
if (typeof addonConfig.shadcn === 'boolean') {
// Simple boolean flag
log('đ', 'Installing shadcn/ui', colors.blue);
runCommand('npx rwsdk-tools shadcn');
} else if (addonConfig.shadcn.required) {
// Object with required flag and components list
log('đ', 'Installing shadcn/ui and components', colors.blue);
// Install specific components if listed
if (addonConfig.shadcn.components && addonConfig.shadcn.components.length > 0) {
installShadcnComponents(addonConfig.shadcn.components);
} else {
// Just install the base if no specific components
runCommand('npx rwsdk-tools shadcn');
}
}
}
// Install packages
if (addonConfig.packages) {
installPackages(addonConfig.packages);
}
// Add environment variables
if (addonConfig.env) {
addEnvVariables(addonConfig.env);
}
// Inject styles
if (addonConfig.styles) {
injectStyles(addonConfig.styles);
}
// Add routes
if (addonConfig.routes) {
addRoutes(addonName, addonConfig.routes);
}
// Merge Prisma schema and create migration
mergePrismaSchema();
// Generate routes
generateRoutes();
// Display post-install message
if (addonConfig.postInstallMessage) {
log('â
', addonConfig.postInstallMessage, colors.green);
} else {
log('â
', `Add-on ${addonName} installed successfully`, colors.green);
}
return true;
} catch (error) {
log('â', `Error installing add-on: ${error.message}`, colors.red);
console.error(error);
return false;
}
}
/**
* Install an add-on from a GitHub repository
*/
async function installFromRepo(repoUrl, addonName) {
try {
// Create the addons directory if it doesn't exist
const addonsDir = path.join(projectRoot, 'src/app/addons');
ensureDirectoryExists(addonsDir);
// Store the current project path as the source path
const sourceAddonPath = path.join(projectRoot, 'src/app/addons', addonName);
// Install the add-on from GitHub
const success = installFromGitHub(repoUrl, addonName);
if (!success) {
log('â', 'Failed to install add-on from GitHub', colors.red);
return false;
}
// Install the add-on, passing both the destination path and source path
const destinationAddonPath = path.join(addonsDir, addonName);
return await installAddon(destinationAddonPath, sourceAddonPath);
} catch (error) {
log('â', `Error installing add-on from repo: ${error.message}`, colors.red);
console.error(error);
return false;
}
}
// Parse command line arguments using a simple flag-based approach
const args = process.argv.slice(2);
// Define flags and their values
const flags = {};
let currentFlag = null;
let positionalArgs = [];
// Check if the first argument is 'install' and skip it if it is
let startIndex = 0;
if (args.length > 0 && args[0] === 'install') {
startIndex = 1;
}
// Parse flags and arguments
for (let i = startIndex; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
// This is a flag
currentFlag = arg.slice(2);
flags[currentFlag] = true; // Default to true if no value is provided
} else if (currentFlag) {
// This is a value for the previous flag
flags[currentFlag] = arg;
currentFlag = null;
} else {
// This is a positional argument
positionalArgs.push(arg);
}
}
// Helper function to display usage
function displayUsage() {
log('âšī¸', 'Usage:', colors.blue);
log('', 'install [options] <addonName>', colors.reset);
log('', '', colors.reset);
log('', 'Options:', colors.blue);
log('', ' --repo <url> Install from a GitHub repository', colors.reset);
log('', ' --source <path> Full path to the add-on directory (not its parent)', colors.reset);
log('', ' --dest <path> Destination directory (defaults to src/app/addons)', colors.reset);
log('', ' --help Display this help message', colors.reset);
log('', '', colors.reset);
log('', 'Examples:', colors.green);
log('', ' Install from local directory:', colors.reset);
log('', ' node src/scripts/installAddon.mjs install suggest --source /path/to/addons/suggest', colors.reset);
log('', ' Install from GitHub repository:', colors.reset);
log('', ' node src/scripts/installAddon.mjs install suggest --repo https://github.com/username/repo/addons', colors.reset);
log('', '', colors.reset);
log('', 'Note: The --source flag should point directly to the add-on directory itself, not its parent directory.', colors.cyan);
process.exit(1);
}
// Check for help flag
if (flags.help) {
displayUsage();
}
// Get the add-on name (first positional argument)
const addonName = positionalArgs[0];
if (!addonName) {
log('â', 'Please provide an add-on name', colors.red);
displayUsage();
}
// Default destination directory
const destinationDir = flags.dest || path.join(projectRoot, 'src/app/addons');
ensureDirectoryExists(destinationDir);
// Determine installation type and execute
async function executeInstall() {
try {
if (flags.repo) {
// Install from GitHub repository
const repoUrl = flags.repo;
log('đ', `Installing add-on ${addonName} from repository: ${repoUrl}`, colors.blue);
// Create the addons directory if it doesn't exist
ensureDirectoryExists(destinationDir);
// Install from GitHub
const success = installFromGitHub(`${repoUrl}/${addonName}`, addonName);
if (!success) {
log('â', 'Failed to install add-on from GitHub', colors.red);
process.exit(1);
}
// Continue with installation process
const destinationAddonPath = path.join(destinationDir, addonName);
await installAddon(destinationAddonPath);
} else if (flags.source) {
// Install from local directory
const sourceDir = flags.source;
log('đ', `Installing add-on ${addonName} from local directory: ${sourceDir}`, colors.blue);
// The source path is the add-on directory itself, not a parent directory
const sourceAddonPath = sourceDir;
const destinationAddonPath = path.join(destinationDir, addonName);
if (!fs.existsSync(sourceAddonPath)) {
log('â', `Source add-on directory not found: ${sourceAddonPath}`, colors.red);
process.exit(1);
}
// Copy the files
const copySuccess = copyDirectory(sourceAddonPath, destinationAddonPath);
if (!copySuccess) {
log('â', 'Failed to copy add-on files', colors.red);
process.exit(1);
}
// Continue with installation process
await installAddon(destinationAddonPath, sourceAddonPath);
} else {
log('â', 'Please specify either --repo or --source flag', colors.red);
displayUsage();
}
process.exit(0);
} catch (error) {
log('â', `Error during installation: ${error.message}`, colors.red);
console.error(error);
process.exit(1);
}
}
// Execute the installation
executeInstall();