diff --git a/docs/env.md b/docs/env.md index 236a264ee..1d29bafb6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -138,6 +138,38 @@ Projects can set a default overlay in `.hack/hack.config.json`: Use `--env=base` to bypass that default and read only `.hack/hack.env.default.yaml`. +## Metadata-only planning API + +Repository integrations can call `resolveProjectEnvMetadata` from +`src/lib/project-env-config.ts` to inspect modern env bindings without obtaining a +key or decrypting values. Pass `projectRoot`, `projectDir`, and `serviceNames`; +optional `envName` selects an overlay, omission uses the project default, and +`null` bypasses that default. + +The result contains selection and file paths, declared and unknown scope names, +and per-target key metadata (`scope` and `secret`) for Compose and host execution. +It contains no plaintext values, ciphertext, merged config, or raw layers. It +reads the same ordered files and uses the same scope projection as +`resolveProjectEnvConfig`: later layers override earlier ones, null removes an +earlier binding, and a later value can reintroduce it. Linked worktrees inherit +primary local layers before their own local overrides, subject to the existing +inheritance settings. If a service is named `host`, that scope remains a service +scope rather than an override applied to every host target. + +A `null` result means no modern env configuration exists; it does not inspect or +resolve legacy `.env` or secret-store values. Callers retain responsibility for +their existing legacy fallback. Selected managed-env layer reads distinguish a +missing path from a failed read or a dangling symlink. Only regular files +(including readable symlinks to regular files) are read; invalid or unreadable +layers throw a value-free error without YAML excerpts or a nested cause. Project configuration +selection keeps its existing semantics. Metadata inspection never writes key +files or materializes `.env`. + +Paths, variable names and scope names can still disclose private project details; +keep this metadata private. It is not portable plan serialization or an atomic +snapshot/admission check. It does not establish that encrypted values can be +decrypted or that runtime injection will succeed. + ## Runtime behavior Direct runtime injection is the default path. diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 25773a524..68abb7845 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -4,7 +4,7 @@ import { createHash, randomBytes, } from "node:crypto"; -import { chmod, readdir, rm } from "node:fs/promises"; +import { chmod, lstat, readdir, rm, stat } from "node:fs/promises"; import { basename, dirname, relative, resolve } from "node:path"; import { YAML } from "bun"; import { @@ -105,12 +105,18 @@ type EffectiveEnvMetadata = Record< Record >; -export type ProjectEnvResolvedConfig = { +/** Names, winning scopes and secret flags only; never carries stored values. */ +export type ProjectEnvResolvedMetadata = { readonly effectiveMetadata: EffectiveEnvMetadata; readonly hostEffectiveMetadata: EffectiveEnvMetadata; readonly selection: ProjectEnvSelection; - readonly merged: ProjectEnvConfig; readonly files: readonly string[]; + readonly declaredScopes: readonly string[]; + readonly unknownScopes: readonly string[]; +}; + +export type ProjectEnvResolvedConfig = ProjectEnvResolvedMetadata & { + readonly merged: ProjectEnvConfig; readonly globalEnv: Readonly>; readonly hostEnv: Readonly>; readonly hostTargetEnv: Readonly< @@ -119,8 +125,6 @@ export type ProjectEnvResolvedConfig = { readonly serviceEnv: Readonly< Record>> >; - readonly declaredScopes: readonly string[]; - readonly unknownScopes: readonly string[]; }; export function selectProjectEnvValues(opts: { @@ -523,11 +527,43 @@ export async function listProjectEnvOverlayNames(opts: { .sort((left, right) => left.localeCompare(right)); } +/** Strict planning reads distinguish missing paths from failed/non-file layers. */ +async function readProjectEnvLayerText(opts: { + readonly path: string; + readonly strictRead?: boolean; +}): Promise { + if (!opts.strictRead) { + return await readTextFile(opts.path); + } + try { + const selected = await stat(opts.path); + if (!selected.isFile()) { + throw new Error("Selected env layer is not a regular file"); + } + } catch (error: unknown) { + if (isRecord(error) && error.code === "ENOENT") { + try { + await lstat(opts.path); + } catch (entryError: unknown) { + if (isRecord(entryError) && entryError.code === "ENOENT") { + return null; + } + throw entryError; + } + } + // A dangling link is an existing invalid layer, not an absent one. + throw error; + } + // Preflight is not an atomic input fence; a failed read after it still refuses. + return await Bun.file(opts.path).text(); +} + async function readProjectEnvConfigFile(opts: { readonly path: string; readonly environment: string; + readonly strictRead?: boolean; }): Promise { - const text = await readTextFile(opts.path); + const text = await readProjectEnvLayerText(opts); if (text === null) { return { path: opts.path, @@ -731,6 +767,7 @@ async function readProjectEnvLayers(opts: { readonly projectRoot: string; readonly projectDir: string; readonly envName?: string | null; + readonly strictRead?: boolean; }) { const selection = await resolveProjectEnvSelection({ projectRoot: opts.projectRoot, @@ -739,6 +776,7 @@ async function readProjectEnvLayers(opts: { }); const defaultRead = await readProjectEnvConfigFile({ + strictRead: opts.strictRead, path: selection.defaultPath, environment: "default", }); @@ -746,10 +784,12 @@ async function readProjectEnvLayers(opts: { selection.overlayPath === null ? null : await readProjectEnvConfigFile({ + strictRead: opts.strictRead, path: selection.overlayPath, environment: selection.effectiveEnv ?? "default", }); const localDefaultRead = await readProjectEnvConfigFile({ + strictRead: opts.strictRead, path: selection.localDefaultPath, environment: "default", }); @@ -757,6 +797,7 @@ async function readProjectEnvLayers(opts: { selection.localOverlayPath === null ? null : await readProjectEnvConfigFile({ + strictRead: opts.strictRead, path: selection.localOverlayPath, environment: selection.effectiveEnv ?? "default", }); @@ -772,6 +813,7 @@ async function readProjectEnvLayers(opts: { await validatePrimaryLocalFile(primaryDefaultPath); inheritedReads.push( await readProjectEnvConfigFile({ + strictRead: opts.strictRead, path: primaryDefaultPath, environment: "default", }) @@ -784,6 +826,7 @@ async function readProjectEnvLayers(opts: { await validatePrimaryLocalFile(primaryOverlayPath); inheritedReads.push( await readProjectEnvConfigFile({ + strictRead: opts.strictRead, path: primaryOverlayPath, environment: selection.effectiveEnv, }) @@ -876,27 +919,49 @@ export async function resolveProjectEnvValue(opts: { }; } -export async function resolveProjectEnvConfig(opts: { +type ProjectEnvResolveOptions = { readonly projectRoot: string; readonly projectDir: string; readonly envName?: string | null; readonly serviceNames: readonly string[]; -}): Promise { - const layers = await readProjectEnvLayers(opts); - if (!layers) { - return null; +}; + +type ProjectEnvLayers = NonNullable< + Awaited> +>; + +/** + * Plan modern env bindings without acquiring a key or decrypting any value. + * Uses the same layers and target scopes as runtime injection. Omitted envName + * selects the configured default; null bypasses it. null output means no modern + * config, so callers can preserve their own legacy fallback. Errors intentionally + * omit parser diagnostics because YAML errors may include stored value excerpts. + * Selected env layer read failures refuse; only ENOENT means an absent layer. + * Project selection retains legacy behavior. Paths and names are private metadata, + * not a portable public plan or an atomic snapshot of configuration inputs. + */ +export async function resolveProjectEnvMetadata( + opts: ProjectEnvResolveOptions +): Promise { + try { + const layers = await readProjectEnvLayers({ ...opts, strictRead: true }); + return layers + ? projectEnvProjection({ layers, serviceNames: opts.serviceNames }) + .metadata + : null; + } catch { + throw new Error( + "Cannot resolve project env metadata: selected configuration is invalid or unreadable." + ); } - const { selection, envLayers, merged, files } = layers; - const keyText = await resolveProjectEnvKey({ - projectRoot: opts.projectRoot, - required: hasSecretEntries({ config: merged }), - }); - const globalEnv = resolveLayeredProjectEnvValuesForScopes({ - layers: envLayers, - scopeNames: ["global"], - keyText, - }); +} +/** One scope projection owns both metadata planning and runtime injection. */ +function projectEnvProjection(opts: { + readonly layers: ProjectEnvLayers; + readonly serviceNames: readonly string[]; +}) { + const { selection, envLayers, merged, files } = opts.layers; const declaredScopes = Object.keys(merged.values).sort((left, right) => left.localeCompare(right) ); @@ -904,80 +969,110 @@ export async function resolveProjectEnvConfig(opts: { const hostScopeConflictsWithService = knownServiceSet.has( PROJECT_ENV_HOST_SCOPE ); - const hostEnv = hostScopeConflictsWithService - ? {} - : resolveLayeredProjectEnvValuesForScopes({ - layers: envLayers, - scopeNames: [PROJECT_ENV_HOST_SCOPE], - keyText, - }); const unknownScopes = declaredScopes .filter((scope) => scope !== "global") .filter((scope) => scope !== PROJECT_ENV_HOST_SCOPE) .filter((scope) => !knownServiceSet.has(scope)); - const serviceSet = new Set([ ...opts.serviceNames, ...declaredScopes.filter((scope) => scope !== "global"), ]); + const serviceTargets = [...serviceSet].map((serviceName) => { + const composeScopeNames = + serviceName === "global" ? ["global"] : ["global", serviceName]; + return { + serviceName, + composeScopeNames, + hostScopeNames: hostScopeConflictsWithService + ? composeScopeNames + : [...composeScopeNames, PROJECT_ENV_HOST_SCOPE], + }; + }); const effectiveMetadata: EffectiveEnvMetadata = { global: resolveMetadata({ layers: envLayers, scopeNames: ["global"] }), }; const hostEffectiveMetadata: EffectiveEnvMetadata = {}; - const serviceEnv: Record> = {}; - const hostTargetEnv: Record> = {}; - for (const serviceName of serviceSet) { - const composeScopeNames = - serviceName === "global" ? ["global"] : ["global", serviceName]; + for (const { + serviceName, + composeScopeNames, + hostScopeNames, + } of serviceTargets) { effectiveMetadata[serviceName] = resolveMetadata({ layers: envLayers, scopeNames: composeScopeNames, }); hostEffectiveMetadata[serviceName] = resolveMetadata({ layers: envLayers, - scopeNames: hostScopeConflictsWithService - ? composeScopeNames - : [...composeScopeNames, PROJECT_ENV_HOST_SCOPE], - }); - serviceEnv[serviceName] = resolveLayeredProjectEnvValuesForScopes({ - layers: envLayers, - scopeNames: composeScopeNames, - keyText, - }); - hostTargetEnv[serviceName] = resolveLayeredProjectEnvValuesForScopes({ - layers: envLayers, - scopeNames: hostScopeConflictsWithService - ? composeScopeNames - : [...composeScopeNames, PROJECT_ENV_HOST_SCOPE], - keyText, + scopeNames: hostScopeNames, }); } + const globalHostScopeNames = hostScopeConflictsWithService + ? ["global"] + : ["global", PROJECT_ENV_HOST_SCOPE]; hostEffectiveMetadata.global = resolveMetadata({ layers: envLayers, - scopeNames: hostScopeConflictsWithService - ? ["global"] - : ["global", PROJECT_ENV_HOST_SCOPE], - }); - hostTargetEnv.global = resolveLayeredProjectEnvValuesForScopes({ - layers: envLayers, - scopeNames: hostScopeConflictsWithService - ? ["global"] - : ["global", PROJECT_ENV_HOST_SCOPE], - keyText, + scopeNames: globalHostScopeNames, }); - - return { + const metadata: ProjectEnvResolvedMetadata = { selection, + files, effectiveMetadata, hostEffectiveMetadata, + declaredScopes, + unknownScopes, + }; + return { + metadata, + serviceTargets, + globalHostScopeNames, + hostScopeNames: hostScopeConflictsWithService + ? [] + : [PROJECT_ENV_HOST_SCOPE], + }; +} + +export async function resolveProjectEnvConfig( + opts: ProjectEnvResolveOptions +): Promise { + const layers = await readProjectEnvLayers(opts); + if (!layers) { + return null; + } + const { envLayers, merged } = layers; + const keyText = await resolveProjectEnvKey({ + projectRoot: opts.projectRoot, + required: hasSecretEntries({ config: merged }), + }); + const projection = projectEnvProjection({ + layers, + serviceNames: opts.serviceNames, + }); + const resolveScopes = (scopeNames: readonly string[]) => + resolveLayeredProjectEnvValuesForScopes({ + layers: envLayers, + scopeNames, + keyText, + }); + const globalEnv = resolveScopes(["global"]); + const hostEnv = resolveScopes(projection.hostScopeNames); + const serviceEnv: Record> = {}; + const hostTargetEnv: Record> = {}; + for (const { + serviceName, + composeScopeNames, + hostScopeNames, + } of projection.serviceTargets) { + serviceEnv[serviceName] = resolveScopes(composeScopeNames); + hostTargetEnv[serviceName] = resolveScopes(hostScopeNames); + } + hostTargetEnv.global = resolveScopes(projection.globalHostScopeNames); + return { + ...projection.metadata, merged, - files, globalEnv, hostEnv, hostTargetEnv, serviceEnv, - declaredScopes, - unknownScopes, }; } diff --git a/tests/project-env-metadata.test.ts b/tests/project-env-metadata.test.ts new file mode 100644 index 000000000..4baf8354c --- /dev/null +++ b/tests/project-env-metadata.test.ts @@ -0,0 +1,469 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { + type ProjectEnvResolvedConfig, + type ProjectEnvValuesByScope, + resolveProjectEnvConfig, + resolveProjectEnvMetadata, +} from "../src/lib/project-env-config.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const roots: string[] = []; +const originalEnv = { + CI: process.env.CI, + HACK_EXECUTION_MODE: process.env.HACK_EXECUTION_MODE, + HACK_ENV_SECRET_KEY: process.env.HACK_ENV_SECRET_KEY, +}; +const plainSentinel = "synthetic-plaintext-must-not-leave-metadata"; +const cipherSentinel = "v1:synthetic-ciphertext-not-valid-for-decryption"; +const secure = { secure: cipherSentinel }; + +afterEach(async () => { + for (const [key, value] of Object.entries(originalEnv)) { + restoreEnv(key, value); + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture() { + Reflect.deleteProperty(process.env, "HACK_ENV_SECRET_KEY"); + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-env-metadata-")) + ); + roots.push(root); + const projectRoot = join(root, "primary"); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ name: "metadata-fixture", env: { defaultOverlay: "qa" } }) + ); + return { root, projectRoot, projectDir, serviceNames: ["api", "web"] }; +} + +async function writeLayer( + projectDir: string, + file: string, + values: ProjectEnvValuesByScope +) { + await writeFile( + join(projectDir, file), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values, + }) + ); +} + +function metadataFromRuntime(resolved: ProjectEnvResolvedConfig | null) { + if (!resolved) { + throw new Error("Expected modern runtime env config"); + } + const { + selection, + files, + effectiveMetadata, + hostEffectiveMetadata, + declaredScopes, + unknownScopes, + } = resolved; + return { + selection, + files, + effectiveMetadata, + hostEffectiveMetadata, + declaredScopes, + unknownScopes, + }; +} + +async function git(cwd: string, args: string[]) { + const child = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [status, error] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + if (status !== 0) { + throw new Error(error); + } +} + +test("metadata keeps absent modern config distinct from legacy env fallback", async () => { + const f = await fixture(); + await writeFile( + join(f.projectDir, ".env"), + `LEGACY_VALUE=${plainSentinel}\n` + ); + expect(await resolveProjectEnvMetadata(f)).toBeNull(); + expect(await resolveProjectEnvConfig(f)).toBeNull(); + expect((await readdir(f.projectDir)).sort()).toEqual([ + ".env", + "hack.config.json", + ]); +}); + +test("metadata selection matches default, explicit and null overlays without exposing stored values", async () => { + const f = await fixture(); + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { BASE: plainSentinel, WINNER: secure }, + }); + await writeLayer(f.projectDir, "hack.env.qa.yaml", { + global: { WINNER: plainSentinel, QA: secure }, + }); + await writeLayer(f.projectDir, "hack.env.preview.yaml", { + api: { PREVIEW: secure }, + }); + for (const [envName, effectiveEnv, expected] of [ + [ + undefined, + "qa", + { + BASE: { scope: "global", secret: false }, + WINNER: { scope: "global", secret: false }, + QA: { scope: "global", secret: true }, + }, + ], + [ + "preview", + "preview", + { + BASE: { scope: "global", secret: false }, + WINNER: { scope: "global", secret: true }, + }, + ], + [ + null, + null, + { + BASE: { scope: "global", secret: false }, + WINNER: { scope: "global", secret: true }, + }, + ], + [ + "missing", + "missing", + { + BASE: { scope: "global", secret: false }, + WINNER: { scope: "global", secret: true }, + }, + ], + ] as const) { + const result = await resolveProjectEnvMetadata({ ...f, envName }); + expect(result?.selection.effectiveEnv).toBe(effectiveEnv); + expect(result?.selection.defaultEnv).toBe("qa"); + expect(result?.effectiveMetadata.global).toEqual(expected); + expect(Object.keys(result ?? {}).sort()).toEqual([ + "declaredScopes", + "effectiveMetadata", + "files", + "hostEffectiveMetadata", + "selection", + "unknownScopes", + ]); + expect(JSON.stringify(result)).not.toContain(plainSentinel); + expect(JSON.stringify(result)).not.toContain(cipherSentinel); + } + expect( + await Bun.file(join(f.projectRoot, PROJECT_ENV_KEY_FILENAME)).exists() + ).toBe(false); + // Positive control: the ordinary resolver still requires a key for these entries. + await expect(resolveProjectEnvConfig(f)).rejects.toThrow( + "Missing project env key" + ); +}); + +test("metadata does not read project keys, decrypt invalid ciphertext or materialize env files", async () => { + const f = await fixture(); + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { ENCRYPTED: secure }, + api: { PLAIN: plainSentinel }, + }); + // A key lookup would try to read this directory as a file and fail. + await mkdir(join(f.projectRoot, PROJECT_ENV_KEY_FILENAME)); + process.env.HACK_ENV_SECRET_KEY = + "synthetic-invalid-key-must-not-be-consumed"; + const before = await readdir(f.projectDir); + const result = await resolveProjectEnvMetadata({ ...f, envName: null }); + expect(result?.effectiveMetadata.api).toEqual({ + ENCRYPTED: { scope: "global", secret: true }, + PLAIN: { scope: "api", secret: false }, + }); + expect(JSON.stringify(result)).not.toContain(plainSentinel); + expect(JSON.stringify(result)).not.toContain(cipherSentinel); + expect(JSON.stringify(result)).not.toContain(process.env.HACK_ENV_SECRET_KEY); + expect(await readdir(f.projectDir)).toEqual(before); + expect(await readdir(join(f.projectRoot, PROJECT_ENV_KEY_FILENAME))).toEqual( + [] + ); + await expect( + resolveProjectEnvConfig({ ...f, envName: null }) + ).rejects.toThrow(); +}); + +test("one shared target projection preserves host collision and unknown-scope semantics", async () => { + const f = await fixture(); + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { SHARED: "base", BASE: false }, + api: { SHARED: "api", API_ONLY: 3 }, + host: { SHARED: "host", HOST_ONLY: "local" }, + extra: { UNKNOWN: "kept" }, + }); + await writeLayer(f.projectDir, "hack.env.qa.yaml", { + global: { SHARED: "overlay", OVERLAY: "later" }, + host: { BASE: null }, + }); + for (const serviceNames of [ + ["api", "web"], + ["api", "host"], + ["api", "global", "api"], + ]) { + const opts = { ...f, serviceNames }; + const metadata = await resolveProjectEnvMetadata(opts); + const runtime = await resolveProjectEnvConfig(opts); + expect(metadata).toEqual(metadataFromRuntime(runtime)); + expect(metadata?.unknownScopes).toEqual(["extra"]); + expect(metadata?.declaredScopes).toEqual([ + "api", + "extra", + "global", + "host", + ]); + expect(metadata?.effectiveMetadata.api?.SHARED).toEqual({ + scope: "global", + secret: false, + }); + expect(runtime?.serviceEnv.api?.SHARED).toBe("overlay"); + const collision = serviceNames.includes("host"); + expect(metadata?.hostEffectiveMetadata.api?.HOST_ONLY).toEqual( + collision ? undefined : { scope: "host", secret: false } + ); + expect(metadata?.hostEffectiveMetadata.api?.BASE).toEqual( + collision ? { scope: "global", secret: false } : undefined + ); + expect(runtime?.hostEnv).toEqual( + collision ? {} : { SHARED: "host", HOST_ONLY: "local" } + ); + expect(runtime?.hostTargetEnv.api?.SHARED).toBe("overlay"); + } +}); + +test("metadata follows layer-first unset and reintroduction across global/service/host scopes", async () => { + const f = await fixture(); + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { REINTRODUCE: secure, DELETE: secure }, + api: { LAYER_WINS: secure, DELETE: plainSentinel }, + host: { LAYER_WINS: secure }, + }); + await writeLayer(f.projectDir, "hack.env.qa.yaml", { + global: { LAYER_WINS: plainSentinel, REINTRODUCE: null, DELETE: null }, + }); + await writeLayer(f.projectDir, "hack.env.local.yaml", { + api: { REINTRODUCE: plainSentinel }, + host: { LAYER_WINS: null }, + }); + await writeLayer(f.projectDir, "hack.env.qa.local.yaml", { + global: { LAYER_WINS: secure }, + host: { REINTRODUCE: secure }, + }); + const result = await resolveProjectEnvMetadata(f); + expect(result?.effectiveMetadata.api).toEqual({ + REINTRODUCE: { scope: "api", secret: false }, + LAYER_WINS: { scope: "global", secret: true }, + }); + expect(result?.hostEffectiveMetadata.api).toEqual({ + REINTRODUCE: { scope: "host", secret: true }, + LAYER_WINS: { scope: "global", secret: true }, + }); + expect(result?.effectiveMetadata.global).toEqual({ + LAYER_WINS: { scope: "global", secret: true }, + }); + expect(JSON.stringify(result)).not.toContain(plainSentinel); + expect(JSON.stringify(result)).not.toContain(cipherSentinel); +}); + +test("worktree metadata observes primary/local precedence and changes without copying keys or state", async () => { + Reflect.deleteProperty(process.env, "CI"); + Reflect.deleteProperty(process.env, "HACK_EXECUTION_MODE"); + const f = await fixture(); + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { TRACKED: plainSentinel, ORDER: plainSentinel }, + }); + await writeLayer(f.projectDir, "hack.env.qa.yaml", { + api: { ORDER: secure }, + }); + await writeFile( + join(f.projectDir, ".gitignore"), + "hack.env.local.yaml\nhack.env.*.local.yaml\n" + ); + await git(f.projectRoot, ["init", "-b", "main"]); + await git(f.projectRoot, ["add", "."]); + await git(f.projectRoot, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-m", + "fixture", + ]); + const projectRoot = join(f.root, "linked"); + await git(f.projectRoot, ["worktree", "add", "-b", "metadata", projectRoot]); + const projectDir = join(projectRoot, ".hack"); + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { DO_NOT_INHERIT: secure }, + }); + await writeLayer(f.projectDir, "hack.env.local.yaml", { + global: { ORDER: secure, PRIMARY: secure }, + }); + await writeLayer(f.projectDir, "hack.env.qa.local.yaml", { + api: { ORDER: plainSentinel }, + global: { PRIMARY: null }, + }); + const opts = { projectRoot, projectDir, serviceNames: f.serviceNames }; + expect( + (await resolveProjectEnvMetadata(opts))?.effectiveMetadata.api + ).toEqual({ + TRACKED: { scope: "global", secret: false }, + ORDER: { scope: "api", secret: false }, + }); + expect(await Bun.file(join(projectDir, "hack.env.local.yaml")).exists()).toBe( + false + ); + await writeLayer(projectDir, "hack.env.local.yaml", { + global: { ORDER: plainSentinel, LOCAL: secure }, + }); + await writeLayer(projectDir, "hack.env.qa.local.yaml", { + api: { ORDER: secure }, + host: { LOCAL: null }, + }); + const primaryBytes = await readFile( + join(f.projectDir, "hack.env.qa.local.yaml") + ); + const result = await resolveProjectEnvMetadata(opts); + expect(result?.files).toEqual( + ["hack.env.default.yaml", "hack.env.qa.yaml"] + .map((file) => join(projectDir, file)) + .concat( + ["hack.env.local.yaml", "hack.env.qa.local.yaml"].map((file) => + join(f.projectDir, file) + ), + ["hack.env.local.yaml", "hack.env.qa.local.yaml"].map((file) => + join(projectDir, file) + ) + ) + ); + expect(result?.effectiveMetadata.api?.ORDER).toEqual({ + scope: "api", + secret: true, + }); + expect(result?.effectiveMetadata.api?.LOCAL).toEqual({ + scope: "global", + secret: true, + }); + expect(result?.hostEffectiveMetadata.api?.LOCAL).toBeUndefined(); + expect(await readFile(join(f.projectDir, "hack.env.qa.local.yaml"))).toEqual( + primaryBytes + ); + expect( + await Bun.file(join(projectRoot, PROJECT_ENV_KEY_FILENAME)).exists() + ).toBe(false); + await writeLayer(f.projectDir, "hack.env.local.yaml", { + global: { UPDATED: secure }, + }); + expect( + (await resolveProjectEnvMetadata(opts))?.effectiveMetadata.api?.UPDATED + ).toEqual({ scope: "global", secret: true }); +}); + +test("metadata errors omit stored values and parser excerpts without changing legacy error behavior", async () => { + const f = await fixture(); + await writeFile( + join(f.projectDir, "hack.env.default.yaml"), + `values: [${plainSentinel}\n` + ); + let failure: unknown; + try { + await resolveProjectEnvMetadata(f); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toBe( + "Error: Cannot resolve project env metadata: selected configuration is invalid or unreadable." + ); + expect(String(failure)).not.toContain(plainSentinel); + expect(failure).not.toHaveProperty("cause"); + await expect(resolveProjectEnvConfig(f)).rejects.toThrow("Failed to parse"); +}); + +test("metadata refuses unreadable selected layers instead of silently omitting them", async () => { + const f = await fixture(); + const message = + "Cannot resolve project env metadata: selected configuration is invalid or unreadable."; + for (const file of [ + "hack.env.default.yaml", + "hack.env.qa.yaml", + "hack.env.local.yaml", + "hack.env.qa.local.yaml", + ]) { + const path = join(f.projectDir, file); + await mkdir(path); + await expect(resolveProjectEnvMetadata(f)).rejects.toThrow(message); + // Existing runtime lookup behavior remains unchanged by strict planning reads. + expect(await resolveProjectEnvConfig(f)).toBeNull(); + await rm(path, { recursive: true }); + } + await writeLayer(f.projectDir, "hack.env.default.yaml", { + global: { BASE: plainSentinel }, + }); + await mkdir(join(f.projectDir, "hack.env.qa.yaml")); + await expect(resolveProjectEnvMetadata(f)).rejects.toThrow(message); + expect((await resolveProjectEnvConfig(f))?.globalEnv).toEqual({ + BASE: plainSentinel, + }); + expect( + (await resolveProjectEnvMetadata({ ...f, envName: null })) + ?.effectiveMetadata.global + ).toEqual({ BASE: { scope: "global", secret: false } }); +}); + +test("metadata rejects a dangling selected layer and retains readable symlink behavior", async () => { + const f = await fixture(); + const overlay = join(f.projectDir, "hack.env.qa.yaml"); + await symlink(join(f.projectDir, "missing.yaml"), overlay); + await expect(resolveProjectEnvMetadata(f)).rejects.toThrow( + "Cannot resolve project env metadata: selected configuration is invalid or unreadable." + ); + expect(await resolveProjectEnvConfig(f)).toBeNull(); + await writeLayer(f.projectDir, "missing.yaml", { + global: { PRESENT: plainSentinel }, + }); + const metadata = await resolveProjectEnvMetadata(f); + expect(metadata?.effectiveMetadata.global).toEqual({ + PRESENT: { scope: "global", secret: false }, + }); + expect(metadata).toEqual( + metadataFromRuntime(await resolveProjectEnvConfig(f)) + ); + expect(JSON.stringify(metadata)).not.toContain(plainSentinel); +});