{"version":3,"file":"project-env.mjs","names":["loadDotenv"],"sources":["../../src/env/project-env.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport process from \"node:process\";\nimport * as p from \"@clack/prompts\";\nimport { config as loadDotenv } from \"dotenv\";\nimport { Context, Data, Effect, Layer } from \"effect\";\n\nexport class EnvEnsureError extends Data.TaggedError(\"EnvEnsureError\")<{ cause: unknown }> {}\n\nexport class EnvLoadError extends Data.TaggedError(\"EnvLoadError\")<{ cause: unknown }> {}\n\nexport interface EnvDrift {\n  key: string;\n  from: string | undefined;\n  to: string;\n}\n\nexport class EnvSyncError extends Data.TaggedError(\"EnvSyncError\")<{ cause: unknown }> {}\n\n/**\n * The process environment as exported by the parent shell. Values present\n * here were explicitly provided by the caller (shell, CI, the regression\n * harness) and therefore outrank planner-generated values; values that only\n * exist after `.env` loading do not. The bootstrap program captures this\n * snapshot as its first step — before any `.env` loading — and provides it\n * to the rest of the program as a service.\n */\nexport class ShellEnv extends Context.Service<ShellEnv, Record<string, string>>()(\n  \"everything-dev/ShellEnv\",\n) {}\n\nexport const captureShellEnv: Effect.Effect<Record<string, string>> = Effect.sync(() =>\n  Object.fromEntries(\n    Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] != null),\n  ),\n);\n\nexport const ShellEnvLive = (env: Record<string, string>) => Layer.succeed(ShellEnv, env);\n\n/**\n * Align the bos-owned lines of `.env` with the generated infra env\n * (ports/secrets derived from the resolved dev topology). Keys the caller\n * explicitly exported (`shellEnv`) are skipped — a deliberate override must\n * not be silently reverted on disk. Lines the user added outside the\n * generated key set are untouched. Idempotent.\n */\nconst GENERATED_OWNED_KEYS = new Set([\"BASE_URL\", \"CORS_ORIGIN\"]);\n\nconst maskValue = (key: string, value: string | undefined): string => {\n  if (value === undefined) return \"(absent)\";\n  if (/_DATABASE_URL$|_REDIS_URL$|_SECRET$|_KEY$/.test(key)) {\n    return value.replace(/\\/\\/([^:/@]+):([^@]+)@/, \"//***:***@\");\n  }\n  return value;\n};\n\nexport const syncEnvFile = (\n  configDir: string,\n  generated: Record<string, string>,\n  shellEnv: Record<string, string> = {},\n): Effect.Effect<EnvDrift[], EnvSyncError> =>\n  Effect.gen(function* () {\n    if (Object.keys(generated).length === 0) return [];\n\n    const envPath = join(configDir, \".env\");\n    if (!existsSync(envPath)) return [];\n\n    const lines = yield* Effect.try({\n      try: () => readFileSync(envPath, \"utf-8\").split(\"\\n\"),\n      catch: (cause) => new EnvSyncError({ cause }),\n    });\n\n    const drift: EnvDrift[] = [];\n    const seen = new Set<string>();\n    const keyOf = (line: string): string | null => {\n      const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);\n      return match?.[1] ?? null;\n    };\n\n    const updated = lines.map((line) => {\n      const key = keyOf(line);\n      if (!key || !(key in generated)) return line;\n      const shellOwned = key in shellEnv && !GENERATED_OWNED_KEYS.has(key);\n      if (shellOwned) return line;\n      seen.add(key);\n      const value = line.slice(key.length + 1);\n      if (value === generated[key]) return line;\n      drift.push({ key, from: value, to: generated[key] });\n      return `${key}=${generated[key]}`;\n    });\n\n    for (const [key, value] of Object.entries(generated)) {\n      if (seen.has(key)) continue;\n      const shellOwned = key in shellEnv && !GENERATED_OWNED_KEYS.has(key);\n      if (shellOwned) continue;\n      drift.push({ key, from: undefined, to: value });\n      updated.push(`${key}=${value}`);\n    }\n\n    if (drift.length > 0) {\n      yield* Effect.try({\n        try: () => writeFileSync(envPath, updated.join(\"\\n\")),\n        catch: (cause) => new EnvSyncError({ cause }),\n      });\n      for (const { key, from, to } of drift) {\n        yield* Effect.logInfo(\n          `[env] ${key} updated: ${maskValue(key, from)} → ${maskValue(key, to)} (generated from resolved ports)`,\n        );\n      }\n    }\n    return drift;\n  });\n\nexport interface ProjectEnvService {\n  readonly ensureFile: (configDir: string) => Effect.Effect<boolean, EnvEnsureError>;\n  readonly load: (\n    configDir: string,\n    options?: { force?: boolean },\n  ) => Effect.Effect<void, EnvLoadError>;\n  readonly sync: (\n    configDir: string,\n    generated: Record<string, string>,\n    shellEnv: Record<string, string>,\n  ) => Effect.Effect<EnvDrift[], EnvSyncError>;\n}\n\nexport class ProjectEnv extends Context.Service<ProjectEnv, ProjectEnvService>()(\n  \"everything-dev/ProjectEnv\",\n) {}\n\nexport const makeProjectEnv = (): ProjectEnvService => {\n  const loadedDirs = new Set<string>();\n  return {\n    ensureFile: (configDir) =>\n      Effect.try({\n        try: () => {\n          const envPath = join(configDir, \".env\");\n          const examplePath = join(configDir, \".env.example\");\n          if (existsSync(envPath) || !existsSync(examplePath)) return false;\n\n          const content = readFileSync(examplePath, \"utf-8\");\n          const lines = content.split(\"\\n\");\n          const secret = randomBytes(32).toString(\"base64url\");\n          const updated = lines\n            .map((line) => {\n              if (line.startsWith(\"BETTER_AUTH_SECRET=\")) {\n                return `BETTER_AUTH_SECRET=${secret}`;\n              }\n              return line;\n            })\n            .join(\"\\n\");\n\n          writeFileSync(envPath, updated);\n          p.log.info(\"Created .env from generated .env.example with generated BETTER_AUTH_SECRET\");\n          return true;\n        },\n        catch: (cause) => new EnvEnsureError({ cause }),\n      }),\n    load: (configDir, options) =>\n      Effect.gen(function* () {\n        if (!options?.force && loadedDirs.has(configDir)) return;\n        const envPath = join(configDir, \".env\");\n        if (!existsSync(envPath)) return;\n\n        yield* Effect.try({\n          try: () => loadDotenv({ path: envPath, processEnv: process.env, quiet: true }),\n          catch: (cause) => new EnvLoadError({ cause }),\n        });\n        loadedDirs.add(configDir);\n      }),\n    sync: (configDir, generated, shellEnv) => syncEnvFile(configDir, generated, shellEnv),\n  };\n};\n\n/**\n * One `ProjectEnv` implementation, installed in two scopes: the CLI provides\n * `ProjectEnvLive` from `bootstrapLayers`, and the plugin runtime installs the\n * same layer with `buildScoped` so it finalizes with the plugin. `makeProjectEnv`\n * is that implementation; command and test callers that need a single shot use\n * the factory and do not register another service.\n */\nexport const ProjectEnvLive: Layer.Layer<ProjectEnv> = Layer.sync(ProjectEnv, () =>\n  makeProjectEnv(),\n);\n"],"mappings":";;;;;;;;;AAQA,IAAa,iBAAb,cAAoC,KAAK,YAAY,gBAAgB,CAAC,CAAqB,CAAC;AAE5F,IAAa,eAAb,cAAkC,KAAK,YAAY,cAAc,CAAC,CAAqB,CAAC;AAQxF,IAAa,eAAb,cAAkC,KAAK,YAAY,cAAc,CAAC,CAAqB,CAAC;;;;;;;;;AAUxF,IAAa,WAAb,cAA8B,QAAQ,QAA0C,CAAC,CAC/E,yBACF,CAAC,CAAC,CAAC;AAEH,MAAa,kBAAyD,OAAO,WAC3E,OAAO,YACL,OAAO,QAAQ,QAAQ,GAAG,CAAC,CAAC,QAAQ,UAAqC,MAAM,MAAM,IAAI,CAC3F,CACF;AAEA,MAAa,gBAAgB,QAAgC,MAAM,QAAQ,UAAU,GAAG;;;;;;;;AASxF,MAAM,uCAAuB,IAAI,IAAI,CAAC,YAAY,aAAa,CAAC;AAEhE,MAAM,aAAa,KAAa,UAAsC;CACpE,IAAI,UAAU,QAAW,OAAO;CAChC,IAAI,4CAA4C,KAAK,GAAG,GACtD,OAAO,MAAM,QAAQ,0BAA0B,YAAY;CAE7D,OAAO;AACT;AAEA,MAAa,eACX,WACA,WACA,WAAmC,CAAC,MAEpC,OAAO,IAAI,aAAa;CACtB,IAAI,OAAO,KAAK,SAAS,CAAC,CAAC,WAAW,GAAG,OAAO,CAAC;CAEjD,MAAM,UAAU,KAAK,WAAW,MAAM;CACtC,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO,CAAC;CAElC,MAAM,QAAQ,OAAO,OAAO,IAAI;EAC9B,WAAW,aAAa,SAAS,OAAO,CAAC,CAAC,MAAM,IAAI;EACpD,QAAQ,UAAU,IAAI,aAAa,EAAE,MAAM,CAAC;CAC9C,CAAC;CAED,MAAM,QAAoB,CAAC;CAC3B,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,SAAS,SAAgC;EAE7C,OADc,kCAAkC,KAAK,IAC1C,CAAC,GAAG,MAAM;CACvB;CAEA,MAAM,UAAU,MAAM,KAAK,SAAS;EAClC,MAAM,MAAM,MAAM,IAAI;EACtB,IAAI,CAAC,OAAO,EAAE,OAAO,YAAY,OAAO;EAExC,IADmB,OAAO,YAAY,CAAC,qBAAqB,IAAI,GAAG,GACnD,OAAO;EACvB,KAAK,IAAI,GAAG;EACZ,MAAM,QAAQ,KAAK,MAAM,IAAI,SAAS,CAAC;EACvC,IAAI,UAAU,UAAU,MAAM,OAAO;EACrC,MAAM,KAAK;GAAE;GAAK,MAAM;GAAO,IAAI,UAAU;EAAK,CAAC;EACnD,OAAO,GAAG,IAAI,GAAG,UAAU;CAC7B,CAAC;CAED,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,SAAS,GAAG;EACpD,IAAI,KAAK,IAAI,GAAG,GAAG;EAEnB,IADmB,OAAO,YAAY,CAAC,qBAAqB,IAAI,GAAG,GACnD;EAChB,MAAM,KAAK;GAAE;GAAK,MAAM;GAAW,IAAI;EAAM,CAAC;EAC9C,QAAQ,KAAK,GAAG,IAAI,GAAG,OAAO;CAChC;CAEA,IAAI,MAAM,SAAS,GAAG;EACpB,OAAO,OAAO,IAAI;GAChB,WAAW,cAAc,SAAS,QAAQ,KAAK,IAAI,CAAC;GACpD,QAAQ,UAAU,IAAI,aAAa,EAAE,MAAM,CAAC;EAC9C,CAAC;EACD,KAAK,MAAM,EAAE,KAAK,MAAM,QAAQ,OAC9B,OAAO,OAAO,QACZ,SAAS,IAAI,YAAY,UAAU,KAAK,IAAI,EAAE,KAAK,UAAU,KAAK,EAAE,EAAE,iCACxE;CAEJ;CACA,OAAO;AACT,CAAC;AAeH,IAAa,aAAb,cAAgC,QAAQ,QAAuC,CAAC,CAC9E,2BACF,CAAC,CAAC,CAAC;AAEH,MAAa,uBAA0C;CACrD,MAAM,6BAAa,IAAI,IAAY;CACnC,OAAO;EACL,aAAa,cACX,OAAO,IAAI;GACT,WAAW;IACT,MAAM,UAAU,KAAK,WAAW,MAAM;IACtC,MAAM,cAAc,KAAK,WAAW,cAAc;IAClD,IAAI,WAAW,OAAO,KAAK,CAAC,WAAW,WAAW,GAAG,OAAO;IAG5D,MAAM,QADU,aAAa,aAAa,OACtB,CAAC,CAAC,MAAM,IAAI;IAChC,MAAM,SAAS,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;IACnD,MAAM,UAAU,MACb,KAAK,SAAS;KACb,IAAI,KAAK,WAAW,qBAAqB,GACvC,OAAO,sBAAsB;KAE/B,OAAO;IACT,CAAC,CAAC,CACD,KAAK,IAAI;IAEZ,cAAc,SAAS,OAAO;IAC9B,EAAE,IAAI,KAAK,4EAA4E;IACvF,OAAO;GACT;GACA,QAAQ,UAAU,IAAI,eAAe,EAAE,MAAM,CAAC;EAChD,CAAC;EACH,OAAO,WAAW,YAChB,OAAO,IAAI,aAAa;GACtB,IAAI,CAAC,SAAS,SAAS,WAAW,IAAI,SAAS,GAAG;GAClD,MAAM,UAAU,KAAK,WAAW,MAAM;GACtC,IAAI,CAAC,WAAW,OAAO,GAAG;GAE1B,OAAO,OAAO,IAAI;IAChB,WAAWA,OAAW;KAAE,MAAM;KAAS,YAAY,QAAQ;KAAK,OAAO;IAAK,CAAC;IAC7E,QAAQ,UAAU,IAAI,aAAa,EAAE,MAAM,CAAC;GAC9C,CAAC;GACD,WAAW,IAAI,SAAS;EAC1B,CAAC;EACH,OAAO,WAAW,WAAW,aAAa,YAAY,WAAW,WAAW,QAAQ;CACtF;AACF;;;;;;;;AASA,MAAa,iBAA0C,MAAM,KAAK,kBAChE,eAAe,CACjB"}