diff --git a/packages/backend/package.json b/packages/backend/package.json index eccf2daf9..5c53762e4 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -21,7 +21,8 @@ "saslprep": "^1.0.3", "socket.io": "^4.7.5", "supertokens-node": "^20.0.5", - "tslib": "^2.4.0" + "tslib": "^2.4.0", + "zod": "^3.24.1" }, "devDependencies": { "@shelf/jest-mongodb": "^4.1.4", diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index c6a77cf36..969a9b4c2 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -8,66 +8,76 @@ import { Command } from "commander"; import { runBuild } from "./commands/build"; import { ALL_PACKAGES, CATEGORY_VM } from "./common/cli.constants"; import { startDeleteFlow } from "./commands/delete"; -import { log } from "./common/cli.utils"; +import { CliValidator } from "./cli.validator"; -const runScript = async () => { - const exitHelpfully = (msg?: string) => { - msg && log.error(msg); - console.log(program.helpInformation()); - process.exit(1); - }; +class CompassCli { + private program: Command; + private validator: CliValidator; - const program = new Command(); - program.option( - `-e, --environment [${CATEGORY_VM.STAG}|${CATEGORY_VM.PROD}]`, - "specify environment" - ); - program.option("-f, --force", "forces operation, no cautionary prompts"); - program.option( - "-u, --user [id|email]", - "specifies which user to run script for" - ); - - program - .command("build") - .description("build compass package(s)") - .argument( - `[${ALL_PACKAGES.join("|")}]`, - "package(s) to build, separated by comma" - ) - .option("--skip-env", "skips copying env files to build"); + constructor(args: string[]) { + this.program = this._createProgram(); + this.validator = new CliValidator(this.program); + this.program.parse(args); + } - program - .command("delete") - .description("deletes users data from compass database"); + public async run() { + const options = this.validator.getCliOptions(); + const { force, user } = options; + const cmd = this.program.args[0]; - program.parse(process.argv); + switch (true) { + case cmd === "build": { + await this.validator.validateBuild(options); + await runBuild(options); + break; + } + case cmd === "delete": { + this.validator.validateDelete(options); + await startDeleteFlow(user as string, force); + break; + } + default: + this.validator.exitHelpfully( + "root", + `${cmd as string} is not a supported cmd` + ); + } + } - const options = program.opts(); - const cmd = program.args[0]; + private _createProgram(): Command { + const program = new Command(); - switch (true) { - case cmd === "build": { - await runBuild(options); - break; - } - case cmd === "delete": { - const force = options["force"] as boolean; - const user = options["user"] as string; + program.option("-f, --force", "force operation, no cautionary prompts"); - if (!user || typeof user !== "string") { - exitHelpfully("You must supply a user"); - } + program + .command("build") + .description("build compass package") + .argument( + `[${ALL_PACKAGES.join(" | ")}]`, + "package to build (only provide 1)" + ) + .option( + "-c, --clientId ", + "google client id to inject into build" + ) + .option( + `-e, --environment [${CATEGORY_VM.STAG} | ${CATEGORY_VM.PROD}]`, + "specify environment" + ); - await startDeleteFlow(user, force); - break; - } - default: - exitHelpfully("Unsupported cmd"); + program + .command("delete") + .description("delete user data from compass database") + .option( + "-u, --user [id | email]", + "specify which user to run script for" + ); + return program; } -}; +} -runScript().catch((err) => { +const cli = new CompassCli(process.argv); +cli.run().catch((err) => { console.log(err); process.exit(1); }); diff --git a/packages/scripts/src/cli.validator.ts b/packages/scripts/src/cli.validator.ts new file mode 100644 index 000000000..5a357a0c8 --- /dev/null +++ b/packages/scripts/src/cli.validator.ts @@ -0,0 +1,168 @@ +import { Command } from "commander"; + +import { ALL_PACKAGES } from "./common/cli.constants"; +import { + Options_Cli, + Options_Cli_Build, + Options_Cli_Delete, + Schema_Options_Cli_Build, + Schema_Options_Cli_Delete, + Schema_Options_Cli_Root, +} from "./common/cli.types"; +import { getPckgsTo, log } from "./common/cli.utils"; + +export class CliValidator { + private program: Command; + + constructor(program: Command) { + this.program = program; + } + + public exitHelpfully(cmd: "root" | "build" | "delete", msg?: string) { + msg && log.error(msg); + + if (cmd === "root") { + console.log(this.program.helpInformation()); + } else { + const command = this.program.commands.find( + (c) => c.name() === cmd + ) as Command; + console.log(command.helpInformation()); + } + + process.exit(1); + } + + public getCliOptions(): Options_Cli { + const options = this._mergeOptions(); + const validOptions = this._validateOptions(options); + + return validOptions; + } + + public async validateBuild(options: Options_Cli) { + if (!options.packages) { + options.packages = await getPckgsTo("build"); + } + + const unsupportedPackages = options.packages.filter( + (pkg) => !ALL_PACKAGES.includes(pkg) + ); + if (unsupportedPackages.length > 0) { + this.exitHelpfully( + "build", + `One or more of these packages isn't supported: ${unsupportedPackages.toString()}` + ); + } + } + + public validateDelete(options: Options_Cli) { + const { user } = options; + if (!user || typeof user !== "string") { + this.exitHelpfully("delete", "You must supply a user"); + } + } + + private _getBuildOptions() { + const buildOpts: Options_Cli_Build = {}; + + const buildCmd = this.program.commands.find( + (cmd) => cmd.name() === "build" + ); + if (buildCmd) { + const packages = this.program.args[1]?.split(","); + if (packages) { + buildOpts.packages = packages; + } + + const environment = buildCmd?.opts()[ + "environment" + ] as Options_Cli_Build["environment"]; + if (environment) { + buildOpts.environment = environment; + } + + const clientId = buildCmd?.opts()[ + "clientId" + ] as Options_Cli_Build["clientId"]; + if (clientId) { + buildOpts.clientId = clientId; + } + } + return buildOpts; + } + + private _getDeleteOptions() { + const deleteOpts: Options_Cli_Delete = {}; + + const deleteCmd = this.program.commands.find( + (cmd) => cmd.name() === "delete" + ); + if (deleteCmd) { + const user = deleteCmd?.opts()["user"] as Options_Cli["user"]; + if (user) { + deleteOpts.user = user; + } + } + + return deleteOpts; + } + + private _mergeOptions = (): Options_Cli => { + const _options = this.program.opts(); + let options: Options_Cli = { + ..._options, + force: _options["force"] === true, + }; + + const buildOptions = this._getBuildOptions(); + if (Object.keys(buildOptions).length > 0) { + options = { + ...options, + ...buildOptions, + }; + } + + const deleteOptions = this._getDeleteOptions(); + if (Object.keys(deleteOptions).length > 0) { + options = { + ...options, + ...deleteOptions, + }; + } + + return options; + }; + + private _validateOptions(options: Options_Cli) { + const { data: rootData, error: rootError } = + Schema_Options_Cli_Root.safeParse(options); + if (rootError) { + this.exitHelpfully( + "root", + `Invalid CLI options: ${rootError.toString()}` + ); + } + + const { data: buildData, error: buildError } = + Schema_Options_Cli_Build.safeParse(options); + if (buildError) { + this.exitHelpfully( + "build", + `Invalid build options: ${buildError.toString()}` + ); + } + + const { data: deleteData, error: deleteError } = + Schema_Options_Cli_Delete.safeParse(options); + if (deleteError) { + this.exitHelpfully( + "delete", + `Invalid delete options: ${deleteError.toString()}` + ); + } + + const data: Options_Cli = { ...rootData, ...buildData, ...deleteData }; + return data; + } +} diff --git a/packages/scripts/src/commands/build.ts b/packages/scripts/src/commands/build.ts index adb014a84..0693a4203 100644 --- a/packages/scripts/src/commands/build.ts +++ b/packages/scripts/src/commands/build.ts @@ -1,7 +1,7 @@ import dotenv from "dotenv"; import path from "path"; import shell from "shelljs"; -import { Options_Cli, Info_VM } from "@scripts/common/cli.types"; +import { Options_Cli } from "@scripts/common/cli.types"; import { COMPASS_BUILD_DEV, COMPASS_ROOT_DEV, @@ -9,37 +9,29 @@ import { PCKG, } from "@scripts/common/cli.constants"; import { - getVmInfo, - getPckgsTo, _confirm, log, fileExists, getClientId, + getApiBaseUrl, + getEnvironmentAnswer, } from "@scripts/common/cli.utils"; export const runBuild = async (options: Options_Cli) => { - const env = options["environment"]; - const vmInfo = await getVmInfo(env); + const packages = options.packages as string[]; - const pckgs = - options["packages"] === undefined - ? await getPckgsTo("build") - : options["packages"]; - - if (pckgs.includes(PCKG.NODE)) { - await buildNodePckgs(vmInfo, options["skipEnv"]); + if (packages.includes(PCKG.NODE)) { + await buildNodePckgs(options); } - - if (pckgs.includes(PCKG.WEB)) { - await buildWeb(vmInfo); + if (packages.includes(PCKG.WEB)) { + await buildWeb(options); } }; -// eslint-disable-next-line @typescript-eslint/require-await -const buildNodePckgs = async (vmInfo: Info_VM, skipEnv?: boolean) => { +const buildNodePckgs = async (options: Options_Cli) => { removeOldBuildFor(PCKG.NODE); createNodeDirs(); - await copyNodeConfigsToBuild(vmInfo, skipEnv); + await copyNodeConfigsToBuild(options); log.info("Compiling node packages ..."); shell.exec( @@ -58,11 +50,17 @@ const buildNodePckgs = async (vmInfo: Info_VM, skipEnv?: boolean) => { ); }; -const buildWeb = async (vmInfo: Info_VM) => { - const { baseUrl, destination } = vmInfo; - const envFile = destination === "staging" ? ".env" : ".env.prod"; +const buildWeb = async (options: Options_Cli) => { + const environment = + options.environment !== undefined + ? options.environment + : await getEnvironmentAnswer(); - const gClientId = await getClientId(destination); + const envFile = environment === "staging" ? ".env" : ".env.prod"; + const baseUrl = await getApiBaseUrl(environment); + const gClientId = options.clientId + ? options.clientId + : await getClientId(environment); const envPath = path.join(__dirname, "..", "..", "..", "backend", envFile); dotenv.config({ path: envPath }); @@ -78,15 +76,16 @@ const buildWeb = async (vmInfo: Info_VM) => { log.success(`Done building web files.`); log.tip(` Now you'll probably want to: - - zip the build/web dir - - copy it to your ${destination} server - - unzip it - - run it`); + - zip the build dir + - copy it to your ${environment} environment + - unzip it to expose the static assets + - serve assets + `); process.exit(0); }; -const copyNodeConfigsToBuild = async (vmInfo: Info_VM, skipEnv?: boolean) => { - const envName = vmInfo.destination === "production" ? ".prod.env" : ".env"; +const copyNodeConfigsToBuild = async (options: Options_Cli) => { + const envName = options.environment === "production" ? ".prod.env" : ".env"; const envPath = `${COMPASS_ROOT_DEV}/packages/backend/${envName}`; @@ -99,7 +98,7 @@ const copyNodeConfigsToBuild = async (vmInfo: Info_VM, skipEnv?: boolean) => { log.warning(`Env file does not exist: ${envPath}`); const keepGoing = - skipEnv === true ? true : await _confirm("Continue anyway?"); + options.force === true ? true : await _confirm("Continue anyway?"); if (!keepGoing) { log.error("Exiting due to missing env file"); diff --git a/packages/scripts/src/common/cli.types.ts b/packages/scripts/src/common/cli.types.ts index c08c9b89c..dee887a4c 100644 --- a/packages/scripts/src/common/cli.types.ts +++ b/packages/scripts/src/common/cli.types.ts @@ -1,16 +1,24 @@ -export type Category_VM = "staging" | "production"; +import { z } from "zod"; -export interface Info_VM { - baseUrl: string; - destination: Category_VM; -} +export const Schema_Options_Cli_Root = z.object({ + force: z.boolean().optional(), +}); -export interface Options_Cli { - build?: boolean; - delete?: boolean; - environment?: Category_VM; - force?: boolean; - packages?: string[]; - skipEnv?: boolean; - user?: string; -} +export const Schema_Options_Cli_Build = z.object({ + clientId: z.string().optional(), + environment: z.enum(["staging", "production"]).optional(), + packages: z.array(z.string()).optional(), +}); + +export const Schema_Options_Cli_Delete = z.object({ + user: z.string().optional(), +}); + +export type Options_Cli_Delete = z.infer; +export type Options_Cli_Build = z.infer; +export type Options_Cli_Root = z.infer; +export type Options_Cli = Options_Cli_Root & + Options_Cli_Build & + Options_Cli_Delete; + +export type Environment_Cli = "staging" | "production"; diff --git a/packages/scripts/src/common/cli.utils.ts b/packages/scripts/src/common/cli.utils.ts index 98ac9443a..18ed41d4f 100644 --- a/packages/scripts/src/common/cli.utils.ts +++ b/packages/scripts/src/common/cli.utils.ts @@ -4,18 +4,27 @@ const { prompt } = pkg; import shell from "shelljs"; import { ALL_PACKAGES, CLI_ENV } from "./cli.constants"; -import { Category_VM } from "./cli.types"; +import { Environment_Cli } from "./cli.types"; export const fileExists = (file: string) => { return shell.test("-e", file); }; -export const getClientId = async (destination: Category_VM) => { - if (destination === "staging") { +export const getApiBaseUrl = async (environment: Environment_Cli) => { + const category = environment ? environment : await getEnvironmentAnswer(); + const isStaging = category === "staging"; + const domain = await getDomainAnswer(isStaging); + const baseUrl = `https://${domain}/api`; + + return baseUrl; +}; + +export const getClientId = async (environment: Environment_Cli) => { + if (environment === "staging") { return process.env["CLIENT_ID"] as string; } - if (destination === "production") { + if (environment === "production") { const q = `Enter the googleClientId for the production environment:`; return prompt([{ type: "input", name: "answer", message: q }]) @@ -58,22 +67,17 @@ const getDomainAnswer = async (isStaging: boolean) => { process.exit(1); }); }; -export const getVmInfo = async (environment?: Category_VM) => { - const destination = environment - ? environment - : ((await getListAnswer("Select environment to use:", [ - "staging", - "production", - ])) as Category_VM); - - const isStaging = destination === "staging"; - const domain = await getDomainAnswer(isStaging); - const baseUrl = `https://${domain}/api`; - return { baseUrl, destination }; +export const getEnvironmentAnswer = async (): Promise => { + const environment = (await getListAnswer("Select environment to use:", [ + "staging", + "production", + ])) as Environment_Cli; + + return environment; }; -const getListAnswer = async (question: string, choices: string[]) => { +export const getListAnswer = async (question: string, choices: string[]) => { const q = [ { type: "list",