Skip to content

Commit be98476

Browse files
authored
Merge pull request #43 from hack-dance/fix/host-runtime-ca-trust
feat(env): improve host command env injection and trust
2 parents a84452e + bd2c074 commit be98476

17 files changed

Lines changed: 1850 additions & 207 deletions

docs/cli.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1638,6 +1638,11 @@ Runs a host command with injected env. Default target is `host`, which applies t
16381638
view and any `host` scope overrides. Use `--target compose` to preserve the container-oriented
16391639
compose view.
16401640

1641+
If you are checking a variable value, prefer `printenv KEY` or
1642+
`sh -lc 'printf "%s\n" "$KEY"'`. `hack env exec -- echo $KEY` expands `$KEY` in your current shell
1643+
before Hack injects the env. Use `--shell 'echo $KEY'` when you want Hack to launch the child shell
1644+
after env injection.
1645+
16411646
Options:
16421647

16431648
| Flag | Type | Default | Description |
@@ -1647,6 +1652,7 @@ Options:
16471652
| `--env <name\|base>` | string | - | Apply an optional env overlay by name |
16481653
| `--service <name>` | string | - | Resolve values for one service scope |
16491654
| `--target <host\|compose>` | string | `host` | Host-local env view for host commands, or raw compose view |
1655+
| `--shell <command>` | string | - | Run a shell command string via `/bin/sh -lc` after env injection |
16501656

16511657
#### hack env shell
16521658

@@ -1711,6 +1717,11 @@ Runs a host command with injected env. Default target is `host`, which applies t
17111717
view and any `host` scope overrides. Use `--target compose` to preserve the container-oriented
17121718
compose view while still running on the host.
17131719

1720+
If you are checking a variable value, prefer `printenv KEY` or
1721+
`sh -lc 'printf "%s\n" "$KEY"'`. `hack host exec -- echo $KEY` expands `$KEY` in your current shell
1722+
before Hack injects the env. Use `--shell 'echo $KEY'` when you want Hack to launch the child shell
1723+
after env injection.
1724+
17141725
Options:
17151726

17161727
| Flag | Type | Default | Description |
@@ -1720,6 +1731,7 @@ Options:
17201731
| `--env <name\|base>` | string | - | Apply an optional env overlay by name |
17211732
| `--scope <name>` | string | - | Resolve values for one env scope while still running on the host |
17221733
| `--target <host\|compose>` | string | `host` | Host-local env view for host commands, or raw compose view |
1734+
| `--shell <command>` | string | - | Run a shell command string via `/bin/sh -lc` after env injection |
17231735

17241736
#### hack host shell
17251737

@@ -2515,7 +2527,7 @@ Options:
25152527
| Flag | Type | Default | Description |
25162528
| --- | --- | --- | --- |
25172529
| `-p`, `--path <dir>` | string | - | Run against a repo path (overrides cwd search) |
2518-
| `--fix` | boolean | false | Attempt safe auto-remediations (network + CoreDNS + CA) |
2530+
| `--fix` | boolean | false | Attempt safe auto-remediations (network + CoreDNS + CA + host TLS trust) |
25192531

25202532
### hack crash-capture
25212533

docs/env.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ hack host exec --env qa --scope api -- bun db:migrate
169169
hack host exec --env qa --scope api --target compose -- bun test
170170
```
171171

172+
When you want to inspect an injected value, avoid `hack env exec -- echo $VAR` or
173+
`hack host exec -- echo $VAR`. Your current shell expands `$VAR` before Hack starts the child
174+
process, so the command often sees an empty string.
175+
176+
Use one of these instead:
177+
178+
```bash
179+
hack env exec -- printenv APPLE_TEAM_ID
180+
hack env exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"'
181+
hack env exec --shell 'echo $APPLE_TEAM_ID'
182+
hack host exec -- printenv APPLE_TEAM_ID
183+
hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"'
184+
hack host exec --shell 'echo $APPLE_TEAM_ID'
185+
```
186+
172187
Open a host shell with injected env:
173188

174189
```bash

docs/guides/codex-managed-environments.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ hack host exec --env qa --scope api --target compose -- bun test
9494

9595
`hack host exec` and `hack host shell` default to a host-local env view for host commands. Use
9696
`--scope` when you want service-scoped values without running inside that service container. Use
97-
`--target compose` when you explicitly want the container-oriented compose view instead.
97+
`--target compose` when you explicitly want the container-oriented compose view instead. If you are
98+
checking a value, prefer `hack host exec -- printenv KEY` or
99+
`hack host exec -- sh -lc 'printf "%s\n" "$KEY"'`; plain `echo $KEY` expands in the parent shell
100+
before Hack injects env. Use `hack host exec --shell 'echo $KEY'` if you want Hack to start the
101+
child shell after env injection.
98102

99103
## Not available in slim mode
100104

docs/integrations.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ Main surface:
132132
Prefer `hack host exec` and `hack host shell` when a command should run on your host machine but
133133
still needs Hack-resolved env. They default to a host-local env view and accept `--scope` when you
134134
want service-scoped values without running inside that container. Use `--target compose` when you
135-
explicitly want the raw compose/container-oriented values.
135+
explicitly want the raw compose/container-oriented values. When checking a variable, use
136+
`hack host exec -- printenv KEY` or `hack host exec -- sh -lc 'printf "%s\n" "$KEY"'`; plain
137+
`echo $KEY` expands before Hack injects env. Use `hack host exec --shell 'echo $KEY'` if you want
138+
Hack to launch the child shell after env injection.
136139

137140
Reference:
138141

src/commands/doctor.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
resolveGlobalCaddyIp,
3636
} from "../lib/caddy-hosts.ts";
3737
import { resolveGlobalConfigPath } from "../lib/config-paths.ts";
38+
import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts";
3839
import { parseDotEnv } from "../lib/env.ts";
3940
import {
4041
ensureDir,
@@ -170,6 +171,7 @@ const DOCTOR_SUMMARY_GROUPS = [
170171
"grafana",
171172
"proxy ports",
172173
"caddy local ca",
174+
"host tls trust",
173175
]),
174176
},
175177
{
@@ -414,6 +416,13 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
414416
timeoutMs: 1500,
415417
})
416418
);
419+
if (isMac()) {
420+
results.push(
421+
await runCheck(s, "host tls trust", () => checkMacHostTlsTrust(), {
422+
timeoutMs: 1500,
423+
})
424+
);
425+
}
417426

418427
// Project (if in a repo or --path)
419428
const startDir = args.options.path
@@ -1851,6 +1860,7 @@ async function runDoctorFix(opts: {
18511860

18521861
await maybeStartGlobalCaddyCompose({ paths });
18531862
await maybeExportCaddyCaCert({ paths });
1863+
await maybeRepairMacHostTlsTrust();
18541864
await maybeMigrateDnsmasq();
18551865
await maybeRepairProjectTicketsGitHealth({ startDir: opts.startDir });
18561866
if (opts.migrateEnvConfig) {
@@ -2078,7 +2088,7 @@ export async function buildDoctorRemediationPlanLines(opts: {
20782088
readonly migrateEnvConfig: boolean;
20792089
}): Promise<string[]> {
20802090
const lines = [
2081-
"1. Review and repair local network, CoreDNS, CA, and daemon drift where needed.",
2091+
"1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.",
20822092
"2. Repair tickets refs if the project repo needs it.",
20832093
];
20842094
if (!opts.migrateEnvConfig) {
@@ -2430,6 +2440,31 @@ async function maybeExportCaddyCaCert(opts: {
24302440
await exportCaddyLocalCaCert({ paths: opts.paths });
24312441
}
24322442

2443+
async function maybeRepairMacHostTlsTrust(): Promise<void> {
2444+
if (!isMac()) {
2445+
return;
2446+
}
2447+
2448+
const hostTlsTrust = await checkMacHostTlsTrust();
2449+
if (hostTlsTrust.status === "ok") {
2450+
return;
2451+
}
2452+
2453+
note(hostTlsTrust.message, "doctor");
2454+
const okRepair = await confirmOrThrow({
2455+
message:
2456+
"Repair macOS host TLS trust now? (Bun/Node/curl/git trust for https://*.hack)",
2457+
initialValue: true,
2458+
});
2459+
if (!okRepair) {
2460+
return;
2461+
}
2462+
2463+
await runHackSubcommand({
2464+
args: ["global", "trust"],
2465+
});
2466+
}
2467+
24332468
async function maybeMigrateDnsmasq(): Promise<void> {
24342469
if (!isMac()) {
24352470
return;

src/commands/env.ts

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
serializeEnvClassificationForJson,
3737
serializeEnvStorageForJson as serializeEnvStorageForJsonShape,
3838
} from "../lib/hack-env-status.ts";
39+
import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts";
3940
import type { ProjectContext } from "../lib/project.ts";
4041
import {
4142
defaultProjectSlugFromPath,
@@ -120,6 +121,15 @@ const optTarget = defineOption({
120121
"Env view for host commands (default: host rewrites container-oriented addresses for local host execution)",
121122
} as const);
122123

124+
const optShellCommand = defineOption({
125+
name: "shellCommand",
126+
type: "string",
127+
long: "--shell",
128+
valueHint: "<command>",
129+
description:
130+
"Run a shell command string via /bin/sh -lc after env injection so `$VAR` expansion happens inside the child shell",
131+
} as const);
132+
123133
const SECRET_MASK = "***";
124134
const MODERN_ENV_STATUS_CLASSIFICATION = {
125135
trust_model: "repo_managed_env_config",
@@ -187,9 +197,16 @@ const execSpec = defineCommand({
187197
summary: "Run a host command with project env injected",
188198
group: "Project",
189199
description:
190-
"Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env.",
191-
options: [optPath, optProject, optEnv, optService, optTarget],
192-
positionals: [{ name: "command", required: true, multiple: true }],
200+
'Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env. To inspect a value, prefer `printenv KEY` or `sh -lc \'printf "%s\\n" "$KEY"\'`; `echo $KEY` expands in your current shell before Hack injects env.',
201+
options: [
202+
optPath,
203+
optProject,
204+
optEnv,
205+
optService,
206+
optTarget,
207+
optShellCommand,
208+
],
209+
positionals: [{ name: "command", required: false, multiple: true }],
193210
subcommands: [],
194211
} as const);
195212

@@ -209,9 +226,9 @@ const hostExecSpec = defineCommand({
209226
summary: "Run a host command with project env injected",
210227
group: "Project",
211228
description:
212-
"Run a one-off command on the host with the selected Hack env overlay injected. Use --scope when you want service-scoped values without running inside that service container.",
213-
options: [optPath, optProject, optEnv, optScope, optTarget],
214-
positionals: [{ name: "command", required: true, multiple: true }],
229+
'Run a one-off command on the host with the selected Hack env overlay injected. Use --scope when you want service-scoped values without running inside that service container. To inspect a value, prefer `printenv KEY` or `sh -lc \'printf "%s\\n" "$KEY"\'`; `echo $KEY` expands in your current shell before Hack injects env.',
230+
options: [optPath, optProject, optEnv, optScope, optTarget, optShellCommand],
231+
positionals: [{ name: "command", required: false, multiple: true }],
215232
subcommands: [],
216233
} as const);
217234

@@ -605,7 +622,7 @@ async function resolveEnvInjection(input: {
605622
target: input.target,
606623
});
607624
return {
608-
env: adaptEnvForHostExecution({
625+
env: await adaptEnvForHostExecution({
609626
env,
610627
target: input.target,
611628
serviceNames,
@@ -627,7 +644,7 @@ async function resolveEnvInjection(input: {
627644
composeFile: input.project.composeFile,
628645
});
629646
return {
630-
env: adaptEnvForHostExecution({
647+
env: await adaptEnvForHostExecution({
631648
env: selectHackEnvValues({
632649
resolved,
633650
serviceName: input.serviceName,
@@ -659,21 +676,21 @@ function adaptEnvForHostExecution(input: {
659676
readonly env: Readonly<Record<string, string>>;
660677
readonly target: (typeof HOST_ENV_TARGET_VALUES)[number];
661678
readonly serviceNames: readonly string[];
662-
}): Record<string, string> {
663-
if (input.target !== "host") {
664-
return { ...input.env };
679+
}): Promise<Record<string, string>> {
680+
if (input.target === "host") {
681+
const composeServiceNames = new Set(input.serviceNames);
682+
const out: Record<string, string> = {};
683+
for (const [key, value] of Object.entries(input.env)) {
684+
out[key] = rewriteEnvValueForHostExecution({
685+
key,
686+
value,
687+
composeServiceNames,
688+
});
689+
}
690+
return appendHackHostTrustEnvironment(out);
665691
}
666692

667-
const composeServiceNames = new Set(input.serviceNames);
668-
const out: Record<string, string> = {};
669-
for (const [key, value] of Object.entries(input.env)) {
670-
out[key] = rewriteEnvValueForHostExecution({
671-
key,
672-
value,
673-
composeServiceNames,
674-
});
675-
}
676-
return out;
693+
return appendHackHostTrustEnvironment(input.env);
677694
}
678695

679696
function rewriteEnvValueForHostExecution(input: {
@@ -1564,6 +1581,12 @@ function resolveInteractiveShellCommand(): readonly string[] {
15641581
return [shellPath, "-l"];
15651582
}
15661583

1584+
function resolveShellCommandCommand(input: {
1585+
readonly command: string;
1586+
}): readonly string[] {
1587+
return ["/bin/sh", "-lc", input.command];
1588+
}
1589+
15671590
function resolveExecutionScopeName(input: {
15681591
readonly scopeName?: string;
15691592
readonly serviceName?: string;
@@ -1585,6 +1608,7 @@ async function runHostCommandWithInjectedEnv(input: {
15851608
readonly serviceName?: string;
15861609
readonly targetOpt: string | undefined;
15871610
readonly command: readonly string[];
1611+
readonly shellCommandOpt?: string;
15881612
}): Promise<number> {
15891613
const project = await resolveProjectForEnv({
15901614
ctx: input.ctx,
@@ -1598,7 +1622,12 @@ async function runHostCommandWithInjectedEnv(input: {
15981622
const target = resolveHostEnvTarget({
15991623
targetOption: input.targetOpt,
16001624
});
1601-
if (input.command.length === 0) {
1625+
const shellCommand = input.shellCommandOpt?.trim();
1626+
const positionalCommand = input.command;
1627+
if (shellCommand && positionalCommand.length > 0) {
1628+
throw new CliUsageError("Use either <command...> or --shell, not both.");
1629+
}
1630+
if (!shellCommand && positionalCommand.length === 0) {
16021631
throw new CliUsageError("Command is required.");
16031632
}
16041633

@@ -1612,11 +1641,16 @@ async function runHostCommandWithInjectedEnv(input: {
16121641
}),
16131642
target,
16141643
});
1615-
return await run(input.command, {
1616-
cwd: project.projectRoot,
1617-
env: envState.env,
1618-
stdin: "inherit",
1619-
});
1644+
return await run(
1645+
shellCommand
1646+
? resolveShellCommandCommand({ command: shellCommand })
1647+
: positionalCommand,
1648+
{
1649+
cwd: project.projectRoot,
1650+
env: envState.env,
1651+
stdin: "inherit",
1652+
}
1653+
);
16201654
}
16211655

16221656
async function openHostShellWithInjectedEnv(input: {
@@ -1670,6 +1704,7 @@ const handleEnvExec: CommandHandlerFor<typeof execSpec> = async ({
16701704
serviceName: args.options.service,
16711705
targetOpt: args.options.target,
16721706
command: args.positionals.command,
1707+
shellCommandOpt: args.options.shellCommand,
16731708
});
16741709
};
16751710

@@ -1699,6 +1734,7 @@ const handleHostExec: CommandHandlerFor<typeof hostExecSpec> = async ({
16991734
scopeName: args.options.scope,
17001735
targetOpt: args.options.target,
17011736
command: args.positionals.command,
1737+
shellCommandOpt: args.options.shellCommand,
17021738
});
17031739
};
17041740

0 commit comments

Comments
 (0)