everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
132 lines (130 loc) • 5.48 kB
JavaScript
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Context, Data, Effect, Layer } from "effect";
import { randomBytes } from "node:crypto";
import process from "node:process";
import { config } from "dotenv";
import * as p from "@clack/prompts";
//#region src/env/project-env.ts
var EnvEnsureError = class extends Data.TaggedError("EnvEnsureError") {};
var EnvLoadError = class extends Data.TaggedError("EnvLoadError") {};
var EnvSyncError = class extends Data.TaggedError("EnvSyncError") {};
/**
* The process environment as exported by the parent shell. Values present
* here were explicitly provided by the caller (shell, CI, the regression
* harness) and therefore outrank planner-generated values; values that only
* exist after `.env` loading do not. The bootstrap program captures this
* snapshot as its first step — before any `.env` loading — and provides it
* to the rest of the program as a service.
*/
var ShellEnv = class extends Context.Service()("everything-dev/ShellEnv") {};
const captureShellEnv = Effect.sync(() => Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null)));
const ShellEnvLive = (env) => Layer.succeed(ShellEnv, env);
/**
* Align the bos-owned lines of `.env` with the generated infra env
* (ports/secrets derived from the resolved dev topology). Keys the caller
* explicitly exported (`shellEnv`) are skipped — a deliberate override must
* not be silently reverted on disk. Lines the user added outside the
* generated key set are untouched. Idempotent.
*/
const GENERATED_OWNED_KEYS = /* @__PURE__ */ new Set(["BASE_URL", "CORS_ORIGIN"]);
const maskValue = (key, value) => {
if (value === void 0) return "(absent)";
if (/_DATABASE_URL$|_REDIS_URL$|_SECRET$|_KEY$/.test(key)) return value.replace(/\/\/([^:/@]+):([^@]+)@/, "//***:***@");
return value;
};
const syncEnvFile = (configDir, generated, shellEnv = {}) => Effect.gen(function* () {
if (Object.keys(generated).length === 0) return [];
const envPath = join(configDir, ".env");
if (!existsSync(envPath)) return [];
const lines = yield* Effect.try({
try: () => readFileSync(envPath, "utf-8").split("\n"),
catch: (cause) => new EnvSyncError({ cause })
});
const drift = [];
const seen = /* @__PURE__ */ new Set();
const keyOf = (line) => {
return /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)?.[1] ?? null;
};
const updated = lines.map((line) => {
const key = keyOf(line);
if (!key || !(key in generated)) return line;
if (key in shellEnv && !GENERATED_OWNED_KEYS.has(key)) return line;
seen.add(key);
const value = line.slice(key.length + 1);
if (value === generated[key]) return line;
drift.push({
key,
from: value,
to: generated[key]
});
return `${key}=${generated[key]}`;
});
for (const [key, value] of Object.entries(generated)) {
if (seen.has(key)) continue;
if (key in shellEnv && !GENERATED_OWNED_KEYS.has(key)) continue;
drift.push({
key,
from: void 0,
to: value
});
updated.push(`${key}=${value}`);
}
if (drift.length > 0) {
yield* Effect.try({
try: () => writeFileSync(envPath, updated.join("\n")),
catch: (cause) => new EnvSyncError({ cause })
});
for (const { key, from, to } of drift) yield* Effect.logInfo(`[env] ${key} updated: ${maskValue(key, from)} → ${maskValue(key, to)} (generated from resolved ports)`);
}
return drift;
});
var ProjectEnv = class extends Context.Service()("everything-dev/ProjectEnv") {};
const makeProjectEnv = () => {
const loadedDirs = /* @__PURE__ */ new Set();
return {
ensureFile: (configDir) => Effect.try({
try: () => {
const envPath = join(configDir, ".env");
const examplePath = join(configDir, ".env.example");
if (existsSync(envPath) || !existsSync(examplePath)) return false;
const lines = readFileSync(examplePath, "utf-8").split("\n");
const secret = randomBytes(32).toString("base64url");
const updated = lines.map((line) => {
if (line.startsWith("BETTER_AUTH_SECRET=")) return `BETTER_AUTH_SECRET=${secret}`;
return line;
}).join("\n");
writeFileSync(envPath, updated);
p.log.info("Created .env from generated .env.example with generated BETTER_AUTH_SECRET");
return true;
},
catch: (cause) => new EnvEnsureError({ cause })
}),
load: (configDir, options) => Effect.gen(function* () {
if (!options?.force && loadedDirs.has(configDir)) return;
const envPath = join(configDir, ".env");
if (!existsSync(envPath)) return;
yield* Effect.try({
try: () => config({
path: envPath,
processEnv: process.env,
quiet: true
}),
catch: (cause) => new EnvLoadError({ cause })
});
loadedDirs.add(configDir);
}),
sync: (configDir, generated, shellEnv) => syncEnvFile(configDir, generated, shellEnv)
};
};
/**
* One `ProjectEnv` implementation, installed in two scopes: the CLI provides
* `ProjectEnvLive` from `bootstrapLayers`, and the plugin runtime installs the
* same layer with `buildScoped` so it finalizes with the plugin. `makeProjectEnv`
* is that implementation; command and test callers that need a single shot use
* the factory and do not register another service.
*/
const ProjectEnvLive = Layer.sync(ProjectEnv, () => makeProjectEnv());
//#endregion
export { EnvEnsureError, EnvLoadError, EnvSyncError, ProjectEnv, ProjectEnvLive, ShellEnv, ShellEnvLive, captureShellEnv, makeProjectEnv, syncEnvFile };
//# sourceMappingURL=project-env.mjs.map