diff --git a/package-lock.json b/package-lock.json index 40b09fd..df66d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1487,6 +1487,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@clack/core": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.0.tgz", + "integrity": "sha512-YJCYBsyJfNDaTbvDUVSJ3SgSuPrcujarRgkJ5NLjexDZKvaOiVVJvAQYx8lIgG0qRT8ff0fPgqyBCVivanIZ+A==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.9.0.tgz", + "integrity": "sha512-nGsytiExgUr4FL0pR/LeqxA28nz3E0cW7eLTSh3Iod9TGrbBt8Y7BHbV3mmkNC4G0evdYyQ3ZsbiBkk7ektArA==", + "license": "MIT", + "dependencies": { + "@clack/core": "0.4.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "license": "MIT", @@ -2667,6 +2688,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@lerna/create": { "version": "8.1.9", "dev": true, @@ -15603,8 +15639,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "dev": true, + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -16963,6 +17000,21 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/simple-git": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz", + "integrity": "sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "license": "MIT", @@ -16976,7 +17028,6 @@ }, "node_modules/sisteransi": { "version": "1.0.5", - "dev": true, "license": "MIT" }, "node_modules/slash": { @@ -18977,15 +19028,18 @@ "version": "0.0.7", "license": "MIT", "dependencies": { + "@clack/prompts": "^0.9.0", "@swc/cli": "^0.5.2", "@swc/core": "^1.10.0", "chokidar": "^3.5.1", "commander": "^12.1.0", - "enquirer": "^2.4.1", "fs-extra": "^11.2.0", "picocolors": "^1.1.0", + "prettier": "^3.4.2", "radash": "^12.1.0", + "simple-git": "^3.27.0", "tree-kill": "^1.2.2", + "ts-morph": "^25.0.0", "typescript": "^5.6.2" }, "bin": { @@ -19046,6 +19100,17 @@ "node": ">= 12" } }, + "packages/cli/node_modules/@ts-morph/common": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.0.tgz", + "integrity": "sha512-/RmKAtctStXqM5nECMQ46duT74Hoig/DBzhWXGHcodlDNrgRbsbwwHqSKFNbca6z9Xt/CUWMeXOsC9QEN1+rqw==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1" + } + }, "packages/cli/node_modules/@types/node": { "version": "22.9.0", "dev": true, @@ -19084,17 +19149,6 @@ "fsevents": "~2.3.1" } }, - "packages/cli/node_modules/enquirer": { - "version": "2.4.1", - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, "packages/cli/node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -19143,9 +19197,19 @@ "node": ">= 8" } }, + "packages/cli/node_modules/ts-morph": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.0.tgz", + "integrity": "sha512-ERPTUVO5qF8cEGJgAejGOsCVlbk8d0SDyiJsucKQT5XgqoZslv0Qml+gnui6Yy6o+uQqw5SestyW2HvlVtT/Sg==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.26.0", + "code-block-writer": "^13.0.3" + } + }, "packages/core": { "name": "@intentjs/core", - "version": "0.1.38", + "version": "0.1.40", "license": "MIT", "dependencies": { "@intentjs/hyper-express": "^0.0.5", diff --git a/packages/cli/bin/intent.ts b/packages/cli/bin/intent.ts index 5aa9199..3dd3f85 100644 --- a/packages/cli/bin/intent.ts +++ b/packages/cli/bin/intent.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node -import { program } from "commander"; +import { Option, program } from "commander"; import { StartServerCommand } from "../commands/start-server"; import { BuildCommand } from "../commands/build"; +import { NEW_PROJECT_OPTIONS } from "../lib/configuration/new-project-config"; +import { NewProjectCommand } from "../commands/new-project"; program .command("start") @@ -40,4 +42,21 @@ program const buildCommand = new BuildCommand(); buildCommand.handle(str); }); + +const newProjectProgram = program + .command("new") + .description("Command to initiailize a new project") + .argument("name", "Name of the project") + .option("--default", "Uses default configuration"); + +Object.values(NEW_PROJECT_OPTIONS).map((n) => + newProjectProgram.addOption( + new Option(n.option, n.description).choices(n.choices) + ) +); + +newProjectProgram.action(async (name, options) => { + const command = new NewProjectCommand(); + await command.handle(name, options); +}); program.parseAsync(); diff --git a/packages/cli/commands/new-project.ts b/packages/cli/commands/new-project.ts new file mode 100644 index 0000000..a17e997 --- /dev/null +++ b/packages/cli/commands/new-project.ts @@ -0,0 +1,231 @@ +import { join } from "path"; +import { INTENT_LOG_PREFIX } from "../lib/utils/log-helpers"; +import pc from "picocolors"; +import { copyFile, existsSync, remove } from "fs-extra"; +import * as p from "@clack/prompts"; +import { NEW_PROJECT_OPTIONS } from "../lib/configuration/new-project-config"; +import { + NEW_PROJECT_CONFIG, + SAFE_DELETE_FILES, +} from "../lib/new-project/config"; +import { InjectConfigCodegen } from "../lib/codegen/inject-config"; +import { cwd } from "process"; +import { downloadRepository } from "../lib/new-project/actions/download-helper"; +import { downloadDependenciesUsingNpm } from "../lib/new-project/actions/download-depedencies"; +import picocolors from "picocolors"; + +export class NewProjectCommand { + constructor() {} + + async handle(name: string, options: Record) { + /** + * Check if provided name is available for creating a directory + */ + this.checkIfDirNameIsValidAndAvailable(name); + + /** + * Build Prompt Options + */ + const promptOptions = this.buildPromptOptions(options); + + p.intro("Setting up a new intent project"); + const missingOptions = await p.group( + { ...promptOptions }, + { + onCancel: ({ results }) => { + p.log.error("User cancelled prompt! Exiting now..."); + process.exit(0); + }, + } + ); + + const config = NEW_PROJECT_CONFIG; + const starterTemplateName = `${config.gitOrg}/${config.repoName}`; + + const newProjectDirectory = join(cwd(), name); + + const finalOptions = { ...options, ...missingOptions }; + const injectConfigTask = new InjectConfigCodegen(newProjectDirectory); + await p.tasks([ + { + title: "Downloading the starter template", + task: async (message) => { + const success = await downloadRepository(starterTemplateName, name); + !success && process.exit(0); + return "Starter template cloned! 🎉"; + }, + }, + { + title: "Cleaning up files", + task: async (message) => { + const deleteFilesPromise = []; + for (const dirOrFileName of SAFE_DELETE_FILES) { + deleteFilesPromise.push(remove(join(name, dirOrFileName))); + } + + await Promise.allSettled(deleteFilesPromise); + return "Files cleaned up 🗑️"; + }, + }, + { + title: "Creating .env file", + task: async (message) => { + await copyFile( + join(newProjectDirectory, ".env.example"), + join(newProjectDirectory, ".env") + ); + return "Created .env file"; + }, + }, + { + title: "Installing dependencies via npm", + task: async (message) => { + await downloadDependenciesUsingNpm(name); + return "Dependencies installed 🧰"; + }, + }, + { + title: "Setting up the selected configuration", + task: async (message) => { + const { database, cache, storage, mailer, queue } = finalOptions; + /** + * Setup queue configuration + */ + const getProjectSettingConfNamespace = (path: string) => + `new-project-settings/config/${path}.json`; + + const url = `https://raw.githubusercontent.com/intentjs/registry/refs/heads/main/`; + + message(`Injecting config for ${picocolors.yellow(queue)} queue`); + await injectConfigTask.handle( + url + getProjectSettingConfNamespace(`queue/${queue}`) + ); + + message(`Injecting config for ${picocolors.yellow(database)} db`); + await injectConfigTask.handle( + url + getProjectSettingConfNamespace(`db/${database}`) + ); + + message( + `Injecting config for ${picocolors.yellow(storage)} filesystem` + ); + await injectConfigTask.handle( + url + getProjectSettingConfNamespace(`storage/${storage}`) + ); + + message(`Injecting config for ${picocolors.yellow(mailer)} mailer`); + await injectConfigTask.handle( + url + getProjectSettingConfNamespace(`mailer/${mailer}`) + ); + + message(`Injecting config for ${picocolors.yellow(cache)} cache`); + await injectConfigTask.handle( + url + getProjectSettingConfNamespace(`cache/${cache}`) + ); + + return `Configuration set!`; + }, + }, + ]); + + p.outro("Project setup complete! 🎉"); + + console.log("Building amazing products using Intent!"); + + console.log(); + console.log(`${picocolors.gray("$")} ${picocolors.green(`cd ${name}`)}`); + console.log( + `${picocolors.gray("$")} ${picocolors.cyan("node intent dev")}` + ); + } + + buildPromptOptions(options: Record) { + const missingPromptOptions = [] as Record; + if (!("database" in options)) { + const promptConfig = NEW_PROJECT_OPTIONS["database"]; + missingPromptOptions["database"] = () => + p.select({ + message: promptConfig.question, + options: promptConfig.selectOptions, + }); + } + + if (!("cache" in options)) { + const promptConfig = NEW_PROJECT_OPTIONS["cache"]; + missingPromptOptions["cache"] = () => + p.select({ + message: promptConfig.question, + options: promptConfig.selectOptions, + }); + } + + if (!("storage" in options)) { + const promptConfig = NEW_PROJECT_OPTIONS["storage"]; + missingPromptOptions["storage"] = () => + p.select({ + message: promptConfig.question, + options: promptConfig.selectOptions, + }); + } + + if (!("mailer" in options)) { + const promptConfig = NEW_PROJECT_OPTIONS["mailer"]; + missingPromptOptions["mailer"] = () => + p.select({ + message: promptConfig.question, + options: promptConfig.selectOptions, + }); + } + + if (!("queue" in options)) { + const promptConfig = NEW_PROJECT_OPTIONS["queue"]; + missingPromptOptions["queue"] = () => + p.select({ + message: promptConfig.question, + options: promptConfig.selectOptions, + }); + } + + return missingPromptOptions; + } + + checkIfDirNameIsValidAndAvailable(name: string) { + // Check if directory name is valid + const isWindows = process.platform === "win32"; + const invalidCharsRegex = isWindows + ? /[<>:"/\\|?*\x00-\x1F]/g // Windows invalid chars + : /[/\x00]/g; // Unix/Linux invalid chars + + if (!name || name.trim().length === 0) { + console.log(INTENT_LOG_PREFIX, pc.red("Directory name cannot be empty")); + process.exit(1); + } + + if (name === "." || name === "..") { + console.log(INTENT_LOG_PREFIX, pc.red("Invalid directory name")); + process.exit(1); + } + + if (invalidCharsRegex.test(name)) { + console.log( + INTENT_LOG_PREFIX, + pc.red( + `Directory name contains invalid characters${ + isWindows ? ' (< > : " \\ / | ? * are not allowed)' : "" + }` + ) + ); + process.exit(1); + } + + // Check if name is available + const dirPath = join(process.cwd(), name); + if (existsSync(dirPath)) { + console.log( + INTENT_LOG_PREFIX, + pc.red(`Directory "${name}" already exists`) + ); + process.exit(1); + } + } +} diff --git a/packages/cli/lib/codegen/ast/inject-config.ts b/packages/cli/lib/codegen/ast/inject-config.ts new file mode 100644 index 0000000..ec3ed10 --- /dev/null +++ b/packages/cli/lib/codegen/ast/inject-config.ts @@ -0,0 +1,202 @@ +import { join } from "path"; +import { format, resolveConfig, resolveConfigFile } from "prettier"; +import { + Project, + SourceFile, + SyntaxKind, + ObjectLiteralExpression, + Node, +} from "ts-morph"; + +export class InjectConfig { + private project: Project; + private sourceFile: SourceFile; + + constructor( + private projectRoot: string, + filePath: string + ) { + this.project = new Project({ + manipulationSettings: { useTrailingCommas: true }, + }); + this.sourceFile = this.project.addSourceFileAtPath(filePath); + } + + /** + * Injects a key-value pair into the AST at the specified path + */ + public async handle(registryObject: Record): Promise { + const objects = this.traverseToTargetObject(registryObject.key); + + if (!objects) { + console.log("Target object not found"); + return; + } + + const propertyAdded = this.updateObjectProperties(objects, registryObject); + if (propertyAdded) { + this.addImports(registryObject); + await this.saveChanges(objects); + } + } + + addImports(registryObject: Record): void { + const { imports = [] } = registryObject; + if (!imports || !imports.length) return; + + for (const importObj of imports) { + if (importObj.namedImports) { + this.sourceFile.addImportDeclaration({ + namedImports: importObj.namedImports, + moduleSpecifier: importObj.moduleSpecifier, + }); + } + } + } + + /** + * Traverses the AST to find the target object based on the path + */ + private traverseToTargetObject( + pathArray: string[] + ): ObjectLiteralExpression | undefined { + let currentObject = this.getObjectLiteral(); + + if (!currentObject) return undefined; + + // Traverse the path except the last element (which is the key to be set) + for (let i = 0; i < pathArray.length - 1; i++) { + const property = currentObject.getProperty(pathArray[i]) as + | ObjectLiteralExpression + | undefined; + if (!property) return undefined; + + currentObject = property.getFirstChildByKind( + SyntaxKind.ObjectLiteralExpression + ); + + if (!currentObject) return undefined; + } + + return currentObject; + } + + /** + * Updates the properties of the target object + */ + private updateObjectProperties( + objects: ObjectLiteralExpression, + registryObject: Record + ): boolean { + // Remove existing properties + const initializerName = registryObject.key[registryObject.key.length - 1]; + let propAlreadyExists = false; + objects.getProperties().forEach((property) => { + const propName = property.getSymbol()?.getName(); + if (propName !== initializerName) { + property.remove(); + } else { + propAlreadyExists = true; + } + }); + + if (propAlreadyExists) return false; + + // Add new property + const newConfig = objects + .addPropertyAssignment({ + name: initializerName, + initializer: "{}", + }) + .getInitializer(); + + if (Node.isObjectLiteralExpression(newConfig)) { + for (const [key, value] of Object.entries(registryObject.value)) { + newConfig.addPropertyAssignment({ + name: key, + initializer: + typeof value === "string" + ? value.includes("env://") + ? value.replaceAll("env://", "process.env.") + : `"${value}"` + : JSON.stringify(value), + }); + } + return true; + } + + return false; + } + + transformEnvValues(obj: Record) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + if (typeof value === "string" && value.startsWith("env://")) { + return [key, `process.env.${value.replace("env://", "")}`]; + } + return [key, value]; + }) + ); + } + + /** + * Saves the changes to the file + */ + private async saveChanges(objects: ObjectLiteralExpression): Promise { + const updatedText = objects.getText().replace(/,\s*,/g, ","); + objects.replaceWithText(updatedText); + + const prettierConfig = await resolveConfig(this.projectRoot, { + config: join(this.projectRoot, ".prettierrc"), + }); + + const formatted = await format(this.sourceFile.getFullText(), { + parser: "typescript", + ...prettierConfig, + }); + + this.sourceFile.replaceWithText(formatted); + this.sourceFile.saveSync(); + } + + /** + * Gets the root object literal from the source file + */ + private getObjectLiteral(): ObjectLiteralExpression | undefined { + // Check for regular return statement + const returnStatement = this.sourceFile.getFirstDescendantByKind( + SyntaxKind.ReturnStatement + ); + + if (returnStatement) { + return returnStatement.getFirstDescendantByKind( + SyntaxKind.ObjectLiteralExpression + ); + } + + // Check for arrow function + const arrowFunction = this.sourceFile.getFirstDescendantByKind( + SyntaxKind.ArrowFunction + ); + + if (!arrowFunction) { + return undefined; + } + + // Check for parenthesized object literal + const parenthesized = arrowFunction.getFirstDescendantByKind( + SyntaxKind.ParenthesizedExpression + ); + + if (parenthesized) { + return parenthesized.getFirstDescendantByKind( + SyntaxKind.ObjectLiteralExpression + ); + } + + // Check for direct object literal + return arrowFunction.getFirstDescendantByKind( + SyntaxKind.ObjectLiteralExpression + ); + } +} diff --git a/packages/cli/lib/codegen/download-registry.ts b/packages/cli/lib/codegen/download-registry.ts new file mode 100644 index 0000000..ae08065 --- /dev/null +++ b/packages/cli/lib/codegen/download-registry.ts @@ -0,0 +1,7 @@ +export class DownloadFromRegistry { + async handle(registryUrl: string): Promise { + const response = await fetch(registryUrl); + const data = await response.json(); + return data; + } +} diff --git a/packages/cli/lib/codegen/env-manager.ts b/packages/cli/lib/codegen/env-manager.ts new file mode 100644 index 0000000..5181c41 --- /dev/null +++ b/packages/cli/lib/codegen/env-manager.ts @@ -0,0 +1,145 @@ +import { promises as fs } from "fs"; +import { resolve } from "path"; + +interface VariableInfo { + line: string; + lineNumber: number; +} + +interface UpdateResult { + action: "updated" | "appended"; + line: string; +} + +export class EnvError extends Error { + constructor( + message: string, + public readonly code?: string + ) { + super(message); + this.name = "EnvError"; + } +} + +export class EnvManager { + private readonly envPath: string; + + constructor(envPath: string = ".env") { + this.envPath = resolve(envPath); + } + + /** + * Reads and returns the content of the .env file + * Creates the file if it doesn't exist + */ + private async readEnvFile(): Promise { + try { + await fs.access(this.envPath); + return await fs.readFile(this.envPath, "utf8"); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // File doesn't exist, create it + await fs.writeFile(this.envPath, ""); + return ""; + } + throw new EnvError( + `Failed to read env file: ${error instanceof Error ? error.message : "Unknown error"}`, + "READ_ERROR" + ); + } + } + + /** + * Finds a variable in the .env file + * @param varName - Name of the variable to find + * @returns Variable info if found, null otherwise + */ + public async findVariable(varName: string): Promise { + try { + const content = await this.readEnvFile(); + const lines = content.split("\n"); + const pattern = new RegExp(`^${this.escapeRegExp(varName)}\\s*=`); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (pattern.test(line)) { + return { line, lineNumber: i }; + } + } + return null; + } catch (error) { + throw new EnvError( + `Failed to find variable: ${error instanceof Error ? error.message : "Unknown error"}`, + "FIND_ERROR" + ); + } + } + + /** + * Updates an existing variable or appends a new one + * @param varName - Name of the variable to update + * @param newValue - New value for the variable + * @returns Result of the update operation + */ + public async updateVariable( + varName: string, + newValue: string + ): Promise { + try { + const content = await this.readEnvFile(); + const lines = content.split("\n"); + const varInfo = await this.findVariable(varName); + const newLine = `${varName}=${newValue}`; + + if (varInfo) { + // Update existing variable + lines[varInfo.lineNumber] = newLine; + await fs.writeFile(this.envPath, lines.join("\n")); + return { action: "updated", line: newLine }; + } else { + // Append new variable + const newContent = + content + (content && !content.endsWith("\n") ? "\n" : "") + newLine; + await fs.writeFile(this.envPath, newContent); + return { action: "appended", line: newLine }; + } + } catch (error) { + throw new EnvError( + `Failed to update variable: ${error instanceof Error ? error.message : "Unknown error"}`, + "UPDATE_ERROR" + ); + } + } + + /** + * Gets the value of a variable from the .env file + * @param varName - Name of the variable to get + * @returns Value of the variable if found, null otherwise + */ + public async getVariable(varName: string): Promise { + try { + const varInfo = await this.findVariable(varName); + if (varInfo) { + const [, value] = varInfo.line.split("="); + return value.trim(); + } + return null; + } catch (error) { + throw new EnvError( + `Failed to get variable: ${error instanceof Error ? error.message : "Unknown error"}`, + "GET_ERROR" + ); + } + } + + /** + * Helper method to escape special characters in variable names + */ + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } +} diff --git a/packages/cli/lib/codegen/inject-config.ts b/packages/cli/lib/codegen/inject-config.ts new file mode 100644 index 0000000..e639971 --- /dev/null +++ b/packages/cli/lib/codegen/inject-config.ts @@ -0,0 +1,74 @@ +import { join, normalize } from "path"; +import { DownloadFromRegistry } from "./download-registry"; +import { InjectConfig } from "./ast/inject-config"; +import { downloadPackageUsingNpm } from "../new-project/actions/download-depedencies"; +import { EnvManager } from "./env-manager"; + +export class InjectConfigCodegen { + constructor(private projectDirectory: string) {} + + async handle(registryUrl: string): Promise { + try { + const downloadFromRegistryTask = new DownloadFromRegistry(); + const config = (await downloadFromRegistryTask.handle( + registryUrl + )) as InjectConfigRegistryType; + + const { filename, dependencies, env } = config; + if (!filename) { + console.warn( + "cannot proceed to inject the config without `filename` attribute." + ); + } + const filePath = normalize(join(this.projectDirectory, filename)); + + /** + * Inject key, value in the specified `filename` + */ + const injectConfigTask = new InjectConfig( + this.projectDirectory, + filePath + ); + injectConfigTask.handle(config); + + if (dependencies) { + await this.installDependencies(dependencies); + } + + if (env && env.length) { + await this.setEnvironmentVariables(env); + } + } catch (e) { + console.log("error ==> ", e); + } + } + + async setEnvironmentVariables(env: [string, string][]): Promise { + const envManager = new EnvManager( + normalize(join(this.projectDirectory, ".env")) + ); + + for (const envRow of env) { + const [variable, value] = envRow; + if (!variable) continue; + await envManager.updateVariable(variable, value); + } + } + + async installDependencies(dependencies: Record): Promise { + await downloadPackageUsingNpm( + this.projectDirectory, + Object.keys(dependencies) + ); + } +} + +export type InjectConfigRegistryType = { + type: "inject-config"; + dependencies: Record | undefined; + namespace: string; + key: string[]; + filename: string; + env: [string, string][]; + value: Record; +}; diff --git a/packages/cli/lib/configuration/new-project-config.ts b/packages/cli/lib/configuration/new-project-config.ts new file mode 100644 index 0000000..f4263f6 --- /dev/null +++ b/packages/cli/lib/configuration/new-project-config.ts @@ -0,0 +1,66 @@ +import picocolors from "picocolors"; + +export const defaultTag = picocolors.gray(`(default)`); + +export const NEW_PROJECT_OPTIONS = { + database: { + option: "-db, --database ", + description: "Default DB for your application", + choices: ["pg", "mysql", "sqlite"], + question: "Choice of Database?", + default: "pg", + selectOptions: [ + { value: "pg", label: `Postgres ${defaultTag}` }, + { value: "mysql", label: "MySQL" }, + { value: "sqlite", label: "SQLite" }, + ], + }, + storage: { + option: "-s, --storage ", + description: "Default Storage Provider", + question: "Which storage provider would you like to use?", + choices: ["s3", "local"], + default: "local", + selectOptions: [ + { value: "local", label: `Local Storage ${defaultTag}` }, + { value: "s3", label: "Amazon S3" }, + ], + }, + cache: { + option: "-c, --cache ", + description: "Default Cache Provider", + question: "Which cache would you like to integrate?", + choices: ["redis", "inmemory", "dicedb"], + default: "inmemory", + selectOptions: [ + { value: "in-memory", label: `In-Memory ${defaultTag}` }, + { value: "redis", label: "Redis" }, + { value: "dicedb", label: "DiceDB" }, + ], + }, + mailer: { + option: "-m, --mailer ", + description: "Default Mail Provider", + question: "Which email service would you like to use?", + choices: ["smtp", "mailgun", "resend"], + default: "smtp", + selectOptions: [ + { value: "smtp", label: `SMTP ${defaultTag}` }, + { value: "mailgun", label: "Mailgun" }, + { value: "resend", label: "Resend" }, + ], + }, + queue: { + option: "-q, --queue ", + description: "Default Queue for your application", + question: "Which queue system would you like to use?", + choices: ["sync", "sqs", "redis", "db"], + default: "db", + selectOptions: [ + { value: "db", label: `Database Queue ${defaultTag}` }, + { value: "sync", label: "Synchronous" }, + { value: "sqs", label: "Amazon SQS" }, + { value: "redis", label: "Redis Queue" }, + ], + }, +}; diff --git a/packages/cli/lib/new-project/actions/download-depedencies.ts b/packages/cli/lib/new-project/actions/download-depedencies.ts new file mode 100644 index 0000000..14b7249 --- /dev/null +++ b/packages/cli/lib/new-project/actions/download-depedencies.ts @@ -0,0 +1,20 @@ +import { exec, execSync } from "child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); +export const downloadDependenciesUsingNpm = async (dirName: string) => { + await execAsync(`npm install`, { + windowsHide: true, + cwd: dirName, + }); +}; + +export const downloadPackageUsingNpm = async ( + dirName: string, + dependencies: string[] +) => { + await execAsync(`npm install ${dependencies.join(" ")} --save`, { + windowsHide: true, + cwd: dirName, + }); +}; diff --git a/packages/cli/lib/new-project/actions/download-from-registry.ts b/packages/cli/lib/new-project/actions/download-from-registry.ts new file mode 100644 index 0000000..7e52314 --- /dev/null +++ b/packages/cli/lib/new-project/actions/download-from-registry.ts @@ -0,0 +1,30 @@ +export class DownloadFromRegistry { + async handle(registryUrl: string) { + /** + * Write code to download + */ + + console.log(registryUrl); + const str = `{ + "type": "inject-config", + "dependencies": { + "@aws-sdk/client-s3": "*" + }, + "namespace": "queue", + "key": ["connections", "sqs"], + "value": { + "driver": "sqs", + "listenerType": "poll", + "apiVersion": "2012-11-05", + "credentials": null, + "prefix": "env://SQS_PREFIX", + "queue": "env://SQS_QUEUE", + "suffix": "", + "region": "env://AWS_REGION" + } +} +`; + + return JSON.parse(str); + } +} diff --git a/packages/cli/lib/new-project/actions/download-helper.ts b/packages/cli/lib/new-project/actions/download-helper.ts new file mode 100644 index 0000000..75842e2 --- /dev/null +++ b/packages/cli/lib/new-project/actions/download-helper.ts @@ -0,0 +1,37 @@ +import { execSync } from "child_process"; +import simpleGit from "simple-git"; +import pc from "picocolors"; +import { NEW_PROJECT_CONFIG } from "../config"; + +export const downloadRepository = async ( + starterTemplateName: string, + dirName: string +) => { + const isGitInstalled = checkIfGitIsInstalled(); + + if (isGitInstalled) { + try { + const git = simpleGit(); + await git.clone( + `https://github.com/${starterTemplateName}.git`, + dirName, + ["--branch", NEW_PROJECT_CONFIG.branch] + ); + return 1; + } catch (e) { + console.log(`[ ${pc.bold(pc.red("error"))} ] ${pc.red(e.message)}`); + return 0; + } + } + + return; +}; + +export const checkIfGitIsInstalled = (): boolean => { + try { + execSync("git --version", { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +}; diff --git a/packages/cli/lib/new-project/config.ts b/packages/cli/lib/new-project/config.ts new file mode 100644 index 0000000..f7a19e2 --- /dev/null +++ b/packages/cli/lib/new-project/config.ts @@ -0,0 +1,14 @@ +export const NEW_PROJECT_CONFIG = { + gitOrg: "intentjs", + repoName: "new-app-starter", + branch: "main", +}; + +export const SAFE_DELETE_FILES = [ + ".git", + ".github", + "CODE_OF_CONDUCT.md", + "LICENSE", + "README.md", + "package-lock.json", +]; diff --git a/packages/cli/package.json b/packages/cli/package.json index e0ac8df..d861297 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,15 +27,18 @@ "publish:npm": "npm publish --access public" }, "dependencies": { + "@clack/prompts": "^0.9.0", "@swc/cli": "^0.5.2", "@swc/core": "^1.10.0", "chokidar": "^3.5.1", "commander": "^12.1.0", - "enquirer": "^2.4.1", "fs-extra": "^11.2.0", "picocolors": "^1.1.0", + "prettier": "^3.4.2", "radash": "^12.1.0", + "simple-git": "^3.27.0", "tree-kill": "^1.2.2", + "ts-morph": "^25.0.0", "typescript": "^5.6.2" }, "devDependencies": {