diff --git a/packages/core/utils/src/common/__tests__/env-editor.spec.ts b/packages/core/utils/src/common/__tests__/env-editor.spec.ts new file mode 100644 index 0000000000..c0606ab8a2 --- /dev/null +++ b/packages/core/utils/src/common/__tests__/env-editor.spec.ts @@ -0,0 +1,195 @@ +import { join } from "path" +import { FileSystem } from "../file-system" +import { EnvEditor } from "../env-editor" + +const BASE_PATH = join(__dirname, "env-editor-tests") +const fs = new FileSystem(BASE_PATH) + +describe("Env editor", () => { + beforeEach(async () => { + await fs.cleanup() + }) + afterAll(async () => { + await fs.cleanup() + }) + + test("add key-value pair to dot-env files", async () => { + await fs.create(".env", "") + await fs.create(".env.template", "") + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + editor.set("PORT", 3000) + + expect(editor.toJSON()).toEqual([ + { + exists: true, + filePath: join(fs.basePath, ".env"), + contents: ["", "PORT=3000"], + }, + { + exists: true, + filePath: join(fs.basePath, ".env.template"), + contents: ["", "PORT=3000"], + }, + ]) + }) + + test("modify non-existing files as well", async () => { + const editor = new EnvEditor(BASE_PATH) + await editor.load() + editor.set("PORT", 3000) + + expect(editor.toJSON()).toEqual([ + { + exists: false, + filePath: join(fs.basePath, ".env"), + contents: ["PORT=3000"], + }, + { + exists: false, + filePath: join(fs.basePath, ".env.template"), + contents: ["PORT=3000"], + }, + ]) + }) + + test("update existing key value pair", async () => { + await fs.create(".env", "PORT=3333") + await fs.create(".env.template", "PORT=4000") + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + editor.set("PORT", 3000) + + expect(editor.toJSON()).toEqual([ + { + exists: true, + filePath: join(fs.basePath, ".env"), + contents: ["PORT=3000"], + }, + { + exists: true, + filePath: join(fs.basePath, ".env.template"), + contents: ["PORT=3000"], + }, + ]) + }) + + test("update in one file and add in another file", async () => { + await fs.create(".env", ["PORT=3333", "", "HOST=localhost"].join("\n")) + await fs.create(".env.template", "") + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + editor.set("PORT", 3000) + + expect(editor.toJSON()).toEqual([ + { + exists: true, + filePath: join(fs.basePath, ".env"), + contents: ["PORT=3000", "", "HOST=localhost"], + }, + { + exists: true, + filePath: join(fs.basePath, ".env.template"), + contents: ["", "PORT=3000"], + }, + ]) + }) + + test("persist changes on disk", async () => { + await fs.create(".env", ["PORT=3333", "", "HOST=localhost"].join("\n")) + await fs.create(".env.template", "") + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + editor.set("PORT", 3000) + await editor.save() + + const envFile = await fs.contents(".env") + expect(envFile.split("\n")).toEqual(["PORT=3000", "", "HOST=localhost"]) + + const envTemplateFile = await fs.contents(".env.template") + expect(envTemplateFile.split("\n")).toEqual(["", "PORT=3000"]) + }) + + test("multiple times persist changes on disk", async () => { + await fs.create(".env", ["PORT=3333", "", "HOST=localhost"].join("\n")) + await fs.create(".env.template", "") + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + + editor.set("PORT", 3000) + await editor.save() + + let envFile = await fs.contents(".env") + expect(envFile.split("\n")).toEqual(["PORT=3000", "", "HOST=localhost"]) + + let envTemplateFile = await fs.contents(".env.template") + expect(envTemplateFile.split("\n")).toEqual(["", "PORT=3000"]) + + editor.set("HOST", "127.0.0.1") + await editor.save() + + envFile = await fs.contents(".env") + expect(envFile.split("\n")).toEqual(["PORT=3000", "", "HOST=127.0.0.1"]) + + envTemplateFile = await fs.contents(".env.template") + expect(envTemplateFile.split("\n")).toEqual([ + "", + "PORT=3000", + "HOST=127.0.0.1", + ]) + }) + + test("add empty template value", async () => { + await fs.create(".env", "") + await fs.create(".env.template", "") + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + + editor.set("PORT", 3000, { withEmptyTemplateValue: true }) + + expect(editor.toJSON()).toEqual([ + { + exists: true, + filePath: join(fs.basePath, ".env"), + contents: ["", "PORT=3000"], + }, + { + exists: true, + filePath: join(fs.basePath, ".env.template"), + contents: ["", "PORT="], + }, + ]) + }) + + test("do not replace existing template value with empty value", async () => { + await fs.create(".env", ["PORT=3333", "", "HOST=localhost"].join("\n")) + await fs.create( + ".env.template", + ["PORT=3333", "", "HOST=localhost"].join("\n") + ) + + const editor = new EnvEditor(BASE_PATH) + await editor.load() + + editor.set("PORT", 3000, { withEmptyTemplateValue: true }) + + expect(editor.toJSON()).toEqual([ + { + exists: true, + filePath: join(fs.basePath, ".env"), + contents: ["PORT=3000", "", "HOST=localhost"], + }, + { + exists: true, + filePath: join(fs.basePath, ".env.template"), + contents: ["PORT=3333", "", "HOST=localhost"], + }, + ]) + }) +}) diff --git a/packages/core/utils/src/common/env-editor.ts b/packages/core/utils/src/common/env-editor.ts new file mode 100644 index 0000000000..7a577cd11f --- /dev/null +++ b/packages/core/utils/src/common/env-editor.ts @@ -0,0 +1,104 @@ +import { join } from "path" +import { writeFile, readFile } from "fs/promises" + +/** + * Exposes the API to edit Env files + */ +export class EnvEditor { + #appRoot: string + #files: { + exists: boolean + contents: string[] + filePath: string + }[] = [] + + constructor(appRoot: string) { + this.#appRoot = appRoot + } + + /** + * Reads a file and returns with contents. Ignores error + * when file is missing + */ + async #readFile(filePath: string) { + try { + const contents = await readFile(filePath, "utf-8") + return { + exists: true, + contents: contents.split(/\r?\n/), + filePath, + } + } catch (error) { + if (error.code === "ENOENT") { + return { + exists: false, + contents: [], + filePath, + } + } + throw error + } + } + + /** + * Loads .env and .env.template files for editing. + */ + async load() { + this.#files = await Promise.all( + [join(this.#appRoot, ".env"), join(this.#appRoot, ".env.template")].map( + (filePath) => this.#readFile(filePath) + ) + ) + } + + /** + * Set key-value pair to the dot-env files. + * + * If `withEmptyTemplateValue` is true then the key will be added with an empty value + * to the `.env.template` file. + */ + set( + key: string, + value: string | number | boolean, + options?: { + withEmptyTemplateValue: boolean + } + ) { + const withEmptyTemplateValue = options?.withEmptyTemplateValue ?? false + this.#files.forEach((file) => { + let entryIndex = file.contents.findIndex((line) => + line.startsWith(`${key}=`) + ) + const writeIndex = entryIndex === -1 ? file.contents.length : entryIndex + + if (withEmptyTemplateValue && file.filePath.endsWith(".env.template")) { + /** + * Do not remove existing template value (if any) + */ + if (entryIndex === -1) { + file.contents[writeIndex] = `${key}=` + } + } else { + file.contents[writeIndex] = `${key}=${value}` + } + }) + } + + /** + * Get files and their contents as JSON + */ + toJSON() { + return this.#files + } + + /** + * Save changes back to the disk + */ + async save() { + await Promise.all( + this.#files.map((file) => { + return writeFile(file.filePath, file.contents.join("\n")) + }) + ) + } +} diff --git a/packages/core/utils/src/common/index.ts b/packages/core/utils/src/common/index.ts index 1636a265d4..0533571f47 100644 --- a/packages/core/utils/src/common/index.ts +++ b/packages/core/utils/src/common/index.ts @@ -69,4 +69,5 @@ export * from "./upper-case-first" export * from "./validate-handle" export * from "./wrap-handler" export * from "./define-config" +export * from "./env-editor" export * from "./normalize-import-path-with-source"