Skip to content
14 changes: 13 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,11 @@ Runs a host command with injected env. Default target is `host`, which applies t
view and any `host` scope overrides. Use `--target compose` to preserve the container-oriented
compose view.

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

Options:

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

#### hack env shell

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

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

Options:

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

#### hack host shell

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

### hack crash-capture

Expand Down
15 changes: 15 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,21 @@ hack host exec --env qa --scope api -- bun db:migrate
hack host exec --env qa --scope api --target compose -- bun test
```

When you want to inspect an injected value, avoid `hack env exec -- echo $VAR` or
`hack host exec -- echo $VAR`. Your current shell expands `$VAR` before Hack starts the child
process, so the command often sees an empty string.

Use one of these instead:

```bash
hack env exec -- printenv APPLE_TEAM_ID
hack env exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"'
hack env exec --shell 'echo $APPLE_TEAM_ID'
hack host exec -- printenv APPLE_TEAM_ID
hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"'
hack host exec --shell 'echo $APPLE_TEAM_ID'
```

Open a host shell with injected env:

```bash
Expand Down
6 changes: 5 additions & 1 deletion docs/guides/codex-managed-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ hack host exec --env qa --scope api --target compose -- bun test

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

## Not available in slim mode

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

Reference:

Expand Down
37 changes: 36 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
resolveGlobalCaddyIp,
} from "../lib/caddy-hosts.ts";
import { resolveGlobalConfigPath } from "../lib/config-paths.ts";
import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts";
import { parseDotEnv } from "../lib/env.ts";
import {
ensureDir,
Expand Down Expand Up @@ -170,6 +171,7 @@ const DOCTOR_SUMMARY_GROUPS = [
"grafana",
"proxy ports",
"caddy local ca",
"host tls trust",
]),
},
{
Expand Down Expand Up @@ -414,6 +416,13 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
timeoutMs: 1500,
})
);
if (isMac()) {
results.push(
await runCheck(s, "host tls trust", () => checkMacHostTlsTrust(), {
timeoutMs: 1500,
})
);
}

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

await maybeStartGlobalCaddyCompose({ paths });
await maybeExportCaddyCaCert({ paths });
await maybeRepairMacHostTlsTrust();
await maybeMigrateDnsmasq();
await maybeRepairProjectTicketsGitHealth({ startDir: opts.startDir });
if (opts.migrateEnvConfig) {
Expand Down Expand Up @@ -2078,7 +2088,7 @@ export async function buildDoctorRemediationPlanLines(opts: {
readonly migrateEnvConfig: boolean;
}): Promise<string[]> {
const lines = [
"1. Review and repair local network, CoreDNS, CA, and daemon drift where needed.",
"1. Review and repair local network, CoreDNS, CA, host TLS env, and daemon drift where needed.",
"2. Repair tickets refs if the project repo needs it.",
];
if (!opts.migrateEnvConfig) {
Expand Down Expand Up @@ -2430,6 +2440,31 @@ async function maybeExportCaddyCaCert(opts: {
await exportCaddyLocalCaCert({ paths: opts.paths });
}

async function maybeRepairMacHostTlsTrust(): Promise<void> {
if (!isMac()) {
return;
}

const hostTlsTrust = await checkMacHostTlsTrust();
if (hostTlsTrust.status === "ok") {
return;
}

note(hostTlsTrust.message, "doctor");
const okRepair = await confirmOrThrow({
message:
"Repair macOS host TLS trust now? (Bun/Node/curl/git trust for https://*.hack)",
initialValue: true,
});
if (!okRepair) {
return;
}

await runHackSubcommand({
args: ["global", "trust"],
});
}

async function maybeMigrateDnsmasq(): Promise<void> {
if (!isMac()) {
return;
Expand Down
90 changes: 63 additions & 27 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
serializeEnvClassificationForJson,
serializeEnvStorageForJson as serializeEnvStorageForJsonShape,
} from "../lib/hack-env-status.ts";
import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts";
import type { ProjectContext } from "../lib/project.ts";
import {
defaultProjectSlugFromPath,
Expand Down Expand Up @@ -120,6 +121,15 @@ const optTarget = defineOption({
"Env view for host commands (default: host rewrites container-oriented addresses for local host execution)",
} as const);

const optShellCommand = defineOption({
name: "shellCommand",
type: "string",
long: "--shell",
valueHint: "<command>",
description:
"Run a shell command string via /bin/sh -lc after env injection so `$VAR` expansion happens inside the child shell",
} as const);

const SECRET_MASK = "***";
const MODERN_ENV_STATUS_CLASSIFICATION = {
trust_model: "repo_managed_env_config",
Expand Down Expand Up @@ -187,9 +197,16 @@ const execSpec = defineCommand({
summary: "Run a host command with project env injected",
group: "Project",
description:
"Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env.",
options: [optPath, optProject, optEnv, optService, optTarget],
positionals: [{ name: "command", required: true, multiple: true }],
'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.',
options: [
optPath,
optProject,
optEnv,
optService,
optTarget,
optShellCommand,
],
positionals: [{ name: "command", required: false, multiple: true }],
subcommands: [],
} as const);

Expand All @@ -209,9 +226,9 @@ const hostExecSpec = defineCommand({
summary: "Run a host command with project env injected",
group: "Project",
description:
"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.",
options: [optPath, optProject, optEnv, optScope, optTarget],
positionals: [{ name: "command", required: true, multiple: true }],
'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.',
options: [optPath, optProject, optEnv, optScope, optTarget, optShellCommand],
positionals: [{ name: "command", required: false, multiple: true }],
subcommands: [],
} as const);

Expand Down Expand Up @@ -605,7 +622,7 @@ async function resolveEnvInjection(input: {
target: input.target,
});
return {
env: adaptEnvForHostExecution({
env: await adaptEnvForHostExecution({
env,
target: input.target,
serviceNames,
Expand All @@ -627,7 +644,7 @@ async function resolveEnvInjection(input: {
composeFile: input.project.composeFile,
});
return {
env: adaptEnvForHostExecution({
env: await adaptEnvForHostExecution({
env: selectHackEnvValues({
resolved,
serviceName: input.serviceName,
Expand Down Expand Up @@ -659,21 +676,21 @@ function adaptEnvForHostExecution(input: {
readonly env: Readonly<Record<string, string>>;
readonly target: (typeof HOST_ENV_TARGET_VALUES)[number];
readonly serviceNames: readonly string[];
}): Record<string, string> {
if (input.target !== "host") {
return { ...input.env };
}): Promise<Record<string, string>> {
if (input.target === "host") {
const composeServiceNames = new Set(input.serviceNames);
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(input.env)) {
out[key] = rewriteEnvValueForHostExecution({
key,
value,
composeServiceNames,
});
}
return appendHackHostTrustEnvironment(out);
}

const composeServiceNames = new Set(input.serviceNames);
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(input.env)) {
out[key] = rewriteEnvValueForHostExecution({
key,
value,
composeServiceNames,
});
}
return out;
return appendHackHostTrustEnvironment(input.env);
}

function rewriteEnvValueForHostExecution(input: {
Expand Down Expand Up @@ -1564,6 +1581,12 @@ function resolveInteractiveShellCommand(): readonly string[] {
return [shellPath, "-l"];
}

function resolveShellCommandCommand(input: {
readonly command: string;
}): readonly string[] {
return ["/bin/sh", "-lc", input.command];
}

function resolveExecutionScopeName(input: {
readonly scopeName?: string;
readonly serviceName?: string;
Expand All @@ -1585,6 +1608,7 @@ async function runHostCommandWithInjectedEnv(input: {
readonly serviceName?: string;
readonly targetOpt: string | undefined;
readonly command: readonly string[];
readonly shellCommandOpt?: string;
}): Promise<number> {
const project = await resolveProjectForEnv({
ctx: input.ctx,
Expand All @@ -1598,7 +1622,12 @@ async function runHostCommandWithInjectedEnv(input: {
const target = resolveHostEnvTarget({
targetOption: input.targetOpt,
});
if (input.command.length === 0) {
const shellCommand = input.shellCommandOpt?.trim();
const positionalCommand = input.command;
if (shellCommand && positionalCommand.length > 0) {
throw new CliUsageError("Use either <command...> or --shell, not both.");
}
if (!shellCommand && positionalCommand.length === 0) {
throw new CliUsageError("Command is required.");
}

Expand All @@ -1612,11 +1641,16 @@ async function runHostCommandWithInjectedEnv(input: {
}),
target,
});
return await run(input.command, {
cwd: project.projectRoot,
env: envState.env,
stdin: "inherit",
});
return await run(
shellCommand
? resolveShellCommandCommand({ command: shellCommand })
: positionalCommand,
{
cwd: project.projectRoot,
env: envState.env,
stdin: "inherit",
}
);
}

async function openHostShellWithInjectedEnv(input: {
Expand Down Expand Up @@ -1670,6 +1704,7 @@ const handleEnvExec: CommandHandlerFor<typeof execSpec> = async ({
serviceName: args.options.service,
targetOpt: args.options.target,
command: args.positionals.command,
shellCommandOpt: args.options.shellCommand,
});
};

Expand Down Expand Up @@ -1699,6 +1734,7 @@ const handleHostExec: CommandHandlerFor<typeof hostExecSpec> = async ({
scopeName: args.options.scope,
targetOpt: args.options.target,
command: args.positionals.command,
shellCommandOpt: args.options.shellCommand,
});
};

Expand Down
Loading
Loading