feat: add env editor utility to edit update .env files (#8741)
This commit is contained in:
@@ -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"],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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"))
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user