From 4d4b1fb62781eab860b0c9971ba3d89189904f6a Mon Sep 17 00:00:00 2001
From: Jp Icalla <jp@four13.co>
Date: Wed, 18 Oct 2023 16:52:16 +0800
Subject: [PATCH] feat: add `envFile` flag to `start` and `init`

---
 bin/stencil-init.js         |   4 +-
 bin/stencil-start.js        |   5 ++
 lib/StencilConfigManager.js | 108 ++++++++++++++++++++++++++++++++++--
 lib/stencil-init.js         |   9 +--
 lib/stencil-start.js        |   8 ++-
 package-lock.json           |  21 ++++++-
 package.json                |   1 +
 7 files changed, 144 insertions(+), 12 deletions(-)

diff --git a/bin/stencil-init.js b/bin/stencil-init.js
index 18b03529..a4038e37 100755
--- a/bin/stencil-init.js
+++ b/bin/stencil-init.js
@@ -11,7 +11,8 @@ program
     .option('-u, --url [url]', 'Store URL')
     .option('-t, --token [token]', 'Access Token')
     .option('-p, --port [port]', 'Port')
-    .option('-h, --apiHost [host]', 'API Host');
+    .option('-h, --apiHost [host]', 'API Host')
+    .option('-e, --envFile [file]', 'Env Vars File');
 
 const cliOptions = prepareCommand(program);
 
@@ -21,5 +22,6 @@ new StencilInit()
         accessToken: cliOptions.token,
         port: cliOptions.port,
         apiHost: cliOptions.apiHost,
+        envFile: cliOptions.envFile,
     })
     .catch(printCliResultErrorAndExit);
diff --git a/bin/stencil-start.js b/bin/stencil-start.js
index c61120c8..7110fa18 100755
--- a/bin/stencil-start.js
+++ b/bin/stencil-start.js
@@ -20,6 +20,10 @@ program
         '-n, --no-cache',
         'Turns off caching for API resource data per storefront page. The cache lasts for 5 minutes before automatically refreshing.',
     )
+    .option(
+        '-e, --envFile [envFile]',
+        'Load config from provided env file, prioritizing system vars.',
+    )
     .option('-t, --timeout', 'Set a timeout for the bundle operation. Default is 20 secs', '60');
 
 const cliOptions = prepareCommand(program);
@@ -30,6 +34,7 @@ const options = {
     apiHost: cliOptions.host,
     tunnel: cliOptions.tunnel,
     cache: cliOptions.cache,
+    envFile: cliOptions.envFile,
 };
 
 const timeout = cliOptions.timeout * 1000; // seconds
diff --git a/lib/StencilConfigManager.js b/lib/StencilConfigManager.js
index d69645eb..7ae324c6 100644
--- a/lib/StencilConfigManager.js
+++ b/lib/StencilConfigManager.js
@@ -1,6 +1,8 @@
 require('colors');
 const fsModule = require('fs');
+const osModule = require('os');
 const path = require('path');
+const dotenv = require('dotenv');
 
 const fsUtilsModule = require('./utils/fsUtils');
 const { THEME_PATH, API_HOST } = require('../constants');
@@ -9,6 +11,7 @@ class StencilConfigManager {
     constructor({
         themePath = THEME_PATH,
         fs = fsModule,
+        os = osModule,
         fsUtils = fsUtilsModule,
         logger = console,
     } = {}) {
@@ -23,6 +26,7 @@ class StencilConfigManager {
         this.secretFieldsSet = new Set(['accessToken', 'githubToken']);
 
         this._fs = fs;
+        this._os = os;
         this._fsUtils = fsUtils;
         this._logger = logger;
     }
@@ -32,7 +36,7 @@ class StencilConfigManager {
      * @param {boolean} ignoreMissingFields
      * @returns {object|null}
      */
-    async read(ignoreFileNotExists = false, ignoreMissingFields = false) {
+    async read(ignoreFileNotExists = false, ignoreMissingFields = false, envFile = null) {
         if (this._fs.existsSync(this.oldConfigPath)) {
             let parsedConfig;
             try {
@@ -51,6 +55,12 @@ class StencilConfigManager {
             ? await this._fsUtils.parseJsonFile(this.configPath)
             : null;
         const secretsConfig = await this._getSecretsConfig(generalConfig);
+        const envConfig = this._getConfigFromEnvVars(envFile);
+
+        if (envConfig) {
+            const parsedConfig = { ...generalConfig, ...envConfig };
+            return this._validateStencilConfig(parsedConfig, ignoreMissingFields);
+        }
 
         if (generalConfig || secretsConfig) {
             const parsedConfig = { ...generalConfig, ...secretsConfig };
@@ -67,11 +77,73 @@ class StencilConfigManager {
     /**
      * @param {object} config
      */
-    async save(config) {
+    async save(config, envFile) {
         const { generalConfig, secretsConfig } = this._splitStencilConfig(config);
 
-        await this._fs.promises.writeFile(this.configPath, JSON.stringify(generalConfig, null, 2));
-        await this._fs.promises.writeFile(this.secretsPath, JSON.stringify(secretsConfig, null, 2));
+        if (envFile) {
+            await this._fs.promises.writeFile(
+                this.configPath,
+                JSON.stringify({ customLayouts: generalConfig.customLayouts }, null, 2),
+            );
+
+            this._setEnvValuesToFile(
+                {
+                    STENCIL_ACCESS_TOKEN: secretsConfig.accessToken,
+                    STENCIL_GITHUB_TOKEN: secretsConfig.githubToken,
+                    STENCIL_STORE_URL: generalConfig.normalStoreUrl,
+                    STENCIL_API_HOST: generalConfig.apiHost,
+                    STENCIL_PORT: generalConfig.port,
+                },
+                envFile,
+            );
+        } else {
+            await this._fs.promises.writeFile(
+                this.configPath,
+                JSON.stringify(generalConfig, null, 2),
+            );
+            await this._fs.promises.writeFile(
+                this.secretsPath,
+                JSON.stringify(secretsConfig, null, 2),
+            );
+        }
+    }
+
+    /**
+     * @param {Array.<{key: String, value: any}>} keyValPairs
+     * @param {string} envFile
+     */
+    _setEnvValuesToFile(keyValPairs, envFile) {
+        const envFilePath = path.join(this.themePath, envFile);
+
+        for (const [key, value] of Object.entries(keyValPairs)) {
+            if (!this._fs.existsSync(envFile)) {
+                this._fs.openSync(envFile, 'a');
+            }
+
+            const vars = this._fs.readFileSync(envFile, 'utf8').split(this._os.EOL);
+
+            // Search for uncommented .env key-value line
+            const envLineRegex = new RegExp(`(?<!#\\s*)${key}(?==)`);
+            const target = vars.findIndex((line) => line.match(envLineRegex));
+
+            if (target !== -1) {
+                // Replace value if found
+                vars.splice(target, 1, `${key}=${value || ''}`);
+            } else if (vars.length === 1 && vars[0] === '') {
+                // Add at beginning of array if only content is empty string
+                vars.unshift(`${key}=${value || ''}`);
+            } else {
+                // For newline at the end if not found
+                if (vars[vars.length - 1] !== '') {
+                    vars.push('');
+                }
+
+                // If key doesn't exist, add as new line
+                vars.splice(vars.length - 1, 0, `${key}=${value || ''}`);
+            }
+
+            this._fs.writeFileSync(envFilePath, vars.join(this._os.EOL));
+        }
     }
 
     /**
@@ -111,6 +183,34 @@ class StencilConfigManager {
             : null;
     }
 
+    /**
+     * @private
+     * @returns {object | null}
+     */
+    _getConfigFromEnvVars(envFile) {
+        if (!envFile) return null;
+
+        dotenv.config({ path: path.join(this.themePath, envFile) });
+
+        const envConfig = {
+            normalStoreUrl: process.env.STENCIL_STORE_URL,
+            accessToken: process.env.STENCIL_ACCESS_TOKEN,
+            githubToken: process.env.STENCIL_GITHUB_TOKEN,
+            apiHost: process.env.STENCIL_API_HOST,
+            port: process.env.STENCIL_PORT,
+        };
+
+        if (!envConfig.normalStoreUrl || !envConfig.accessToken || !envConfig.port) {
+            return null;
+        }
+
+        for (const [key, val] of Object.entries(envConfig)) {
+            if (!val) delete envConfig[key];
+        }
+
+        return envConfig;
+    }
+
     /**
      * @private
      * @param {object} config
diff --git a/lib/stencil-init.js b/lib/stencil-init.js
index 3be7158b..af9f88bd 100644
--- a/lib/stencil-init.js
+++ b/lib/stencil-init.js
@@ -30,15 +30,16 @@ class StencilInit {
      * @param {string} cliOptions.normalStoreUrl
      * @param {string} cliOptions.accessToken
      * @param {number} cliOptions.port
+     * @param {string} [cliOptions.envFile]
      * @returns {Promise<void>}
      */
     async run(cliOptions = {}) {
-        const oldStencilConfig = await this.readStencilConfig();
+        const oldStencilConfig = await this.readStencilConfig(cliOptions.envFile);
         const defaultAnswers = this.getDefaultAnswers(oldStencilConfig);
         const questions = this.getQuestions(defaultAnswers, cliOptions);
         const answers = await this.askQuestions(questions);
         const updatedStencilConfig = this.applyAnswers(oldStencilConfig, answers, cliOptions);
-        await this._stencilConfigManager.save(updatedStencilConfig);
+        await this._stencilConfigManager.save(updatedStencilConfig, updatedStencilConfig.envFile);
 
         this._logger.log(
             'You are now ready to go! To start developing, run $ ' + 'stencil start'.cyan,
@@ -48,11 +49,11 @@ class StencilInit {
     /**
      * @returns {object}
      */
-    async readStencilConfig() {
+    async readStencilConfig(envFile) {
         let parsedConfig;
 
         try {
-            parsedConfig = await this._stencilConfigManager.read(true, true);
+            parsedConfig = await this._stencilConfigManager.read(true, true, envFile);
         } catch (err) {
             this._logger.error(
                 'Detected a broken stencil-cli config:\n',
diff --git a/lib/stencil-start.js b/lib/stencil-start.js
index 040665cb..dcfc3a08 100755
--- a/lib/stencil-start.js
+++ b/lib/stencil-start.js
@@ -54,7 +54,13 @@ class StencilStart {
         if (cliOptions.variation) {
             await this._themeConfigManager.setVariationByName(cliOptions.variation);
         }
-        const initialStencilConfig = await this._stencilConfigManager.read();
+
+        const initialStencilConfig = await this._stencilConfigManager.read(
+            false,
+            false,
+            cliOptions.envFile,
+        );
+
         // Use initial (before updates) port for BrowserSync
         const browserSyncPort = initialStencilConfig.port;
 
diff --git a/package-lock.json b/package-lock.json
index e620ecae..33ea651b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@bigcommerce/stencil-cli",
-  "version": "7.2.1",
+  "version": "7.2.3",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@bigcommerce/stencil-cli",
-      "version": "7.2.1",
+      "version": "7.2.3",
       "license": "BSD-4-Clause",
       "dependencies": {
         "@bigcommerce/stencil-paper": "4.10.4",
@@ -26,6 +26,7 @@
         "colors": "1.4.0",
         "commander": "^6.1.0",
         "confidence": "^5.0.1",
+        "dotenv": "^16.3.1",
         "form-data": "^3.0.0",
         "front-matter": "^4.0.2",
         "glob": "^7.1.6",
@@ -7506,6 +7507,17 @@
         "node": ">=8"
       }
     },
+    "node_modules/dotenv": {
+      "version": "16.3.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+      "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/motdotla/dotenv?sponsor=1"
+      }
+    },
     "node_modules/duplexer2": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -26758,6 +26770,11 @@
         "is-obj": "^2.0.0"
       }
     },
+    "dotenv": {
+      "version": "16.3.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+      "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ=="
+    },
     "duplexer2": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
diff --git a/package.json b/package.json
index 8b7c0a7b..fc09e85b 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
     "colors": "1.4.0",
     "commander": "^6.1.0",
     "confidence": "^5.0.1",
+    "dotenv": "^16.3.1",
     "form-data": "^3.0.0",
     "front-matter": "^4.0.2",
     "glob": "^7.1.6",