Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
223 changes: 159 additions & 64 deletions src/lib/project-env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,12 +105,18 @@ type EffectiveEnvMetadata = Record<
Record<string, { readonly scope: string; readonly secret: boolean }>
>;

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<Record<string, string>>;
readonly hostEnv: Readonly<Record<string, string>>;
readonly hostTargetEnv: Readonly<
Expand All @@ -119,8 +125,6 @@ export type ProjectEnvResolvedConfig = {
readonly serviceEnv: Readonly<
Record<string, Readonly<Record<string, string>>>
>;
readonly declaredScopes: readonly string[];
readonly unknownScopes: readonly string[];
};

export function selectProjectEnvValues(opts: {
Expand Down Expand Up @@ -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<string | null> {
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<ProjectEnvConfigReadResult> {
const text = await readTextFile(opts.path);
const text = await readProjectEnvLayerText(opts);
if (text === null) {
return {
path: opts.path,
Expand Down Expand Up @@ -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,
Expand All @@ -739,24 +776,28 @@ async function readProjectEnvLayers(opts: {
});

const defaultRead = await readProjectEnvConfigFile({
strictRead: opts.strictRead,
path: selection.defaultPath,
environment: "default",
});
const overlayRead =
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",
});
const localOverlayRead =
selection.localOverlayPath === null
? null
: await readProjectEnvConfigFile({
strictRead: opts.strictRead,
path: selection.localOverlayPath,
environment: selection.effectiveEnv ?? "default",
});
Expand All @@ -772,6 +813,7 @@ async function readProjectEnvLayers(opts: {
await validatePrimaryLocalFile(primaryDefaultPath);
inheritedReads.push(
await readProjectEnvConfigFile({
strictRead: opts.strictRead,
path: primaryDefaultPath,
environment: "default",
})
Expand All @@ -784,6 +826,7 @@ async function readProjectEnvLayers(opts: {
await validatePrimaryLocalFile(primaryOverlayPath);
inheritedReads.push(
await readProjectEnvConfigFile({
strictRead: opts.strictRead,
path: primaryOverlayPath,
environment: selection.effectiveEnv,
})
Expand Down Expand Up @@ -876,108 +919,160 @@ 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<ProjectEnvResolvedConfig | null> {
const layers = await readProjectEnvLayers(opts);
if (!layers) {
return null;
};

type ProjectEnvLayers = NonNullable<
Awaited<ReturnType<typeof readProjectEnvLayers>>
>;

/**
* 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<ProjectEnvResolvedMetadata | null> {
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)
);
const knownServiceSet = new Set(opts.serviceNames);
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<string>([
...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<string, Record<string, string>> = {};
const hostTargetEnv: Record<string, Record<string, string>> = {};
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<ProjectEnvResolvedConfig | null> {
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<string, Record<string, string>> = {};
const hostTargetEnv: Record<string, Record<string, string>> = {};
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,
};
}

Expand Down
Loading
Loading