diff --git a/AGENTS.md b/AGENTS.md index 10961c9d..fb33e3da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,8 +293,9 @@ Workspaces (mux-managed, tmux-first by default): - Stop workspace: `hack session stop ` Host-side env helpers: -- One-off host command with injected env: `hack env exec --env qa --service api -- bun db:migrate` -- Interactive host shell with injected env: `hack env shell --env qa --service api` +- One-off host command with injected env: `hack host exec --env qa --scope api -- bun db:migrate` +- `--scope` selects which env scope to inject; it does not move execution into that service container. +- Interactive host shell with injected env: `hack host shell --env qa --scope api` Tickets (git-backed): - Create: `hack tickets create --title "..." --body-stdin` diff --git a/README.md b/README.md index 1892a6a0..53862666 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,15 @@ Core commands: hack env list hack env add AWS_PROFILE dev hack env add --secret DATABASE_URL postgres://... -hack env exec --env qa --service api -- bun db:migrate -hack env exec --env qa --service api --target compose -- bun test -hack env shell --env qa --service api +hack host exec --env qa --scope api -- bun db:migrate +hack host exec --env qa --scope api --target compose -- bun test +hack host shell --env qa --scope api ``` -`hack env exec` and `hack env shell` now default to a host-local env view for host commands. Use -`--target compose` when you explicitly want the container-oriented addresses from the compose view. +`hack host exec` and `hack host shell` run on your host machine with Hack-resolved env injected. +Use `--scope` when you want service-scoped values without running inside that service container. +Use `--target compose` when you explicitly want the container-oriented addresses from the compose +view instead of the default host-local rewrites. Docs: diff --git a/docs/cli.md b/docs/cli.md index 7802ed7d..36d8960d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -92,6 +92,7 @@ Current practical rule: | `hack auth` | Manage Hack account sign-in | Collaboration & integrations | | `hack linear` | Connect Linear and sync repo work with Linear projects/issues | Collaboration & integrations | | `hack env` | Set project env vars and local secrets | Collaboration & integrations | +| `hack host` | Run host-side commands with project env injected | Collaboration & integrations | | `hack session` | Manage persistent project workspaces with tmux-first onboarding | Collaboration & integrations | | `hack ssh` | Show SSH connection info for remote access to this machine | Collaboration & integrations | | `hack tickets` | Track repo-local work without leaving git | Collaboration & integrations | @@ -1664,6 +1665,61 @@ Options: | `--service ` | string | - | Resolve values for one service scope | | `--target ` | string | `host` | Host-local env view for host commands, or raw compose view | +### hack host + +Usage: `hack host ` + +Use `hack host` when a command should run on the host machine, not inside the compose network, but +still needs Hack-resolved env. `--scope` selects which env scope to inject; it does not choose a +container execution target. + +If you need a one-off command in the compose network, use `hack run ...` instead. +If you need to run inside an already-running container, use `docker compose exec ...` +today. + +Subcommands: + +| Subcommand | Summary | +| --- | --- | +| `exec` | Run a host command with project env injected | +| `shell` | Open a host shell with project env injected | + +#### hack host exec + +Usage: `hack host exec [options] ` + +Runs a host command with injected env. Default target is `host`, which applies the host-local env +view and any `host` scope overrides. Use `--target compose` to preserve the container-oriented +compose view while still running on the host. + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--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 | + +#### hack host shell + +Usage: `hack host shell [options]` + +Starts an interactive host shell with injected env. Default target is `host`, which applies the +host-local env view and any `host` scope overrides. Use `--target compose` to preserve the +container-oriented compose view while still running on the host. + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--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 | + #### hack env unset Usage: `hack env unset [key] [options]` diff --git a/docs/env.md b/docs/env.md index 1f17898c..61942cc5 100644 --- a/docs/env.md +++ b/docs/env.md @@ -97,6 +97,8 @@ Hack reads the canonical YAML files and injects the resolved env directly into: - `hack run` - `hack restart` - lifecycle host processes +- `hack host exec` +- `hack host shell` - `hack env exec` - `hack env shell` - `hack session start --env ... --service ...` @@ -106,14 +108,23 @@ That means `.hack/.env` is no longer the primary runtime source of truth. `hack env materialize` is manual by design. Use it only when you need a compatibility file for an external tool that expects `.env` on disk. -For host commands, `hack env exec` and `hack env shell` default to a host-local view. That means -Hack prefers host-usable values when it can: +For host commands, `hack host exec`, `hack host shell`, `hack env exec`, and `hack env shell` +default to a host-local view. That means Hack prefers host-usable values when it can: - explicit `host` scope values override the normal `global` + `` merge - common container-only hostnames such as `host.docker.internal` and compose service hosts are rewritten to `127.0.0.1` Use `--target compose` if you explicitly want the container-oriented compose view instead. +## Choosing the right execution surface + +- `hack host exec` / `hack host shell`: run on your host machine with Hack-resolved env injected. +- `hack run ...`: run a one-off command in the compose network with an ephemeral service container. +- `docker compose exec ...`: run inside an already-running container when you specifically need that container's live filesystem or process context. + +For host execution, `--scope` means "which env scope should Hack inject", not "where should this run." +For example, `hack host exec --scope api -- bun db:migrate` still runs on the host. + ## Common commands Inspect resolved env: @@ -153,17 +164,17 @@ hack env materialize --env qa --service api Run a host command with injected env: ```bash -hack env exec -- bun db:migrate -hack env exec --env qa --service api -- bun db:migrate -hack env exec --env qa --service api --target compose -- bun test +hack host exec -- bun db:migrate +hack host exec --env qa --scope api -- bun db:migrate +hack host exec --env qa --scope api --target compose -- bun test ``` Open a host shell with injected env: ```bash -hack env shell -hack env shell --env qa --service api -hack env shell --env qa --service api --target compose +hack host shell +hack host shell --env qa --scope api +hack host shell --env qa --scope api --target compose ``` Example with an explicit host override: @@ -252,7 +263,7 @@ If you are writing new docs or new project setup flows, document the YAML overla - Never commit `.hack.secret.key` - Prefer `hack env add` over hand-editing encrypted values - Prefer direct runtime injection over materializing `.hack/.env` -- Use `hack env exec` or env-aware sessions for host-side scripts like migrations, generators, and admin tasks +- Use `hack host exec` or env-aware sessions for host-side scripts like migrations, generators, and admin tasks ## Related docs diff --git a/docs/guides/codex-managed-environments.md b/docs/guides/codex-managed-environments.md index 30a001c0..44e9d17d 100644 --- a/docs/guides/codex-managed-environments.md +++ b/docs/guides/codex-managed-environments.md @@ -78,8 +78,8 @@ bash scripts/maintain-codex-slim.sh - `hack linear` - `hack x github` - `hack env list` -- `hack env exec` -- `hack env shell` +- `hack host exec` +- `hack host shell` - repo-local docs, specs, and agent setup - command and config surfaces that do not require the machine-wide runtime stack @@ -88,11 +88,12 @@ If the repo uses the modern env overlay model, provide secret decryption materia ```bash export HACK_ENV_SECRET_KEY="..." -hack env exec --env qa --service api -- bun db:migrate -hack env exec --env qa --service api --target compose -- bun test +hack host exec --env qa --scope api -- bun db:migrate +hack host exec --env qa --scope api --target compose -- bun test ``` -`hack env exec` and `hack env shell` default to a host-local env view for host commands. Use +`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. ## Not available in slim mode diff --git a/docs/integrations.md b/docs/integrations.md index ef019eb1..1c46f3e5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -122,13 +122,17 @@ Main surface: - `hack env list` - `hack env add` +- `hack host exec` +- `hack host shell` - `hack env exec` - `hack env shell` - `hack env unset` - `hack env materialize` -`hack env exec` and `hack env shell` default to a host-local env view for host-side commands. Use -`--target compose` when you explicitly want the raw compose/container-oriented values. +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. Reference: diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 48fb85de..e233ef68 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -7,7 +7,7 @@ import { crashCaptureCommand } from "../commands/crash-capture.ts"; import { daemonCommand } from "../commands/daemon.ts"; import { dispatchCommand } from "../commands/dispatch.ts"; import { doctorCommand } from "../commands/doctor.ts"; -import { envCommand } from "../commands/env.ts"; +import { envCommand, hostCommand } from "../commands/env.ts"; import { gatewayCommand } from "../commands/gateway.ts"; import { globalCommand } from "../commands/global.ts"; import { helpCommand } from "../commands/help.ts"; @@ -88,6 +88,7 @@ export const CLI_SPEC = defineCli({ theCommand, secretsCommand, envCommand, + hostCommand, configCommand, mcpCommand, setupCommand, diff --git a/src/commands/env.ts b/src/commands/env.ts index f3986f3a..f4308e76 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -102,6 +102,15 @@ const optService = defineOption({ description: "Target scope (global or a discovered service name)", } as const); +const optScope = defineOption({ + name: "scope", + type: "string", + long: "--scope", + valueHint: "", + description: + "Resolve values for one env scope while still running the command on the host", +} as const); + const optTarget = defineOption({ name: "target", type: "string", @@ -195,6 +204,28 @@ const shellSpec = defineCommand({ subcommands: [], } as const); +const hostExecSpec = defineCommand({ + name: "exec", + 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 }], + subcommands: [], +} as const); + +const hostShellSpec = defineCommand({ + name: "shell", + summary: "Open a host shell with project env injected", + group: "Project", + description: + "Start an interactive host shell 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: [], + subcommands: [], +} as const); + const unsetSpec = defineCommand({ name: "unset", summary: "Remove an env value from the canonical config", @@ -1533,24 +1564,41 @@ function resolveInteractiveShellCommand(): readonly string[] { return [shellPath, "-l"]; } -const handleEnvExec: CommandHandlerFor = async ({ - ctx, - args, -}): Promise => { +function resolveExecutionScopeName(input: { + readonly scopeName?: string; + readonly serviceName?: string; +}): string | null { + const scopeName = input.scopeName?.trim(); + const serviceName = input.serviceName?.trim(); + if (scopeName && serviceName) { + throw new CliUsageError("Use either --scope or --service, not both."); + } + return scopeName || serviceName || null; +} + +async function runHostCommandWithInjectedEnv(input: { + readonly ctx: CliContext; + readonly pathOpt: string | undefined; + readonly projectOpt: string | undefined; + readonly envOpt: string | undefined; + readonly scopeName?: string; + readonly serviceName?: string; + readonly targetOpt: string | undefined; + readonly command: readonly string[]; +}): Promise { const project = await resolveProjectForEnv({ - ctx, - pathOpt: args.options.path, - projectOpt: args.options.project, + ctx: input.ctx, + pathOpt: input.pathOpt, + projectOpt: input.projectOpt, }); const projectName = await resolveProjectName(project); const envName = resolveRequestedEnvName({ - envOption: args.options.env, + envOption: input.envOpt, }); const target = resolveHostEnvTarget({ - targetOption: args.options.target, + targetOption: input.targetOpt, }); - const command = args.positionals.command; - if (command.length === 0) { + if (input.command.length === 0) { throw new CliUsageError("Command is required."); } @@ -1558,37 +1606,48 @@ const handleEnvExec: CommandHandlerFor = async ({ project, projectName, envName, - serviceName: args.options.service?.trim() || null, + serviceName: resolveExecutionScopeName({ + scopeName: input.scopeName, + serviceName: input.serviceName, + }), target, }); - return await run(command, { + return await run(input.command, { cwd: project.projectRoot, env: envState.env, stdin: "inherit", }); -}; +} -const handleEnvShell: CommandHandlerFor = async ({ - ctx, - args, -}): Promise => { +async function openHostShellWithInjectedEnv(input: { + readonly ctx: CliContext; + readonly pathOpt: string | undefined; + readonly projectOpt: string | undefined; + readonly envOpt: string | undefined; + readonly scopeName?: string; + readonly serviceName?: string; + readonly targetOpt: string | undefined; +}): Promise { const project = await resolveProjectForEnv({ - ctx, - pathOpt: args.options.path, - projectOpt: args.options.project, + ctx: input.ctx, + pathOpt: input.pathOpt, + projectOpt: input.projectOpt, }); const projectName = await resolveProjectName(project); const envName = resolveRequestedEnvName({ - envOption: args.options.env, + envOption: input.envOpt, }); const target = resolveHostEnvTarget({ - targetOption: args.options.target, + targetOption: input.targetOpt, }); const envState = await resolveEnvInjection({ project, projectName, envName, - serviceName: args.options.service?.trim() || null, + serviceName: resolveExecutionScopeName({ + scopeName: input.scopeName, + serviceName: input.serviceName, + }), target, }); @@ -1597,6 +1656,64 @@ const handleEnvShell: CommandHandlerFor = async ({ env: envState.env, stdin: "inherit", }); +} + +const handleEnvExec: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + return await runHostCommandWithInjectedEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + envOpt: args.options.env, + serviceName: args.options.service, + targetOpt: args.options.target, + command: args.positionals.command, + }); +}; + +const handleEnvShell: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + return await openHostShellWithInjectedEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + envOpt: args.options.env, + serviceName: args.options.service, + targetOpt: args.options.target, + }); +}; + +const handleHostExec: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + return await runHostCommandWithInjectedEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + envOpt: args.options.env, + scopeName: args.options.scope, + targetOpt: args.options.target, + command: args.positionals.command, + }); +}; + +const handleHostShell: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + return await openHostShellWithInjectedEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + envOpt: args.options.env, + scopeName: args.options.scope, + targetOpt: args.options.target, + }); }; const handleEnvUnset: CommandHandlerFor = async ({ @@ -1958,3 +2075,18 @@ export const envCommand = defineCommand({ ), ], } as const); + +export const hostCommand = defineCommand({ + name: "host", + summary: "Run host-side commands with project env injected", + group: "Integrations", + expandInRootHelp: true, + description: + "Use hack host when a command should run on your host machine, not inside the compose network, but still needs Hack-resolved env and host-local rewrites.", + options: [], + positionals: [], + subcommands: [ + withHandler(hostExecSpec, handleHostExec), + withHandler(hostShellSpec, handleHostShell), + ], +} as const); diff --git a/src/mcp/agent-docs.ts b/src/mcp/agent-docs.ts index 8d35078b..1185fe79 100644 --- a/src/mcp/agent-docs.ts +++ b/src/mcp/agent-docs.ts @@ -244,9 +244,10 @@ export function renderAgentDocsSnippet(): string { "- Stop workspace: `hack session stop `", "", "Host-side env helpers:", - "- One-off host command with injected env: `hack env exec --env qa --service api -- bun db:migrate`", + "- One-off host command with injected env: `hack host exec --env qa --scope api -- bun db:migrate`", "- Host commands default to a host-local env view; use `--target compose` when you explicitly want container-oriented addresses.", - "- Interactive host shell with injected env: `hack env shell --env qa --service api`", + "- `--scope` selects which env scope to inject; it does not move execution into that service container.", + "- Interactive host shell with injected env: `hack host shell --env qa --scope api`", "", "Tickets (git-backed):", '- Create: `hack tickets create --title "..." --body-stdin`', diff --git a/tests/cli-command.test.ts b/tests/cli-command.test.ts index 4f0845b5..6d47ea92 100644 --- a/tests/cli-command.test.ts +++ b/tests/cli-command.test.ts @@ -116,6 +116,17 @@ test("resolveCommand finds nested project owner show command", () => { ]); expect(resolved.remainingPositionals).toEqual([]); }); + +test("resolveCommand exposes host exec path", () => { + const resolved = resolveCommand(CLI_SPEC, ["host", "exec", "bun", "test"]); + expect(resolved.command?.name).toBe("exec"); + expect(resolved.path.map((command) => command.name)).toEqual([ + "host", + "exec", + ]); + expect(resolved.remainingPositionals).toEqual(["bun", "test"]); +}); + test("resolveCommand exposes org, team, and auth invite command paths", () => { const orgResolved = resolveCommand(CLI_SPEC, ["org", "member", "invite"]); expect(orgResolved.command?.name).toBe("invite"); diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index df218ba0..9db79da1 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -36,7 +36,7 @@ mock.module("../src/lib/shell.ts", () => ({ }, })); -const { envCommand } = await import("../src/commands/env.ts"); +const { envCommand, hostCommand } = await import("../src/commands/env.ts"); afterEach(async () => { runCalls.length = 0; @@ -102,6 +102,57 @@ test("env exec injects merged overlay env into one-off host commands", async () }); }); +test("host exec injects scoped env into one-off host commands", 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: ["bun", "db:migrate"], + }, + raw: { + argv: [ + "--path", + projectRoot, + "--env", + "qa", + "--scope", + "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]).toEqual({ + cmd: ["bun", "db:migrate"], + cwd: projectRoot, + env: { + API_BASE_URL: "https://qa.example.com", + GLOBAL_FLAG: "base", + SERVICE_TOKEN: "overlay-secret", + }, + }); +}); + test("env shell opens the current shell with injected project env", async () => { const projectRoot = await createProject(); process.env.SHELL = "/bin/zsh"; @@ -142,6 +193,46 @@ test("env shell opens the current shell with injected project env", async () => }); }); +test("host shell opens the current shell with injected project env", async () => { + const projectRoot = await createProject(); + process.env.SHELL = "/bin/zsh"; + const shellCommand = findHostSubcommand("shell"); + + const input = { + ctx: { + cwd: projectRoot, + cli: CLI_SPEC, + }, + args: { + options: { + path: projectRoot, + project: undefined, + env: "qa", + scope: undefined, + target: undefined, + }, + positionals: {}, + raw: { + argv: ["--path", projectRoot, "--env", "qa"], + 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", + GLOBAL_FLAG: "base", + }, + }); +}); + test("env exec defaults to a host-local view for host-like env values", async () => { const projectRoot = await createProject({ services: ["api", "redis"], @@ -247,6 +338,11 @@ type EnvSubcommand = Extract< (typeof envCommand.subcommands)[number], { readonly name: N } >; +type HostSubcommandName = (typeof hostCommand.subcommands)[number]["name"]; +type HostSubcommand = Extract< + (typeof hostCommand.subcommands)[number], + { readonly name: N } +>; function findSubcommand( name: N @@ -262,6 +358,20 @@ function findSubcommand( }; } +function findHostSubcommand( + name: N +): HostSubcommand & { + readonly handler: CommandHandlerFor>; +} { + const command = hostCommand.subcommands.find((entry) => entry.name === name); + if (!(command && "handler" in command)) { + throw new Error(`Missing host subcommand: ${name}`); + } + return command as HostSubcommand & { + readonly handler: CommandHandlerFor>; + }; +} + async function createProject(input?: { readonly services?: readonly string[]; readonly defaultYaml?: string;