Skip to content

Commit

Permalink
feat: add env editor utility to edit update .env files (#8741)
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage authored Aug 25, 2024
1 parent 57e7653 commit 9a50805
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 0 deletions.
195 changes: 195 additions & 0 deletions packages/core/utils/src/common/__tests__/env-editor.spec.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
])
})
})
104 changes: 104 additions & 0 deletions packages/core/utils/src/common/env-editor.ts
Original file line number Diff line number Diff line change
@@ -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"))
})
)
}
}
1 change: 1 addition & 0 deletions packages/core/utils/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

0 comments on commit 9a50805

Please sign in to comment.