diff --git a/docs/cli.md b/docs/cli.md index 8a5c53b8..c06b7da7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 | @@ -1647,6 +1652,7 @@ Options: | `--env ` | string | - | Apply an optional env overlay by name | | `--service ` | string | - | Resolve values for one service scope | | `--target ` | string | `host` | Host-local env view for host commands, or raw compose view | +| `--shell ` | string | - | Run a shell command string via `/bin/sh -lc` after env injection | #### hack env shell @@ -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 | @@ -1720,6 +1731,7 @@ Options: | `--env ` | string | - | Apply an optional env overlay by name | | `--scope ` | string | - | Resolve values for one env scope while still running on the host | | `--target ` | string | `host` | Host-local env view for host commands, or raw compose view | +| `--shell ` | string | - | Run a shell command string via `/bin/sh -lc` after env injection | #### hack host shell @@ -2515,7 +2527,7 @@ Options: | Flag | Type | Default | Description | | --- | --- | --- | --- | | `-p`, `--path ` | 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 diff --git a/docs/env.md b/docs/env.md index ec04386e..1dbe83ac 100644 --- a/docs/env.md +++ b/docs/env.md @@ -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 diff --git a/docs/guides/codex-managed-environments.md b/docs/guides/codex-managed-environments.md index 44e9d17d..8ad75b57 100644 --- a/docs/guides/codex-managed-environments.md +++ b/docs/guides/codex-managed-environments.md @@ -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 diff --git a/docs/integrations.md b/docs/integrations.md index 1c46f3e5..ecb95040 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -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: diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c1ff5160..edc170c7 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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, @@ -170,6 +171,7 @@ const DOCTOR_SUMMARY_GROUPS = [ "grafana", "proxy ports", "caddy local ca", + "host tls trust", ]), }, { @@ -414,6 +416,13 @@ const handleDoctor: CommandHandlerFor = 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 @@ -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) { @@ -2078,7 +2088,7 @@ export async function buildDoctorRemediationPlanLines(opts: { readonly migrateEnvConfig: boolean; }): Promise { 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) { @@ -2430,6 +2440,31 @@ async function maybeExportCaddyCaCert(opts: { await exportCaddyLocalCaCert({ paths: opts.paths }); } +async function maybeRepairMacHostTlsTrust(): Promise { + 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 { if (!isMac()) { return; diff --git a/src/commands/env.ts b/src/commands/env.ts index f4308e76..76b35098 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -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, @@ -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: "", + 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", @@ -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); @@ -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); @@ -605,7 +622,7 @@ async function resolveEnvInjection(input: { target: input.target, }); return { - env: adaptEnvForHostExecution({ + env: await adaptEnvForHostExecution({ env, target: input.target, serviceNames, @@ -627,7 +644,7 @@ async function resolveEnvInjection(input: { composeFile: input.project.composeFile, }); return { - env: adaptEnvForHostExecution({ + env: await adaptEnvForHostExecution({ env: selectHackEnvValues({ resolved, serviceName: input.serviceName, @@ -659,21 +676,21 @@ function adaptEnvForHostExecution(input: { readonly env: Readonly>; readonly target: (typeof HOST_ENV_TARGET_VALUES)[number]; readonly serviceNames: readonly string[]; -}): Record { - if (input.target !== "host") { - return { ...input.env }; +}): Promise> { + if (input.target === "host") { + const composeServiceNames = new Set(input.serviceNames); + const out: Record = {}; + 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 = {}; - for (const [key, value] of Object.entries(input.env)) { - out[key] = rewriteEnvValueForHostExecution({ - key, - value, - composeServiceNames, - }); - } - return out; + return appendHackHostTrustEnvironment(input.env); } function rewriteEnvValueForHostExecution(input: { @@ -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; @@ -1585,6 +1608,7 @@ async function runHostCommandWithInjectedEnv(input: { readonly serviceName?: string; readonly targetOpt: string | undefined; readonly command: readonly string[]; + readonly shellCommandOpt?: string; }): Promise { const project = await resolveProjectForEnv({ ctx: input.ctx, @@ -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 or --shell, not both."); + } + if (!shellCommand && positionalCommand.length === 0) { throw new CliUsageError("Command is required."); } @@ -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: { @@ -1670,6 +1704,7 @@ const handleEnvExec: CommandHandlerFor = async ({ serviceName: args.options.service, targetOpt: args.options.target, command: args.positionals.command, + shellCommandOpt: args.options.shellCommand, }); }; @@ -1699,6 +1734,7 @@ const handleHostExec: CommandHandlerFor = async ({ scopeName: args.options.scope, targetOpt: args.options.target, command: args.positionals.command, + shellCommandOpt: args.options.shellCommand, }); }; diff --git a/src/commands/global.ts b/src/commands/global.ts index fbebee16..d757f786 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -57,11 +57,19 @@ import { ensureDir, pathExists, readTextFile, + writeTextFile, writeTextFileIfChanged, } from "../lib/fs.ts"; import { getString, isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; +import { + buildHackHostTrustEnvironment, + renderHackHostTrustShellExports, + resolveHackHostTrustBundlePath, + resolveHackHostTrustEnvScriptPath, + resolveHackLocalCaCertPath, +} from "../lib/local-ca.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -492,172 +500,235 @@ async function globalInstall(): Promise { } const s = spinner(); - s.start("Ensuring gum…"); + await ensureOptionalInstallDependencies({ spinner: s }); + await warnIfSessionsMuxUnavailable(); + const useStaticIps = await ensureGlobalDockerNetworks({ spinner: s }); + const paths = getGlobalPaths(); + await materializeGlobalInstallFiles({ paths, useStaticIps }); + logger.success({ message: "Global files ready in ~/.hack/" }); + await globalUp(); + await completeGlobalInstallHostBootstrap(); + note( + [ + "Next:", + "- Open https://logs.hack", + "- Start a repo with: hack init && hack up", + ].join("\n"), + "Global install" + ); + + return 0; +} + +async function ensureOptionalInstallDependencies(opts: { + readonly spinner: ReturnType; +}): Promise { + await ensureManagedGum({ spinner: opts.spinner }); + await ensureManagedMutagen({ spinner: opts.spinner }); + await ensureMacInstallDependencies(); +} + +async function ensureManagedGum(opts: { + readonly spinner: ReturnType; +}): Promise { + opts.spinner.start("Ensuring gum…"); const gum = await ensureBundledGumInstalled(); if (gum.ok) { - s.stop(gum.installed ? "Installed bundled gum" : "gum already installed"); - } else { - const systemGum = Bun.which("gum"); - s.stop( - systemGum ? "gum available on PATH" : "gum not installed (optional)" + opts.spinner.stop( + gum.installed ? "Installed bundled gum" : "gum already installed" ); - if (gum.reason === "failed") { - logger.warn({ - message: `gum install failed: ${gum.message ?? "unknown error"}`, - }); - } + return; + } + + const systemGum = Bun.which("gum"); + opts.spinner.stop( + systemGum ? "gum available on PATH" : "gum not installed (optional)" + ); + if (gum.reason === "failed") { + logger.warn({ + message: `gum install failed: ${gum.message ?? "unknown error"}`, + }); } +} - s.start("Ensuring mutagen…"); +async function ensureManagedMutagen(opts: { + readonly spinner: ReturnType; +}): Promise { + opts.spinner.start("Ensuring mutagen…"); const mutagen = await ensureBundledMutagenInstalled(); if (mutagen.ok) { - s.stop( + opts.spinner.stop( mutagen.installed ? "Installed managed mutagen" : "mutagen already installed" ); - } else { - const systemMutagen = getMutagenPath(); - s.stop( - systemMutagen ? "mutagen available on PATH" : "mutagen not installed" - ); - if (!systemMutagen) { - const detail = mutagen.message ? `: ${mutagen.message}` : ""; - logger.warn({ - message: `mutagen install skipped (${mutagen.reason}${detail})`, - }); - logger.warn({ - message: - "Remote sync may fail without mutagen. Repair with: hack doctor --fix", - }); - } + return; } + const systemMutagen = getMutagenPath(); + opts.spinner.stop( + systemMutagen ? "mutagen available on PATH" : "mutagen not installed" + ); + if (!systemMutagen) { + const detail = mutagen.message ? `: ${mutagen.message}` : ""; + logger.warn({ + message: `mutagen install skipped (${mutagen.reason}${detail})`, + }); + logger.warn({ + message: + "Remote sync may fail without mutagen. Repair with: hack doctor --fix", + }); + } +} + +async function ensureMacInstallDependencies(): Promise { if (isMac()) { await ensureMacChafa(); await ensureMacMkcert(); - } else { - logger.warn({ - message: "Skipping chafa install (only automated on macOS for now).", - }); + return; } - await warnIfSessionsMuxUnavailable(); + logger.warn({ + message: "Skipping chafa install (only automated on macOS for now).", + }); +} - s.start("Checking Docker…"); +async function ensureGlobalDockerNetworks(opts: { + readonly spinner: ReturnType; +}): Promise { + opts.spinner.start("Checking Docker…"); await ensureDockerRunning(); - s.stop("Docker is running"); + opts.spinner.stop("Docker is running"); - s.start("Ensuring shared networks…"); + opts.spinner.start("Ensuring shared networks…"); const ingressNetwork = await ensureNetwork(DEFAULT_INGRESS_NETWORK, { subnet: DEFAULT_INGRESS_SUBNET, gateway: DEFAULT_INGRESS_GATEWAY, }); await ensureNetwork(DEFAULT_LOGGING_NETWORK); - s.stop( + opts.spinner.stop( `Networks ready (${DEFAULT_INGRESS_NETWORK}, ${DEFAULT_LOGGING_NETWORK})` ); - const useStaticIps = ingressNetwork.hasSubnet; - if (!useStaticIps) { + + if (!ingressNetwork.hasSubnet) { logger.warn({ message: "hack-dev network has no subnet; CoreDNS will resolve via dynamic IP.", }); } + return ingressNetwork.hasSubnet; +} - const paths = getGlobalPaths(); - await ensureDir(paths.caddyDir); - await ensureDir(paths.loggingDir); - await ensureDir(paths.schemasDir); - await ensureDir(dirname(paths.grafanaDatasource)); - await ensureDir(dirname(paths.grafanaDashboardsProvisioning)); - await ensureDir(dirname(paths.grafanaDashboard)); - await ensureDir(dirname(paths.alloyConfig)); - await ensureDir(dirname(paths.lokiConfig)); +async function materializeGlobalInstallFiles(opts: { + readonly paths: ReturnType; + readonly useStaticIps: boolean; +}): Promise { + await ensureGlobalInstallDirectories({ paths: opts.paths }); + await writeGlobalInstallAssets({ + paths: opts.paths, + useStaticIps: opts.useStaticIps, + }); +} +async function ensureGlobalInstallDirectories(opts: { + readonly paths: ReturnType; +}): Promise { + await ensureDir(opts.paths.caddyDir); + await ensureDir(opts.paths.loggingDir); + await ensureDir(opts.paths.schemasDir); + await ensureDir(dirname(opts.paths.grafanaDatasource)); + await ensureDir(dirname(opts.paths.grafanaDashboardsProvisioning)); + await ensureDir(dirname(opts.paths.grafanaDashboard)); + await ensureDir(dirname(opts.paths.alloyConfig)); + await ensureDir(dirname(opts.paths.lokiConfig)); +} + +async function writeGlobalInstallAssets(opts: { + readonly paths: ReturnType; + readonly useStaticIps: boolean; +}): Promise { await writeWithPromptIfDifferent( - paths.caddyCompose, + opts.paths.caddyCompose, renderGlobalCaddyCompose({ - useStaticCoreDnsIp: useStaticIps, - useStaticCaddyIp: useStaticIps, + useStaticCoreDnsIp: opts.useStaticIps, + useStaticCaddyIp: opts.useStaticIps, }) ); await writeWithPromptIfDifferent( - paths.coreDnsConfig, - renderGlobalCoreDnsConfig({ useStaticCaddyIp: useStaticIps }) + opts.paths.coreDnsConfig, + renderGlobalCoreDnsConfig({ useStaticCaddyIp: opts.useStaticIps }) ); await writeWithPromptIfDifferent( - paths.loggingCompose, + opts.paths.loggingCompose, renderGlobalLoggingCompose() ); await writeWithPromptIfDifferent( - paths.alloyConfig, + opts.paths.alloyConfig, renderGlobalAlloyConfig() ); await writeWithPromptIfDifferent( - paths.lokiConfig, + opts.paths.lokiConfig, renderGlobalLokiConfigYaml() ); await writeWithPromptIfDifferent( - paths.grafanaDatasource, + opts.paths.grafanaDatasource, renderGlobalGrafanaDatasourceYaml() ); await writeWithPromptIfDifferent( - paths.grafanaDashboardsProvisioning, + opts.paths.grafanaDashboardsProvisioning, renderGlobalGrafanaDashboardsProvisioningYaml() ); await writeWithPromptIfDifferent( - paths.grafanaDashboard, + opts.paths.grafanaDashboard, renderGlobalGrafanaLogsDashboardJson() ); await writeWithPromptIfDifferent( - paths.configSchema, + opts.paths.configSchema, renderProjectConfigSchemaJson() ); await writeWithPromptIfDifferent( - paths.envSchema, + opts.paths.envSchema, renderProjectEnvSchemaJson() ); await writeWithPromptIfDifferent( - paths.managedEnvSchema, + opts.paths.managedEnvSchema, renderProjectManagedEnvSchemaJson() ); await writeWithPromptIfDifferent( - paths.branchesSchema, + opts.paths.branchesSchema, renderProjectBranchesSchemaJson() ); +} - logger.success({ message: "Global files ready in ~/.hack/" }); - await globalUp(); - +async function completeGlobalInstallHostBootstrap(): Promise { if (isMac()) { - const hostDnsTarget = await resolvePreferredMacHostDnsTarget(); - await ensureMacHackDns({ targetIp: hostDnsTarget }); - await ensureMacTrustCaddyLocalCa(); - await maybeOfferMacRecoverySetup(); - } else { - logger.warn({ - message: "Skipping DNS bootstrap (only implemented for macOS for now).", - }); - note( - [ - `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, - "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", - ].join("\n"), - "DNS setup" - ); + await bootstrapMacGlobalInstall(); + return; } + logger.warn({ + message: "Skipping DNS bootstrap (only implemented for macOS for now).", + }); note( [ - "Next:", - "- Open https://logs.hack", - "- Start a repo with: hack init && hack up", + `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, + "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", ].join("\n"), - "Global install" + "DNS setup" ); +} - return 0; +async function bootstrapMacGlobalInstall(): Promise { + const hostDnsTarget = await resolvePreferredMacHostDnsTarget(); + await ensureMacHackDns({ targetIp: hostDnsTarget }); + const certPath = await exportCaddyLocalCaCert(); + if (certPath) { + const trustReady = await ensureMacTrustCaddyLocalCa({ certPath }); + if (trustReady) { + await configureMacHostTlsTrust({ certPath }); + } + } + await maybeOfferMacRecoverySetup(); } async function globalLogsReset(): Promise { @@ -1136,13 +1207,8 @@ async function inspectIngressNetworkSnapshot(): Promise(); const containerIpByName = new Map(); for (const entry of parsed) { - if (!entry || typeof entry !== "object") { - continue; - } if (subnet === null) { - const ipamConfig = ( - entry as { - IPAM?: { - Config?: Array<{ Subnet?: unknown; Gateway?: unknown }>; - }; - } - ).IPAM?.Config; - if (Array.isArray(ipamConfig)) { - for (const config of ipamConfig) { - if (subnet === null && typeof config?.Subnet === "string") { - subnet = config.Subnet; - } - if (gateway === null && typeof config?.Gateway === "string") { - gateway = config.Gateway; - } - if (subnet !== null && gateway !== null) { - break; - } - } - } + const ipamConfig = readIngressIpamConfig({ entry }); + subnet = ipamConfig.subnet; + gateway = ipamConfig.gateway; } - const containers = (entry as { Containers?: Record }) - .Containers; - if (!containers || typeof containers !== "object") { - continue; - } - - for (const info of Object.values(containers)) { - if (!info || typeof info !== "object") { - continue; - } - const record = info as { Name?: unknown; IPv4Address?: unknown }; - const name = typeof record.Name === "string" ? record.Name : ""; - const ipRaw = - typeof record.IPv4Address === "string" ? record.IPv4Address : ""; - if (!(name && ipRaw)) { - continue; - } - const ip = extractIpv4Address({ raw: ipRaw }); - if (ip.length === 0) { - continue; - } - usedIps.add(ip); - containerIpByName.set(name, ip); - } + collectIngressContainerIps({ + entry, + usedIps, + containerIpByName, + }); } return { @@ -1211,6 +1238,85 @@ async function inspectIngressNetworkSnapshot(): Promise[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(opts.stdout); + } catch { + return null; + } + if (!Array.isArray(parsed)) { + return null; + } + return parsed.filter(isRecord); +} + +function readIngressIpamConfig(opts: { + readonly entry: Record; +}): { + readonly subnet: string | null; + readonly gateway: string | null; +} { + const ipam = opts.entry.IPAM; + if (!isRecord(ipam)) { + return { subnet: null, gateway: null }; + } + + const rawConfig = ipam.Config; + if (!Array.isArray(rawConfig)) { + return { subnet: null, gateway: null }; + } + + let subnet: string | null = null; + let gateway: string | null = null; + for (const config of rawConfig) { + if (!isRecord(config)) { + continue; + } + if (subnet === null && typeof config.Subnet === "string") { + subnet = config.Subnet; + } + if (gateway === null && typeof config.Gateway === "string") { + gateway = config.Gateway; + } + if (subnet !== null && gateway !== null) { + break; + } + } + + return { subnet, gateway }; +} + +function collectIngressContainerIps(opts: { + readonly entry: Record; + readonly usedIps: Set; + readonly containerIpByName: Map; +}): void { + const containers = opts.entry.Containers; + if (!isRecord(containers)) { + return; + } + + for (const info of Object.values(containers)) { + if (!isRecord(info)) { + continue; + } + const name = typeof info.Name === "string" ? info.Name : ""; + const ipRaw = typeof info.IPv4Address === "string" ? info.IPv4Address : ""; + if (!(name && ipRaw)) { + continue; + } + const ip = extractIpv4Address({ raw: ipRaw }); + if (ip.length === 0) { + continue; + } + opts.usedIps.add(ip); + opts.containerIpByName.set(name, ip); + } +} + async function reassignIngressIpConflicts(opts: { readonly conflicts: readonly IngressIpConflict[]; readonly reservedIps: readonly string[]; @@ -2335,8 +2441,34 @@ async function globalTrust(): Promise { return 0; } - await ensureDockerRunning(); - await ensureMacTrustCaddyLocalCa(); + const existingCertPath = (await pathExists(resolveHackLocalCaCertPath())) + ? resolveHackLocalCaCertPath() + : null; + let certPath = existingCertPath; + const dockerStatus = await exec(["docker", "info"], { stdin: "ignore" }); + if (dockerStatus.exitCode === 0) { + certPath = (await exportCaddyLocalCaCert()) ?? certPath; + } else if (certPath) { + logger.info({ + message: + "Docker is not running; using the previously exported Caddy Local CA for trust setup.", + }); + } else { + await ensureDockerRunning(); + certPath = (await exportCaddyLocalCaCert()) ?? certPath; + } + if (!certPath) { + return 1; + } + + const trustReady = await ensureMacTrustCaddyLocalCa({ + certPath, + }); + if (trustReady) { + await configureMacHostTlsTrust({ + certPath, + }); + } return 0; } @@ -2934,7 +3066,9 @@ async function ensureMacMkcert(): Promise { } } -async function ensureMacTrustCaddyLocalCa(): Promise { +async function ensureMacTrustCaddyLocalCa(input: { + readonly certPath: string; +}): Promise { const ok = await confirm({ message: "Trust Caddy Local CA in macOS System keychain? (enables trusted https://*.hack; requires sudo)", @@ -2944,7 +3078,11 @@ async function ensureMacTrustCaddyLocalCa(): Promise { throw new Error("Canceled"); } if (!ok) { - return; + logger.info({ + message: + "Skipped macOS System keychain trust; leaving host TLS env unchanged.", + }); + return false; } // Fast-path: already trusted. @@ -2962,11 +3100,7 @@ async function ensureMacTrustCaddyLocalCa(): Promise { logger.info({ message: "Caddy Local CA already present in System keychain", }); - return; - } - const certPath = await exportCaddyLocalCaCert(); - if (!certPath) { - return; + return true; } logger.step({ @@ -2982,7 +3116,7 @@ async function ensureMacTrustCaddyLocalCa(): Promise { "trustRoot", "-k", "/Library/Keychains/System.keychain", - certPath, + input.certPath, ], { stdin: "inherit" } ); @@ -2991,7 +3125,7 @@ async function ensureMacTrustCaddyLocalCa(): Promise { logger.warn({ message: `Failed to trust Caddy Local CA (exit ${installExit}). You may see HTTPS warnings in the browser.`, }); - return; + return false; } logger.success({ message: "Trusted Caddy Local CA (macOS System keychain)" }); @@ -3002,6 +3136,106 @@ async function ensureMacTrustCaddyLocalCa(): Promise { ].join("\n"), "TLS" ); + return true; +} + +async function configureMacHostTlsTrust(input: { + readonly certPath: string; +}): Promise { + const bundlePath = await writeMacHostTrustBundle({ + certPath: input.certPath, + }); + const env = buildHackHostTrustEnvironment({ + certPath: input.certPath, + bundlePath, + }); + const scriptPath = resolveHackHostTrustEnvScriptPath(); + await ensureDir(dirname(scriptPath)); + await writeTextFile( + scriptPath, + renderHackHostTrustShellExports({ + certPath: input.certPath, + bundlePath, + }) + ); + + for (const [key, value] of Object.entries(env)) { + const exitCode = await run(["launchctl", "setenv", key, value], { + stdin: "ignore", + }); + if (exitCode !== 0) { + logger.warn({ + message: `Failed to set ${key} for future macOS shells via launchctl (exit ${exitCode}).`, + }); + } + } + + logger.success({ + message: + "Prepared host trust env for Bun/Node/curl/git and registered it for future macOS shells.", + }); + note( + [ + `Current shell: source ${scriptPath}`, + "New Terminal/iTerm windows should inherit the trust env automatically.", + ].join("\n"), + "Host TLS" + ); +} + +async function writeMacHostTrustBundle(input: { + readonly certPath: string; +}): Promise { + const bundlePath = resolveHackHostTrustBundlePath(); + const systemRootsKeychain = + "/System/Library/Keychains/SystemRootCertificates.keychain"; + + const pemChunks: string[] = []; + if (await pathExists(systemRootsKeychain)) { + const result = await exec( + ["security", "find-certificate", "-a", "-p", systemRootsKeychain], + { + stdin: "ignore", + } + ); + if (result.exitCode !== 0) { + logger.warn({ + message: `Failed to export trust roots from ${systemRootsKeychain}; host bundle will skip it.`, + }); + } else { + const pemText = result.stdout.trim(); + if (pemText.length > 0) { + pemChunks.push(pemText); + } + } + } + + const localCaPem = (await readTextFile(input.certPath))?.trim() ?? ""; + if (localCaPem.length === 0) { + logger.warn({ + message: `Unable to read exported Caddy Local CA at ${input.certPath}.`, + }); + return null; + } + + if (pemChunks.length === 0) { + logger.warn({ + message: + "No macOS system trust roots were exported for the host trust bundle; falling back to NODE_EXTRA_CA_CERTS only.", + }); + return null; + } + + const bundleText = `${[...pemChunks, localCaPem].join("\n")}\n`; + const expectedPath = resolveHackLocalCaCertPath(); + if (input.certPath !== expectedPath) { + logger.info({ + message: `Using exported Caddy Local CA at ${input.certPath} instead of ${expectedPath}.`, + }); + } + await ensureDir(dirname(bundlePath)); + await writeTextFileIfChanged(bundlePath, bundleText); + return bundlePath; } async function exportCaddyLocalCaCert(): Promise { diff --git a/src/commands/project.ts b/src/commands/project.ts index 60416536..e4aad931 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -92,6 +92,7 @@ import { resolveLifecycleLogPath, upsertLifecycleStateEntry, } from "../lib/lifecycle-runtime.ts"; +import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import { buildLogSelector, resolveShouldTryLoki, @@ -1289,7 +1290,7 @@ async function runLifecycleCommands(opts: { const proc = Bun.spawn(["sh", "-c", cmd.command], { cwd, - env: mergeLifecycleCommandEnv(opts.env), + env: await mergeLifecycleCommandEnv(opts.env), stdin: "inherit", stdout: "pipe", stderr: "pipe", @@ -2160,16 +2161,16 @@ function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -function mergeLifecycleCommandEnv( +export async function mergeLifecycleCommandEnv( override: Readonly> -): Record { +): Promise> { const base: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (typeof value === "string") { base[key] = value; } } - return { ...base, ...override }; + return await appendHackHostTrustEnvironment({ ...base, ...override }); } async function resolveBranchComposeFiles(opts: { diff --git a/src/commands/session.ts b/src/commands/session.ts index 52fa29d7..d260b5a8 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -17,6 +17,7 @@ import { resolveHackEnv, selectHackEnvValues, } from "../lib/hack-env.ts"; +import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import { defaultProjectSlugFromPath, findProjectContext, @@ -1081,10 +1082,12 @@ async function resolveSessionInjectedEnv(opts: { serviceNames, }); if (modern) { - return selectProjectEnvValues({ - resolved: modern, - scopeName: opts.serviceName, - }); + return await appendHackHostTrustEnvironment( + selectProjectEnvValues({ + resolved: modern, + scopeName: opts.serviceName, + }) + ); } const resolved = await resolveHackEnv({ @@ -1092,10 +1095,12 @@ async function resolveSessionInjectedEnv(opts: { projectName: opts.projectName, envName: opts.envName, }); - return selectHackEnvValues({ - resolved, - serviceName: opts.serviceName, - }); + return await appendHackHostTrustEnvironment( + selectHackEnvValues({ + resolved, + serviceName: opts.serviceName, + }) + ); } async function resolveProjectContextForWorkspace(opts: { diff --git a/src/lib/doctor-host-tls.ts b/src/lib/doctor-host-tls.ts new file mode 100644 index 00000000..5ed6211b --- /dev/null +++ b/src/lib/doctor-host-tls.ts @@ -0,0 +1,65 @@ +import { pathExists } from "./fs.ts"; +import { + findHackLocalCaCertPath, + resolveHackHostTrustBundlePath, + resolveHackHostTrustEnvScriptPath, +} from "./local-ca.ts"; +import { exec } from "./shell.ts"; + +export async function checkMacHostTlsTrust(input?: { + readonly certPath?: string | null; + readonly bundlePath?: string; + readonly envScriptPath?: string; + readonly pathExists?: typeof pathExists; + readonly exec?: typeof exec; +}): Promise<{ + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; +}> { + const pathExistsFn = input?.pathExists ?? pathExists; + const execFn = input?.exec ?? exec; + const certPath = input?.certPath ?? (await findHackLocalCaCertPath()); + if (!certPath) { + return { + name: "host tls trust", + status: "warn", + message: "Missing Caddy Local CA (run: hack doctor --fix)", + }; + } + + const issues: string[] = []; + const keychainTrust = await execFn( + [ + "security", + "find-certificate", + "-c", + "Caddy Local Authority", + "/Library/Keychains/System.keychain", + ], + { stdin: "ignore" } + ); + if (keychainTrust.exitCode !== 0) { + issues.push("macOS System keychain trust missing"); + } + + const bundlePath = input?.bundlePath ?? resolveHackHostTrustBundlePath(); + if (!(await pathExistsFn(bundlePath))) { + issues.push(`missing ${bundlePath}`); + } + + const envScriptPath = + input?.envScriptPath ?? resolveHackHostTrustEnvScriptPath(); + if (!(await pathExistsFn(envScriptPath))) { + issues.push(`missing ${envScriptPath}`); + } + + return { + name: "host tls trust", + status: issues.length > 0 ? "warn" : "ok", + message: + issues.length > 0 + ? `${issues.join("; ")} (run: hack doctor --fix)` + : `Ready (${envScriptPath})`, + }; +} diff --git a/src/lib/local-ca.ts b/src/lib/local-ca.ts new file mode 100644 index 00000000..0ae79a6c --- /dev/null +++ b/src/lib/local-ca.ts @@ -0,0 +1,141 @@ +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +import { GLOBAL_CADDY_DIR_NAME, GLOBAL_HACK_DIR_NAME } from "../constants.ts"; +import { pathExists } from "./fs.ts"; + +export const HACK_HOST_TRUST_BUNDLE_FILENAME = + "caddy-host-trust-bundle.pem" as const; +export const HACK_HOST_TRUST_ENV_FILENAME = "caddy-host-trust-env.sh" as const; + +interface HackHostTrustEnvironment { + readonly NODE_EXTRA_CA_CERTS: string; + readonly HACK_LOCAL_CA_CERT: string; + readonly SSL_CERT_FILE?: string; + readonly CURL_CA_BUNDLE?: string; + readonly REQUESTS_CA_BUNDLE?: string; + readonly GIT_SSL_CAINFO?: string; + readonly HACK_HOST_TRUST_BUNDLE?: string; +} + +export function resolveHackLocalCaCertPath(input?: { + readonly home?: string | null; +}): string { + const home = input?.home?.trim() || process.env.HOME?.trim() || homedir(); + return resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); +} + +export async function findHackLocalCaCertPath(input?: { + readonly home?: string | null; +}): Promise { + const certPath = resolveHackLocalCaCertPath(input); + return (await pathExists(certPath)) ? certPath : null; +} + +export function resolveHackHostTrustBundlePath(input?: { + readonly home?: string | null; +}): string { + const home = input?.home?.trim() || process.env.HOME?.trim() || homedir(); + return resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + HACK_HOST_TRUST_BUNDLE_FILENAME + ); +} + +export async function findHackHostTrustBundlePath(input?: { + readonly home?: string | null; +}): Promise { + const bundlePath = resolveHackHostTrustBundlePath(input); + return (await pathExists(bundlePath)) ? bundlePath : null; +} + +export function resolveHackHostTrustEnvScriptPath(input?: { + readonly home?: string | null; +}): string { + const home = input?.home?.trim() || process.env.HOME?.trim() || homedir(); + return resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + HACK_HOST_TRUST_ENV_FILENAME + ); +} + +export function buildHackHostTrustEnvironment(input: { + readonly certPath: string; + readonly bundlePath?: string | null; +}): HackHostTrustEnvironment { + const env: HackHostTrustEnvironment = { + NODE_EXTRA_CA_CERTS: input.certPath, + HACK_LOCAL_CA_CERT: input.certPath, + }; + + const bundlePath = input.bundlePath?.trim(); + if (!bundlePath) { + return env; + } + + return { + ...env, + SSL_CERT_FILE: bundlePath, + CURL_CA_BUNDLE: bundlePath, + REQUESTS_CA_BUNDLE: bundlePath, + GIT_SSL_CAINFO: bundlePath, + HACK_HOST_TRUST_BUNDLE: bundlePath, + }; +} + +export async function appendHackHostTrustEnvironment( + env: Readonly> +): Promise> { + const certPath = await findHackLocalCaCertPath(); + if (!certPath) { + return { ...env }; + } + + const bundlePath = await findHackHostTrustBundlePath(); + const trustEnvironment = buildHackHostTrustEnvironment({ + certPath, + bundlePath, + }); + + return { + ...trustEnvironment, + ...env, + HACK_LOCAL_CA_CERT: trustEnvironment.HACK_LOCAL_CA_CERT, + ...(trustEnvironment.HACK_HOST_TRUST_BUNDLE + ? { + HACK_HOST_TRUST_BUNDLE: trustEnvironment.HACK_HOST_TRUST_BUNDLE, + } + : {}), + }; +} + +export function renderHackHostTrustShellExports(input: { + readonly certPath: string; + readonly bundlePath?: string | null; +}): string { + const exports = buildHackHostTrustEnvironment(input); + return [ + "#!/usr/bin/env sh", + "# Auto-generated by `hack global trust`.", + ...Object.entries(exports).map( + ([key, value]) => `export ${key}=${shellQuote(value)}` + ), + "", + ].join("\n"); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index 26d86793..df8d923e 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -78,7 +78,7 @@ test("doctor remediation plan mentions env migration when requested for a legacy }); expect(lines).toEqual([ - "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.", "3. Prompt to migrate legacy env config (.hack/hack.env.json) to hack.env.*.yaml.", ]); @@ -96,7 +96,7 @@ test("doctor remediation plan skips env migration when modern env files already }); expect(lines).toEqual([ - "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.", "3. Skip env migration because this project already uses hack.env.*.yaml.", ]); diff --git a/tests/doctor-host-trust.test.ts b/tests/doctor-host-trust.test.ts new file mode 100644 index 00000000..3dce0da6 --- /dev/null +++ b/tests/doctor-host-trust.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; + +import { checkMacHostTlsTrust } from "../src/lib/doctor-host-tls.ts"; + +test("checkMacHostTlsTrust reports ready when keychain trust and host env artifacts exist", async () => { + const caDir = "/tmp/hack-doctor-host-trust"; + const bundlePath = resolve(caDir, "caddy-host-trust-bundle.pem"); + const envScriptPath = resolve(caDir, "caddy-host-trust-env.sh"); + + const result = await checkMacHostTlsTrust({ + certPath: resolve(caDir, "caddy-local-authority.crt"), + bundlePath, + envScriptPath, + pathExists: async () => true, + exec: async () => ({ exitCode: 0, stdout: "trusted", stderr: "" }), + }); + + expect(result).toEqual({ + name: "host tls trust", + status: "ok", + message: `Ready (${envScriptPath})`, + }); +}); + +test("checkMacHostTlsTrust flags missing keychain trust and host env artifacts", async () => { + const caDir = "/tmp/hack-doctor-host-trust"; + const bundlePath = resolve(caDir, "caddy-host-trust-bundle.pem"); + const envScriptPath = resolve(caDir, "caddy-host-trust-env.sh"); + + const result = await checkMacHostTlsTrust({ + certPath: resolve(caDir, "caddy-local-authority.crt"), + bundlePath, + envScriptPath, + pathExists: async () => false, + exec: async () => ({ exitCode: 1, stdout: "", stderr: "missing" }), + }); + + expect(result.name).toBe("host tls trust"); + expect(result.status).toBe("warn"); + expect(result.message).toContain("macOS System keychain trust missing"); + expect(result.message).toContain(`missing ${bundlePath}`); + expect(result.message).toContain(`missing ${envScriptPath}`); + expect(result.message).toContain("(run: hack doctor --fix)"); +}); diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index 9db79da1..77533629 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -17,6 +17,7 @@ const runCalls: Array<{ readonly env: Record | undefined; }> = []; const tempDirs = new Set(); +const originalHome = process.env.HOME; const originalShell = process.env.SHELL; mock.module("../src/lib/shell.ts", () => ({ @@ -44,6 +45,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); + process.env.HOME = originalHome; process.env.SHELL = originalShell; }); @@ -153,6 +155,207 @@ test("host exec injects scoped env into one-off host commands", async () => { }); }); +test("host exec preserves child-shell env expansion patterns", async () => { + const projectRoot = await createProject(); + const execCommand = findHostSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + }, + positionals: { + command: ["sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "api", + "sh", + "-lc", + 'printf "%s\\n" "$SERVICE_TOKEN"', + ], + positionals: ["sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + +test("env exec can run a shell command string after env injection", async () => { + const projectRoot = await createProject(); + const execCommand = findSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + service: "api", + target: undefined, + shellCommand: 'printf "%s\\n" "$SERVICE_TOKEN"', + }, + positionals: { + command: [], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--service", + "api", + "--shell", + 'printf "%s\\n" "$SERVICE_TOKEN"', + ], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["/bin/sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + +test("host exec can run a shell command string after env injection", async () => { + const projectRoot = await createProject(); + const execCommand = findHostSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + shellCommand: 'printf "%s\\n" "$SERVICE_TOKEN"', + }, + positionals: { + command: [], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "api", + "--shell", + 'printf "%s\\n" "$SERVICE_TOKEN"', + ], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["/bin/sh", "-lc", 'printf "%s\\n" "$SERVICE_TOKEN"'], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + +test("host exec rejects mixing positional commands with --shell", async () => { + const projectRoot = await createProject(); + const execCommand = findHostSubcommand("exec"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + shellCommand: 'printf "%s\\n" "$SERVICE_TOKEN"', + }, + positionals: { + command: ["printenv", "SERVICE_TOKEN"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "api", + "--shell", + 'printf "%s\\n" "$SERVICE_TOKEN"', + "printenv", + "SERVICE_TOKEN", + ], + positionals: ["printenv", "SERVICE_TOKEN"], + }, + }, + } as unknown as Parameters[0]; + + await expect(execCommand.handler(input)).rejects.toThrow( + "Use either or --shell, not both." + ); + expect(runCalls).toHaveLength(0); +}); + test("env shell opens the current shell with injected project env", async () => { const projectRoot = await createProject(); process.env.SHELL = "/bin/zsh"; @@ -233,6 +436,65 @@ test("host shell opens the current shell with injected project env", async () => }); }); +test("host shell can inject scoped env and local Hack CA trust", async () => { + const projectRoot = await createProject(); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + process.env.SHELL = "/bin/zsh"; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const shellCommand = findHostSubcommand("shell"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: "api", + target: undefined, + }, + positionals: {}, + raw: { + argv: ["--path", projectRoot, "--env", "qa", "--scope", "api"], + positionals: [], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await shellCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]).toEqual({ + cmd: ["/bin/zsh", "-l"], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }, + }); +}); + test("env exec defaults to a host-local view for host-like env values", async () => { const projectRoot = await createProject({ services: ["api", "redis"], @@ -333,6 +595,217 @@ test("env exec can preserve the compose view when requested", async () => { }); }); +test("env exec compose target still adds local Hack CA trust for host runtimes", async () => { + const projectRoot = await createProject({ + defaultYaml: [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' DATABASE_URL: "mysql://appuser:secret@host.docker.internal:3306/app?ssl={\\"rejectUnauthorized\\":false}"', + ' REDISHOST: "redis"', + "", + ].join("\n"), + }); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const execCommand = findSubcommand("exec"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: undefined, + service: undefined, + target: "compose", + shellCommand: undefined, + }, + positionals: { + command: ["env"], + }, + raw: { + argv: ["--path", projectRoot, "--target", "compose", "env"], + positionals: ["env"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]?.env).toEqual({ + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + DATABASE_URL: + 'mysql://appuser:secret@host.docker.internal:3306/app?ssl={"rejectUnauthorized":false}', + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REDISHOST: "redis", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + +test("env exec adds local Hack CA trust for host runtimes when available", async () => { + const projectRoot = await createProject(); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const execCommand = findSubcommand("exec"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + service: "api", + target: undefined, + }, + positionals: { + command: ["bun", "db:migrate"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--service", + "api", + "bun", + "db:migrate", + ], + positionals: ["bun", "db:migrate"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + +test("env exec preserves explicit TLS env values while adding Hack trust metadata", async () => { + const projectRoot = await createProject({ + defaultYaml: [ + "version: 1", + "environment: default", + "secretsprovider: project_key", + "values:", + " global:", + ' GLOBAL_FLAG: "base"', + ' NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem"', + ' SSL_CERT_FILE: "/tmp/custom-bundle.pem"', + "", + ].join("\n"), + }); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const execCommand = findSubcommand("exec"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + service: "api", + target: undefined, + shellCommand: undefined, + }, + positionals: { + command: ["bun", "db:migrate"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--service", + "api", + "bun", + "db:migrate", + ], + positionals: ["bun", "db:migrate"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await execCommand.handler(input); + + expect(exitCode).toBe(0); + expect(runCalls).toHaveLength(1); + expect(runCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); +}); + type EnvSubcommandName = (typeof envCommand.subcommands)[number]["name"]; type EnvSubcommand = Extract< (typeof envCommand.subcommands)[number], @@ -376,6 +849,7 @@ async function createProject(input?: { readonly services?: readonly string[]; readonly defaultYaml?: string; }): Promise { + await ensureIsolatedHome(); const root = await mkdtemp(join(tmpdir(), "hack-env-exec-")); tempDirs.add(root); @@ -437,3 +911,12 @@ async function createProject(input?: { return projectRoot; } + +async function ensureIsolatedHome(): Promise { + if (process.env.HOME && process.env.HOME !== originalHome) { + return; + } + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; +} diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index c3e6faeb..4caf15cb 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -14,7 +14,13 @@ import { } from "../src/constants.ts"; const runCalls: string[][] = []; +const execCalls: string[][] = []; let runResponder: ((cmd: readonly string[]) => number | null) | null = null; +let execMockResponder: + | (( + cmd: readonly string[] + ) => { exitCode: number; stdout: string; stderr: string } | null) + | null = null; let tempDir: string | null = null; let originalHome: string | undefined; @@ -22,12 +28,14 @@ let originalLogger: string | undefined; let originalUser: string | undefined; let reachabilityByHost: Record = {}; let idUser = "mock-user"; +let pathExistsOverrides = new Map(); +let confirmResponder: (() => boolean) | null = null; mock.module("@clack/prompts", () => ({ access: async () => true, autocompleteMultiselect: async () => [], cancel: () => {}, - confirm: async () => true, + confirm: async () => confirmResponder?.() ?? true, multiselect: async () => [], isCancel: () => false, log: { @@ -65,10 +73,59 @@ mock.module("node:net", () => ({ }, }; }, + createServer: () => ({ + close: () => {}, + listen: () => {}, + on: () => {}, + }), +})); + +mock.module("../src/lib/fs.ts", () => ({ + ensureDir: async (absoluteDir: string) => { + await mkdir(absoluteDir, { recursive: true }); + }, + ensureGitignoreEntry: async () => ({ changed: false }), + pathExists: async (absolutePath: string) => { + const override = pathExistsOverrides.get(absolutePath); + if (override !== undefined) { + return override; + } + try { + await Bun.file(absolutePath).stat(); + return true; + } catch { + return false; + } + }, + readTextFile: async (absolutePath: string) => { + try { + return await Bun.file(absolutePath).text(); + } catch { + return null; + } + }, + writeTextFile: async (absolutePath: string, content: string) => { + await Bun.write(absolutePath, content); + }, + writeTextFileIfChanged: async (absolutePath: string, content: string) => { + const existing = await Bun.file(absolutePath) + .text() + .catch(() => null); + if (existing === content) { + return { changed: false }; + } + await Bun.write(absolutePath, content); + return { changed: true }; + }, })); mock.module("../src/lib/shell.ts", () => ({ exec: async (cmd: readonly string[]) => { + execCalls.push([...cmd]); + const custom = execMockResponder?.(cmd) ?? null; + if (custom) { + return custom; + } if (cmd[0] === "docker" && cmd[1] === "info") { return { exitCode: 0, stdout: "", stderr: "" }; } @@ -122,9 +179,15 @@ beforeEach(async () => { process.env.USER = "env-user"; process.env.HACK_LOGGER = "console"; runCalls.length = 0; + execCalls.length = 0; runResponder = null; + execMockResponder = null; + pathExistsOverrides = new Map([ + ["/etc/sudoers.d/dance.hack-dns-recovery", false], + ]); reachabilityByHost = {}; idUser = "mock-user"; + confirmResponder = null; }); afterEach(async () => { @@ -162,6 +225,15 @@ async function readDnsmasqConf(root: string): Promise { return await Bun.file(dnsmasqConf).text(); } +async function fileExists(path: string): Promise { + try { + await Bun.file(path).stat(); + return true; + } catch { + return false; + } +} + async function writeComposeFile(path: string): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, "services: {}\n"); @@ -362,3 +434,280 @@ test("global up falls back to interactive sudo when stdin is tty but stdout is r } } }); + +test("global trust prepares host runtime trust env for future shells", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + await writeComposeFile(caddyCompose); + + const localCaPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + await mkdir(dirname(localCaPath), { recursive: true }); + await writeFile( + localCaPath, + "-----BEGIN CERTIFICATE-----\nLOCAL\n-----END CERTIFICATE-----\n" + ); + + execMockResponder = (cmd) => { + if ( + cmd[0] === "docker" && + cmd[1] === "compose" && + cmd[2] === "-f" && + cmd[4] === "ps" + ) { + return { exitCode: 0, stdout: "caddy-123\n", stderr: "" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-c" + ) { + return { exitCode: 0, stdout: "already trusted", stderr: "" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-a" && + cmd[3] === "-p" + ) { + return { + exitCode: 0, + stdout: + "-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n", + stderr: "", + }; + } + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "trust"]); + + const bundlePath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-bundle.pem" + ); + const envScriptPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-env.sh" + ); + + expect(code).toBe(0); + expect(await Bun.file(bundlePath).text()).toContain("LOCAL"); + expect(await Bun.file(bundlePath).text()).toContain("SYSTEM"); + expect(await Bun.file(envScriptPath).text()).toContain("NODE_EXTRA_CA_CERTS"); + expect(execCalls).toEqual( + expect.arrayContaining([ + [ + "security", + "find-certificate", + "-a", + "-p", + "/System/Library/Keychains/SystemRootCertificates.keychain", + ], + ]) + ); + expect(execCalls).not.toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + "security", + "find-certificate", + "-a", + "-p", + "/Library/Keychains/System.keychain", + ]), + expect.arrayContaining([ + "security", + "find-certificate", + "-a", + "-p", + resolve(tempDir!, "Library", "Keychains", "login.keychain-db"), + ]), + ]) + ); + expect(runCalls).toEqual( + expect.arrayContaining([ + [ + "docker", + "cp", + "caddy-123:/data/caddy/pki/authorities/local/root.crt", + localCaPath, + ], + ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", localCaPath], + ["launchctl", "setenv", "SSL_CERT_FILE", bundlePath], + ["launchctl", "setenv", "CURL_CA_BUNDLE", bundlePath], + ["launchctl", "setenv", "REQUESTS_CA_BUNDLE", bundlePath], + ["launchctl", "setenv", "GIT_SSL_CAINFO", bundlePath], + ]) + ); +}); + +test("global trust leaves host TLS env unchanged when keychain trust is declined", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + await writeComposeFile(caddyCompose); + confirmResponder = () => false; + + const localCaPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + await mkdir(dirname(localCaPath), { recursive: true }); + await writeFile( + localCaPath, + "-----BEGIN CERTIFICATE-----\nLOCAL\n-----END CERTIFICATE-----\n" + ); + + execMockResponder = (cmd) => { + if ( + cmd[0] === "docker" && + cmd[1] === "compose" && + cmd[2] === "-f" && + cmd[4] === "ps" + ) { + return { exitCode: 0, stdout: "caddy-123\n", stderr: "" }; + } + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "trust"]); + + const bundlePath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-bundle.pem" + ); + const envScriptPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-env.sh" + ); + + expect(code).toBe(0); + expect(await fileExists(bundlePath)).toBe(false); + expect(await fileExists(envScriptPath)).toBe(false); + expect(runCalls).toEqual( + expect.arrayContaining([ + [ + "docker", + "cp", + "caddy-123:/data/caddy/pki/authorities/local/root.crt", + localCaPath, + ], + ]) + ); + expect(runCalls).not.toEqual( + expect.arrayContaining([ + ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", localCaPath], + ]) + ); +}); + +test("global trust falls back to an existing exported CA when Caddy is unavailable", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + await writeComposeFile(caddyCompose); + + const localCaPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + await mkdir(dirname(localCaPath), { recursive: true }); + await writeFile( + localCaPath, + "-----BEGIN CERTIFICATE-----\nLOCAL\n-----END CERTIFICATE-----\n" + ); + + execMockResponder = (cmd) => { + if (cmd[0] === "docker" && cmd[1] === "info") { + return { exitCode: 1, stdout: "", stderr: "down" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-c" + ) { + return { exitCode: 0, stdout: "already trusted", stderr: "" }; + } + if ( + cmd[0] === "security" && + cmd[1] === "find-certificate" && + cmd[2] === "-a" && + cmd[3] === "-p" + ) { + return { + exitCode: 0, + stdout: + "-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n", + stderr: "", + }; + } + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "trust"]); + + const bundlePath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-bundle.pem" + ); + const envScriptPath = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-host-trust-env.sh" + ); + + expect(code).toBe(0); + expect(await Bun.file(bundlePath).text()).toContain("LOCAL"); + expect(await Bun.file(bundlePath).text()).toContain("SYSTEM"); + expect(await Bun.file(envScriptPath).text()).toContain("NODE_EXTRA_CA_CERTS"); + expect(runCalls).toEqual( + expect.arrayContaining([ + ["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", localCaPath], + ["launchctl", "setenv", "SSL_CERT_FILE", bundlePath], + ]) + ); + expect(runCalls).not.toEqual( + expect.arrayContaining([expect.arrayContaining(["docker", "cp"])]) + ); +}); diff --git a/tests/project-lifecycle-processes.test.ts b/tests/project-lifecycle-processes.test.ts index 809e9520..df7894e7 100644 --- a/tests/project-lifecycle-processes.test.ts +++ b/tests/project-lifecycle-processes.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { collectDescendantProcessGroupIds, + mergeLifecycleCommandEnv, parseProcessSnapshotOutput, resolveLifecycleProcessGroupIdsForTmuxState, wrapLifecyclePersistentCommand, @@ -12,12 +13,14 @@ import { import { readLifecycleState } from "../src/lib/lifecycle-runtime.ts"; const tempDirs = new Set(); +const originalHome = process.env.HOME; afterEach(async () => { for (const tempDir of tempDirs) { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); + process.env.HOME = originalHome; }); test("readLifecycleState preserves lifecycle pane and process group metadata", async () => { @@ -152,6 +155,66 @@ test("wrapLifecyclePersistentCommand avoids login-shell execution", () => { ); }); +test("mergeLifecycleCommandEnv appends local Hack CA trust for host processes", async () => { + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const merged = await mergeLifecycleCommandEnv({ + APP_ENV: "dev", + }); + + expect(merged).toMatchObject({ + APP_ENV: "dev", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + +test("mergeLifecycleCommandEnv preserves explicit TLS env values", async () => { + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const merged = await mergeLifecycleCommandEnv({ + APP_ENV: "dev", + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); + + expect(merged).toMatchObject({ + APP_ENV: "dev", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); +}); + async function createLifecycleProjectDir(): Promise { const root = await mkdtemp(join(tmpdir(), "hack-lifecycle-processes-")); tempDirs.add(root); diff --git a/tests/session-env-command.test.ts b/tests/session-env-command.test.ts index 1374426e..b1abfd5d 100644 --- a/tests/session-env-command.test.ts +++ b/tests/session-env-command.test.ts @@ -23,6 +23,7 @@ const execInSessionCalls: Array<{ readonly env: Record | undefined; }> = []; const tempDirs = new Set(); +const originalHome = process.env.HOME; let registeredProject: { readonly id: string; readonly name: string; @@ -130,6 +131,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); + process.env.HOME = originalHome; }); afterAll(() => { @@ -337,6 +339,69 @@ test("session exec treats explicit --service global as an env injection request" expect(execInSessionCalls).toHaveLength(0); }); +test("session start includes local Hack CA trust when available", async () => { + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const projectRoot = await createProject(); + const startCommand = findSubcommand("start"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + up: false, + new: false, + name: undefined, + detach: true, + env: "qa", + service: "api", + }, + positionals: { + project: "session-env-test", + }, + raw: { + argv: [ + "--detach", + "--env", + "qa", + "--service", + "api", + "session-env-test", + ], + positionals: ["session-env-test"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await startCommand.handler(input); + + expect(exitCode).toBe(0); + expect(createSessionCalls).toHaveLength(1); + expect(createSessionCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: resolve(caDir, "caddy-local-authority.crt"), + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: resolve(caDir, "caddy-host-trust-bundle.pem"), + }); +}); + test("session start accepts compose-compatible service scope names", async () => { const projectRoot = await createProject(); const startCommand = findSubcommand("start"); @@ -387,6 +452,76 @@ test("session start accepts compose-compatible service scope names", async () => }); }); +test("session start preserves explicit TLS env values while adding Hack trust metadata", async () => { + const projectRoot = await createProject({ + globalValues: { + GLOBAL_FLAG: "base", + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }, + }); + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; + + const caDir = resolve(homeRoot, ".hack", "caddy", "pki"); + await mkdir(caDir, { recursive: true }); + await writeFile(resolve(caDir, "caddy-local-authority.crt"), "local-ca\n"); + await writeFile( + resolve(caDir, "caddy-host-trust-bundle.pem"), + "system-ca\nlocal-ca\n" + ); + + const startCommand = findSubcommand("start"); + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + up: false, + new: false, + name: undefined, + detach: true, + env: "qa", + service: "api", + }, + positionals: { + project: "session-env-test", + }, + raw: { + argv: [ + "--detach", + "--env", + "qa", + "--service", + "api", + "session-env-test", + ], + positionals: ["session-env-test"], + }, + }, + } as unknown as Parameters[0]; + + const exitCode = await startCommand.handler(input); + + expect(exitCode).toBe(0); + expect(createSessionCalls).toHaveLength(1); + expect(createSessionCalls[0]?.env).toEqual({ + API_BASE_URL: "https://qa.example.com", + CURL_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + GIT_SSL_CAINFO: resolve(caDir, "caddy-host-trust-bundle.pem"), + GLOBAL_FLAG: "base", + HACK_HOST_TRUST_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + HACK_LOCAL_CA_CERT: resolve(caDir, "caddy-local-authority.crt"), + NODE_EXTRA_CA_CERTS: "/tmp/custom-extra.pem", + REQUESTS_CA_BUNDLE: resolve(caDir, "caddy-host-trust-bundle.pem"), + SERVICE_TOKEN: "overlay-secret", + SSL_CERT_FILE: "/tmp/custom-bundle.pem", + }); +}); + type SessionSubcommandName = (typeof sessionCommand.subcommands)[number]["name"]; type SessionSubcommand = Extract< @@ -410,7 +545,10 @@ function findSubcommand( }; } -async function createProject(): Promise { +async function createProject(input?: { + readonly globalValues?: Readonly>; +}): Promise { + await ensureIsolatedHome(); const root = await mkdtemp(join(tmpdir(), "hack-session-env-")); tempDirs.add(root); @@ -434,15 +572,20 @@ async function createProject(): Promise { )}\n` ); - await setProjectEnvValue({ - projectRoot, - projectDir, - envName: null, - scope: "global", - key: "GLOBAL_FLAG", - value: "base", - secret: false, - }); + const globalValues = input?.globalValues ?? { + GLOBAL_FLAG: "base", + }; + for (const [key, value] of Object.entries(globalValues)) { + await setProjectEnvValue({ + projectRoot, + projectDir, + envName: null, + scope: "global", + key, + value, + secret: false, + }); + } await setProjectEnvValue({ projectRoot, projectDir, @@ -482,3 +625,12 @@ async function createProject(): Promise { return projectRoot; } + +async function ensureIsolatedHome(): Promise { + if (process.env.HOME && process.env.HOME !== originalHome) { + return; + } + const homeRoot = await mkdtemp(join(tmpdir(), "hack-home-")); + tempDirs.add(homeRoot); + process.env.HOME = homeRoot; +}