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 "./validate-handle"
|
||||||
export * from "./wrap-handler"
|
export * from "./wrap-handler"
|
||||||
export * from "./define-config"
|
export * from "./define-config"
|
||||||
|
export * from "./env-editor"
|
||||||
export * from "./normalize-import-path-with-source"
|
export * from "./normalize-import-path-with-source"
|
||||||
|
|||||||
Reference in New Issue
Block a user