From 60c043b6de315130ddca402b428c0690403c6de6 Mon Sep 17 00:00:00 2001
From: Tyler Dane <tyler@switchback.tech>
Date: Sun, 29 Dec 2024 07:51:05 -0600
Subject: [PATCH] refactor(cli): convert CLI to class and use zod for
 validation

---
 packages/scripts/src/cli.ts              | 164 ++++++++++++-----------
 packages/scripts/src/commands/build.ts   |  44 +++---
 packages/scripts/src/common/cli.types.ts |  25 ++--
 packages/scripts/src/common/cli.utils.ts |  38 +++---
 4 files changed, 141 insertions(+), 130 deletions(-)

diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts
index 33275df2..1ced114c 100644
--- a/packages/scripts/src/cli.ts
+++ b/packages/scripts/src/cli.ts
@@ -6,101 +6,113 @@ dotenv.config({
 import { Command } from "commander";
 
 import { runBuild } from "./commands/build";
-import { ALL_PACKAGES, CATEGORY_VM, PCKG } from "./common/cli.constants";
+import { ALL_PACKAGES, CATEGORY_VM } from "./common/cli.constants";
 import { startDeleteFlow } from "./commands/delete";
+import { Options_Cli, Schema_Options_Cli } from "./common/cli.types";
 import { log } from "./common/cli.utils";
-import { Options_Cli } from "./common/cli.types";
 
-const createProgram = () => {
-  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"
-  );
+class CompassCli {
+  private program: Command;
+  private options: Options_Cli;
 
-  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.program.parse(args);
+    this.options = this.getCliOptions();
+  }
 
-  program
-    .command("delete")
-    .description("delete user data from compass database");
-  return program;
-};
+  private createProgram(): Command {
+    const program = new Command();
+    program.option(
+      `-e, --environment [${CATEGORY_VM.STAG} | ${CATEGORY_VM.PROD}]`,
+      "specify environment"
+    );
+    program.option("-f, --force", "force operation, no cautionary prompts");
+    program.option(
+      "-u, --user [id | email]",
+      "specify which user to run script for"
+    );
 
-const exitHelpfully = (program: Command, msg?: string) => {
-  msg && log.error(msg);
-  console.log(program.helpInformation());
-  process.exit(1);
-};
+    program
+      .command("build")
+      .description("build compass package(s)")
+      .argument(
+        `[${ALL_PACKAGES.join(" | ")}]`,
+        "package(s) to build, separated by comma"
+      )
+      .option("--skip-env", "skip copying env files to build");
 
-const getCliOptions = (program: Command): Options_Cli => {
-  const _options = program.opts();
-  const packages = program.args[1]?.split(",");
+    program
+      .command("delete")
+      .description("delete user data from compass database");
+    return program;
+  }
 
-  const options = {
-    ..._options,
-    packages,
-    force: _options["force"] === true,
-    user: _options["user"] as string,
-  };
+  private getCliOptions(): Options_Cli {
+    const _options = this.program.opts();
+    const packages = this.program.args[1]?.split(",");
+    const options: Options_Cli = {
+      ..._options,
+      force: _options["force"] === true,
+      packages,
+    };
 
-  return options;
-};
+    const { data, error } = Schema_Options_Cli.safeParse(options);
+    if (error) {
+      log.error(`Invalid CLI options: ${JSON.stringify(error.format())}`);
+      process.exit(1);
+    }
 
-const validatePackages = (packages: string[] | undefined) => {
-  if (!packages) {
-    log.error("Packages must be defined");
+    return data;
   }
-  if (!packages?.includes(PCKG.NODE) && !packages?.includes(PCKG.WEB)) {
-    log.error(
-      `One or more of these pckgs isn't supported: ${(
-        packages as string[]
-      )?.toString()}`
-    );
 
-    process.exit(1);
+  private validatePackages(packages: string[] | undefined) {
+    if (!packages) {
+      log.error("Packages must be defined");
+      process.exit(1);
+    }
+    const unsupportedPackages = packages.filter(
+      (pkg) => !ALL_PACKAGES.includes(pkg)
+    );
+    if (unsupportedPackages.length > 0) {
+      log.error(
+        `One or more of these packages isn't supported: ${unsupportedPackages.toString()}`
+      );
+      process.exit(1);
+    }
   }
-};
-
-const runScript = async () => {
-  const program = createProgram();
-  program.parse(process.argv);
 
-  const options = getCliOptions(program);
-  const { user, force } = options;
+  public async run() {
+    const { user, force, packages } = this.options;
+    const cmd = this.program.args[0];
 
-  const cmd = program.args[0];
-  switch (true) {
-    case cmd === "build": {
-      validatePackages(options.packages);
-      await runBuild(options);
-      break;
-    }
-    case cmd === "delete": {
-      if (!user || typeof user !== "string") {
-        exitHelpfully(program, "You must supply a user");
+    switch (true) {
+      case cmd === "build": {
+        this.validatePackages(packages);
+        await runBuild(this.options);
+        break;
       }
-
-      await startDeleteFlow(user as string, force);
-      break;
+      case cmd === "delete": {
+        if (!user || typeof user !== "string") {
+          this.exitHelpfully("You must supply a user");
+        }
+        await startDeleteFlow(user as string, force);
+        break;
+      }
+      default:
+        this.exitHelpfully("Unsupported cmd");
     }
-    default:
-      exitHelpfully(program, "Unsupported cmd");
   }
-};
 
-runScript().catch((err) => {
+  private exitHelpfully(msg?: string) {
+    msg && log.error(msg);
+    console.log(this.program.helpInformation());
+    process.exit(1);
+  }
+}
+
+const cli = new CompassCli(process.argv);
+cli.run().catch((err) => {
   console.log(err);
   process.exit(1);
 });
diff --git a/packages/scripts/src/commands/build.ts b/packages/scripts/src/commands/build.ts
index 889f9223..3c441294 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,34 +9,33 @@ 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 vmInfo = await getVmInfo(options.environment);
-
   const pckgs =
     options?.packages?.length === 0
       ? await getPckgsTo("build")
       : (options.packages as string[]);
 
   if (pckgs.includes(PCKG.NODE)) {
-    await buildNodePckgs(vmInfo, options);
+    await buildNodePckgs(options);
   }
   if (pckgs.includes(PCKG.WEB)) {
-    await buildWeb(vmInfo);
+    await buildWeb(options);
   }
 };
 
-const buildNodePckgs = async (vmInfo: Info_VM, options: Options_Cli) => {
+const buildNodePckgs = async (options: Options_Cli) => {
   removeOldBuildFor(PCKG.NODE);
   createNodeDirs();
-  await copyNodeConfigsToBuild(vmInfo, options.skipEnv, options.force);
+  await copyNodeConfigsToBuild(options);
 
   log.info("Compiling node packages ...");
   shell.exec(
@@ -55,11 +54,15 @@ const buildNodePckgs = async (vmInfo: Info_VM, options: Options_Cli) => {
   );
 };
 
-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 = await getClientId(environment);
 
   const envPath = path.join(__dirname, "..", "..", "..", "backend", envFile);
   dotenv.config({ path: envPath });
@@ -76,18 +79,15 @@ const buildWeb = async (vmInfo: Info_VM) => {
   log.tip(`
     Now you'll probably want to:
       - zip the build dir
-      - copy it to your ${destination} server
-      - unzip it and serve as the static assets
+      - 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,
-  force?: 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}`;
 
@@ -100,9 +100,7 @@ const copyNodeConfigsToBuild = async (
     log.warning(`Env file does not exist: ${envPath}`);
 
     const keepGoing =
-      skipEnv === true || force === 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 c08c9b89..cc9465fa 100644
--- a/packages/scripts/src/common/cli.types.ts
+++ b/packages/scripts/src/common/cli.types.ts
@@ -1,16 +1,13 @@
-export type Category_VM = "staging" | "production";
+import { z } from "zod";
 
-export interface Info_VM {
-  baseUrl: string;
-  destination: Category_VM;
-}
+export type Environment_Cli = "staging" | "production";
 
-export interface Options_Cli {
-  build?: boolean;
-  delete?: boolean;
-  environment?: Category_VM;
-  force?: boolean;
-  packages?: string[];
-  skipEnv?: boolean;
-  user?: string;
-}
+export const Schema_Options_Cli = z.object({
+  clientId: z.string().optional(),
+  environment: z.enum(["staging", "production"]).optional(),
+  force: z.boolean().optional(),
+  packages: z.array(z.string()).optional(),
+  user: z.string().optional(),
+});
+
+export type Options_Cli = z.infer<typeof Schema_Options_Cli>;
diff --git a/packages/scripts/src/common/cli.utils.ts b/packages/scripts/src/common/cli.utils.ts
index 98ac9443..18ed41d4 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<Environment_Cli> => {
+  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",