Skip to content

Commit 844d431

Browse files
author
hack-cli-tests
committed
feat(env): add env overlays and doctor migration checks
1 parent d7b87dd commit 844d431

11 files changed

Lines changed: 812 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
* Merge pull request #29 from hack-dance/codex/define-env-key-rotation-and-recovery-flows-16o779 ([408f664](https://github.com/hack-dance/hack/commit/408f664)), closes [#29](https://github.com/hack-dance/hack/issues/29)
88
* docs: clarify portable env recovery flows ([5bb98db](https://github.com/hack-dance/hack/commit/5bb98db))
99

10+
## Next
11+
12+
* feat(env): add `--env=<name>` overlays on top of the base `.hack/hack.env.json` contract and `.hack/.env` compatibility file
13+
* feat(env): allow project defaults via `defaultEnvConfig` / `default_env_config`
14+
* feat(doctor): warn when repos are still on legacy local-only plaintext env mode instead of the bundled portable backend flow
15+
1016
## <small>1.21.2 (2026-03-24)</small>
1117

1218
* Merge branch 'main' into codex/linear-mention-hack-462-preserve-.env-compatibility-whil-6icfe6 ([7e821e4](https://github.com/hack-dance/hack/commit/7e821e4))

docs/env.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Opt-in portable bundle mode:
3535
- Set `controlPlane.secrets.storePlaintextInBackend=true` in project config when you want `plain_env` values mirrored into the configured backend alongside secret values.
3636
- In that mode, the backend copy becomes the portable source of truth and `.hack/.env` becomes a compatibility materialization target.
3737
- This works best with the repo-relative `encrypted_file` backend, because the encrypted file plus key can move with the repo onboarding flow.
38+
- Optional env overlays can then be selected with `--env=<name>`, which layers `.hack/.env.<name>` and backend-scoped values on top of the base `.hack/.env` values.
39+
- Projects can set `defaultEnvConfig` (or `default_env_config`) in `.hack/hack.config.json` so `hack up`, `hack env`, and related commands automatically use that overlay unless `--env=base` is passed.
3840

3941
Current status:
4042

@@ -66,6 +68,36 @@ See `docs/plans/2026-03-13-env-portability-and-secret-management-design.md` for
6668
- `process.env` (ambient fallback): supplies `plain_env` values when `.hack/.env` is missing a key.
6769
- Configured secret backend (`controlPlane.secrets.backend`): stores secret values for `source: "keychain"` contract vars.
6870

71+
## Env overlay model
72+
73+
Hack now supports a Pulumi-like overlay convention without changing the base contract file:
74+
75+
- Base contract: `.hack/hack.env.json`
76+
- Base plaintext compatibility file: `.hack/.env`
77+
- Optional overlay plaintext compatibility file: `.hack/.env.<env>`
78+
- Optional overlay selection: `hack ... --env=qa`
79+
- Optional project default: `"defaultEnvConfig": "qa"` in `.hack/hack.config.json`
80+
81+
Resolution order:
82+
83+
1. selected overlay value (`--env=qa` or `defaultEnvConfig`)
84+
2. base value
85+
3. `process.env` fallback for `plain_env`
86+
87+
Secret-backed overlay values are stored in the configured backend under env-scoped keys, so one encrypted backend can carry base plus multiple overlays together.
88+
89+
Use `--env=base` to bypass the configured default overlay and operate on the base env only.
90+
91+
## Migrating older repos
92+
93+
For older repos that only used `.hack/.env`:
94+
95+
1. Keep `.hack/hack.env.json` as the base contract.
96+
2. Enable `controlPlane.secrets.storePlaintextInBackend=true`.
97+
3. Re-save existing values with `hack env set KEY=VALUE` so plaintext values are mirrored into the portable backend bundle.
98+
4. Add overlay-specific values with `hack env set --env=qa KEY=VALUE` or `hack env set --env=prod KEY=VALUE`.
99+
5. Run `hack doctor` to confirm whether the repo is still on legacy local-only plaintext mode or is using bundled base/overlay envs.
100+
69101
## Contract format (`.hack/hack.env.json`)
70102

71103
```json

src/cli/options.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ export const optProject = defineOption({
1919
"Target a registered project by name (from ~/.hack/projects.json)",
2020
} as const);
2121

22+
export const optEnv = defineOption({
23+
name: "env",
24+
type: "string",
25+
long: "--env",
26+
valueHint: "<name|base>",
27+
description:
28+
"Apply an optional env overlay by name (use 'base' to bypass overlays)",
29+
} as const);
30+
2231
export const optBranch = defineOption({
2332
name: "branch",
2433
type: "string",

src/commands/doctor.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
GLOBAL_LOGGING_COMPOSE_FILENAME,
2222
GLOBAL_LOGGING_DIR_NAME,
2323
HACK_PROJECT_DIR_PRIMARY,
24+
PROJECT_ENV_CONTRACT_FILENAME,
2425
} from "../constants.ts";
2526
import { resolveGatewayConfig } from "../control-plane/extensions/gateway/config.ts";
2627
import { listGatewayTokens } from "../control-plane/extensions/gateway/tokens.ts";
@@ -42,6 +43,7 @@ import {
4243
writeTextFileIfChanged,
4344
} from "../lib/fs.ts";
4445
import { resolveHackInvocation } from "../lib/hack-cli.ts";
46+
import { resolveEnvFilePath } from "../lib/hack-env.ts";
4547
import {
4648
ensureBundledMutagenInstalled,
4749
getManagedMutagenAgentBundlePath,
@@ -351,6 +353,9 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
351353
results.push(
352354
await runCheck(s, "DEV_HOST", () => checkDevHost({ startDir }))
353355
);
356+
results.push(
357+
await runCheck(s, "env mode", () => checkProjectEnvMode({ startDir }))
358+
);
354359
results.push(
355360
await runCheck(
356361
s,
@@ -373,6 +378,12 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
373378
message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`,
374379
durationMs: 0,
375380
});
381+
results.push({
382+
name: "env mode",
383+
status: "warn",
384+
message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`,
385+
durationMs: 0,
386+
});
376387
results.push({
377388
name: "tickets git",
378389
status: "warn",
@@ -1442,6 +1453,84 @@ async function checkProjectTicketsGitHealth({
14421453
};
14431454
}
14441455

1456+
async function checkProjectEnvMode({
1457+
startDir,
1458+
}: {
1459+
readonly startDir: string;
1460+
}): Promise<CheckResult> {
1461+
const ctx = await findProjectContext(startDir);
1462+
if (!ctx) {
1463+
return {
1464+
name: "env mode",
1465+
status: "warn",
1466+
message: `Missing ${HACK_PROJECT_DIR_PRIMARY}/ (run 'hack init' in a repo)`,
1467+
};
1468+
}
1469+
1470+
const cfg = await readProjectConfig(ctx);
1471+
const configPath = cfg.configPath ?? ctx.configFile;
1472+
if (cfg.parseError) {
1473+
return {
1474+
name: "env mode",
1475+
status: "warn",
1476+
message: `Invalid ${configPath}: ${cfg.parseError}`,
1477+
};
1478+
}
1479+
1480+
const contractPath = resolve(ctx.projectDir, PROJECT_ENV_CONTRACT_FILENAME);
1481+
const contractExists = await pathExists(contractPath);
1482+
if (!contractExists) {
1483+
return {
1484+
name: "env mode",
1485+
status: "warn",
1486+
message: `Missing ${contractPath}`,
1487+
};
1488+
}
1489+
1490+
const controlPlane = await readControlPlaneConfig({
1491+
projectDir: ctx.projectDir,
1492+
});
1493+
if (controlPlane.parseError) {
1494+
return {
1495+
name: "env mode",
1496+
status: "warn",
1497+
message: controlPlane.parseError,
1498+
};
1499+
}
1500+
1501+
const storePlaintextInBackend =
1502+
controlPlane.config.secrets.storePlaintextInBackend;
1503+
if (!storePlaintextInBackend) {
1504+
return {
1505+
name: "env mode",
1506+
status: "warn",
1507+
message:
1508+
"Portable plaintext bundling is disabled. Legacy .hack/.env values stay machine-local until controlPlane.secrets.storePlaintextInBackend=true and values are re-saved with hack env set.",
1509+
};
1510+
}
1511+
1512+
if (!cfg.defaultEnvConfig) {
1513+
return {
1514+
name: "env mode",
1515+
status: "ok",
1516+
message: "Base env only (.hack/.env + bundled backend)",
1517+
};
1518+
}
1519+
1520+
const overlayPath = resolveEnvFilePath({
1521+
projectDir: ctx.projectDir,
1522+
envName: cfg.defaultEnvConfig,
1523+
});
1524+
const overlayExists = await pathExists(overlayPath);
1525+
return {
1526+
name: "env mode",
1527+
status: overlayExists ? "ok" : "warn",
1528+
message: overlayExists
1529+
? `defaultEnvConfig=${cfg.defaultEnvConfig} overlays ${overlayPath} on top of .hack/.env`
1530+
: `defaultEnvConfig=${cfg.defaultEnvConfig} is set, but ${overlayPath} does not exist yet; base env will still apply`,
1531+
};
1532+
}
1533+
14451534
async function checkCaddyHostMapping({
14461535
startDir,
14471536
}: {

src/commands/env.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import {
88
defineOption,
99
withHandler,
1010
} from "../cli/command.ts";
11-
import { optJson, optPath, optProject } from "../cli/options.ts";
12-
import { PROJECT_ENV_FILENAME } from "../constants.ts";
11+
import { optEnv, optJson, optPath, optProject } from "../cli/options.ts";
1312
import { readControlPlaneConfig } from "../control-plane/sdk/config.ts";
1413
import { updateGlobalConfig } from "../lib/config.ts";
1514
import type {
@@ -19,13 +18,16 @@ import type {
1918
import {
2019
readHackEnvRuntimeConfig,
2120
removeDotEnvKey,
21+
resolveEnvFilePath,
22+
resolveEnvSecretKey,
2223
resolveHackEnv,
2324
upsertDotEnvValue,
2425
} from "../lib/hack-env.ts";
2526
import type { ProjectContext } from "../lib/project.ts";
2627
import {
2728
defaultProjectSlugFromPath,
2829
findProjectContext,
30+
parseEnvConfigSelection,
2931
readProjectConfig,
3032
sanitizeProjectSlug,
3133
} from "../lib/project.ts";
@@ -56,7 +58,7 @@ const listSpec = defineCommand({
5658
name: "list",
5759
summary: "List env contract vars and resolution state",
5860
group: "Project",
59-
options: [optPath, optProject, optJson, optShowSecrets],
61+
options: [optPath, optProject, optEnv, optJson, optShowSecrets],
6062
positionals: [],
6163
subcommands: [],
6264
} as const);
@@ -65,7 +67,7 @@ const setSpec = defineCommand({
6567
name: "set",
6668
summary: "Set an env value (.hack/.env or keychain)",
6769
group: "Project",
68-
options: [optPath, optProject, optSecret],
70+
options: [optPath, optProject, optEnv, optSecret],
6971
positionals: [{ name: "spec", required: false }],
7072
subcommands: [],
7173
} as const);
@@ -74,7 +76,7 @@ const unsetSpec = defineCommand({
7476
name: "unset",
7577
summary: "Unset an env value (.hack/.env and keychain)",
7678
group: "Project",
77-
options: [optPath, optProject],
79+
options: [optPath, optProject, optEnv],
7880
positionals: [{ name: "key", required: false }],
7981
subcommands: [],
8082
} as const);
@@ -403,6 +405,16 @@ async function resolveProjectName(project: ProjectContext): Promise<string> {
403405
return sanitizeProjectSlug(raw.length > 0 ? raw : derived);
404406
}
405407

408+
function resolveRequestedEnvName(input: {
409+
readonly envOption: string | undefined;
410+
}): string | null | undefined {
411+
const envName = parseEnvConfigSelection(input.envOption);
412+
if (input.envOption !== undefined && envName === undefined) {
413+
throw new CliUsageError("Invalid --env value.");
414+
}
415+
return envName;
416+
}
417+
406418
async function resolveConfiguredSecretStore(input: {
407419
readonly project: ProjectContext;
408420
readonly projectName: string;
@@ -433,17 +445,28 @@ const handleEnvList: CommandHandlerFor<typeof listSpec> = async ({
433445
const projectName = await resolveProjectName(project);
434446
const showSecrets = args.options.showSecrets === true;
435447
const json = args.options.json === true;
448+
const envName = resolveRequestedEnvName({
449+
envOption: args.options.env,
450+
});
436451

437452
const resolved = await resolveHackEnv({
438453
projectDir: project.projectDir,
439454
projectName,
455+
envName,
440456
});
441457

442458
if (json) {
443459
process.stdout.write(
444460
`${JSON.stringify(
445461
{
446462
project: projectName,
463+
env_selection: {
464+
requested: resolved.envSelection.requestedEnv,
465+
default: resolved.envSelection.defaultEnv,
466+
effective: resolved.envSelection.effectiveEnv,
467+
overlay_path: resolved.envSelection.overlayPath,
468+
overlay_exists: resolved.envSelection.overlayExists,
469+
},
447470
storage: serializeEnvStorageForJson({ storage: resolved.storage }),
448471
vars: resolved.values.map((v) => ({
449472
key: v.key,
@@ -472,6 +495,12 @@ const handleEnvList: CommandHandlerFor<typeof listSpec> = async ({
472495
await display.kv({
473496
title: "Env storage",
474497
entries: [
498+
[
499+
"env_selection",
500+
resolved.envSelection.effectiveEnv
501+
? `${resolved.envSelection.effectiveEnv} (base .hack/.env overlaid by ${resolved.envSelection.overlayPath})`
502+
: "base (.hack/.env only)",
503+
],
475504
[
476505
"contract",
477506
`${resolved.storage.contract.path} (committed metadata only; no values are stored here)`,
@@ -597,6 +626,9 @@ const handleEnvSet: CommandHandlerFor<typeof setSpec> = async ({
597626
const runtimeConfig = await readHackEnvRuntimeConfig({
598627
projectDir: project.projectDir,
599628
});
629+
const envName = resolveRequestedEnvName({
630+
envOption: args.options.env,
631+
});
600632

601633
const spec = (args.positionals.spec ?? "").trim();
602634
const [keyFromSpec, valueFromSpec] = parseKeyValueSpec(spec);
@@ -610,20 +642,29 @@ const handleEnvSet: CommandHandlerFor<typeof setSpec> = async ({
610642

611643
if (storeSecret) {
612644
const store = await resolveConfiguredSecretStore({ project, projectName });
613-
await store.set({ key, value });
645+
await store.set({
646+
key: resolveEnvSecretKey({ key, envName }),
647+
value,
648+
});
614649
logger.success({
615-
message: `Stored secret "${key}" in ${formatSecretStoreDescriptor({ descriptor: store.descriptor })}`,
650+
message: `Stored secret "${key}" in ${formatSecretStoreDescriptor({ descriptor: store.descriptor })}${envName ? ` (env ${envName})` : ""}`,
616651
});
617652
return 0;
618653
}
619654

620-
const envFile = resolve(project.projectDir, PROJECT_ENV_FILENAME);
655+
const envFile = resolveEnvFilePath({
656+
projectDir: project.projectDir,
657+
envName,
658+
});
621659
const [result, mirroredToBackend] = await Promise.all([
622660
upsertDotEnvValue({ envFile, key, value }),
623661
runtimeConfig.storePlaintextInBackend
624662
? resolveConfiguredSecretStore({ project, projectName }).then(
625663
async (store) => {
626-
await store.set({ key, value });
664+
await store.set({
665+
key: resolveEnvSecretKey({ key, envName }),
666+
value,
667+
});
627668
return formatSecretStoreDescriptor({
628669
descriptor: store.descriptor,
629670
});
@@ -655,11 +696,14 @@ const handleEnvUnset: CommandHandlerFor<typeof unsetSpec> = async ({
655696
});
656697
const projectName = await resolveProjectName(project);
657698
const store = await resolveConfiguredSecretStore({ project, projectName });
699+
const envName = resolveRequestedEnvName({
700+
envOption: args.options.env,
701+
});
658702

659703
const key = await resolveEnvKey({ key: (args.positionals.key ?? "").trim() });
660704

661705
const ok = await confirm({
662-
message: `Unset "${key}" from ${project.projectDir}/.env and ${formatSecretStoreDescriptor({ descriptor: store.descriptor })}?`,
706+
message: `Unset "${key}" from ${resolveEnvFilePath({ projectDir: project.projectDir, envName })} and ${formatSecretStoreDescriptor({ descriptor: store.descriptor })}${envName ? ` (env ${envName})` : ""}?`,
663707
initialValue: true,
664708
});
665709
if (isCancel(ok)) {
@@ -669,10 +713,15 @@ const handleEnvUnset: CommandHandlerFor<typeof unsetSpec> = async ({
669713
return 0;
670714
}
671715

672-
const envFile = resolve(project.projectDir, PROJECT_ENV_FILENAME);
716+
const envFile = resolveEnvFilePath({
717+
projectDir: project.projectDir,
718+
envName,
719+
});
673720
const [dotenvResult, secretDeleted] = await Promise.all([
674721
removeDotEnvKey({ envFile, key }),
675-
store.delete({ key }),
722+
store.delete({
723+
key: resolveEnvSecretKey({ key, envName }),
724+
}),
676725
]);
677726

678727
logger.success({

0 commit comments

Comments
 (0)