diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 431e2b63..1dd5aacc 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -12,11 +12,11 @@ Use `hack` as the primary interface for local-first development. ## Integration freshness -- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). +- Content revision: `902b04fe1469` (version alone is not a freshness guarantee). ## Product boundary @@ -52,6 +52,7 @@ Use `hack` as the primary interface for local-first development. - Primary host comes from `dev_host` (default: `.hack`). - Subdomain pattern is `.` (for example: `api.myapp.hack`). - OAuth alias (when enabled) also routes `.` and `..` (default tld: `gy`). +- Browser launches automatically prefer a routed OAuth alias when enabled; custom development hosts outside `.hack` stay on the primary host. Set `open.prefer` or pass `hack open --prefer ` to override. - Not every compose service is routable: only services with Caddy labels and on `hack-dev` are exposed. - Required labels for HTTP services: `caddy`, `caddy.reverse_proxy`, `caddy.tls=internal`. - Quick checks: `hack open`, `hack open `, `hack open --json`. @@ -70,7 +71,7 @@ Use `hack` as the primary interface for local-first development. - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. +- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose..override.yml`, `.hack/.branch/compose..runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. - Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). @@ -104,7 +105,7 @@ Use `hack` as the primary interface for local-first development. - Command inside an already-running service container: `hack exec -- `. - Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly. - Interactive host shell with injected env: `hack host shell --env --scope `. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS. ## Logs (default is compose) diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index f0dd0cfa..e8f4adb4 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -7,11 +7,11 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is ## Integration freshness -- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). +- Content revision: `902b04fe1469` (version alone is not a freshness guarantee). ## Product boundary @@ -34,7 +34,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. +- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose..override.yml`, `.hack/.branch/compose..runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. - Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). @@ -61,7 +61,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - Command inside an already-running service container: `hack exec -- `. - Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly. - Interactive host shell with injected env: `hack host shell --env --scope `. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS. ## Lifecycle + startup diff --git a/AGENTS.md b/AGENTS.md index e986574f..f3c2d2e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,11 +210,11 @@ Most formatting and common issues are automatically fixed by Biome. Run `bun x u Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). +- Content revision: `902b04fe1469` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -245,6 +245,7 @@ Hostname routing + Caddy labels: - Primary host comes from `dev_host` (default: `.hack`). - Subdomain pattern is `.` (for example: `api.myapp.hack`). - OAuth alias (when enabled) also routes `.` and `..` (default tld: `gy`). +- Browser launches automatically prefer a routed OAuth alias when enabled; custom development hosts outside `.hack` stay on the primary host. Set `open.prefer` or pass `hack open --prefer ` to override. - Not every compose service is routable: only services with Caddy labels and on `hack-dev` are exposed. - Required labels for HTTP services: `caddy`, `caddy.reverse_proxy`, `caddy.tls=internal`. - Quick checks: `hack open`, `hack open `, `hack open --json`. @@ -261,7 +262,7 @@ Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. +- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose..override.yml`, `.hack/.branch/compose..runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. - Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). @@ -291,7 +292,7 @@ Running things (decision guide): - Command inside an already-running service container: `hack exec -- `. - Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly. - Interactive host shell with injected env: `hack host shell --env --scope `. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS. Logs (default is compose): - Fast tail: `hack logs --pretty` diff --git a/CLAUDE.md b/CLAUDE.md index 9ed6223b..1ca59aa7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,11 +80,11 @@ This project uses Obsidian for project context, specs, research, and progress tr Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces). Integration freshness: -- These instructions were generated by hack CLI v3.3.6; treat cached rules from another version as potentially stale. +- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale. - At session start, audit project and global integrations with `hack setup sync --all-scopes --check`. - If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced. - Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command. -- Content revision: `64098e5ae1ea` (version alone is not a freshness guarantee). +- Content revision: `902b04fe1469` (version alone is not a freshness guarantee). Product boundary: - Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -115,6 +115,7 @@ Hostname routing + Caddy labels: - Primary host comes from `dev_host` (default: `.hack`). - Subdomain pattern is `.` (for example: `api.myapp.hack`). - OAuth alias (when enabled) also routes `.` and `..` (default tld: `gy`). +- Browser launches automatically prefer a routed OAuth alias when enabled; custom development hosts outside `.hack` stay on the primary host. Set `open.prefer` or pass `hack open --prefer ` to override. - Not every compose service is routable: only services with Caddy labels and on `hack-dev` are exposed. - Required labels for HTTP services: `caddy`, `caddy.reverse_proxy`, `caddy.tls=internal`. - Quick checks: `hack open`, `hack open `, `hack open --json`. @@ -131,7 +132,7 @@ Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. - Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). -- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. +- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose..override.yml`, `.hack/.branch/compose..runtime.override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. - Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk). @@ -161,7 +162,7 @@ Running things (decision guide): - Command inside an already-running service container: `hack exec -- `. - Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly. - Interactive host shell with injected env: `hack host shell --env --scope `. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS. Logs (default is compose): - Fast tail: `hack logs --pretty` diff --git a/docs/architecture.md b/docs/architecture.md index c96d14ce..383b0b09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,7 +62,8 @@ graph LR - Project scope (`.hack`) - `docker-compose.yml` defines services + Caddy labels - - `hack.config.json` stores project name, dev host, log preferences, OAuth alias + - `hack.config.json` stores project name, dev host, log preferences, OAuth alias, and browser + launch preference (`open.prefer`) - `controlPlane.gateway.enabled` marks the project as gateway-capable - Optional overrides: - `.internal/compose.override.yml` (generated internal DNS/TLS injection) @@ -321,9 +322,11 @@ one, or set `worktree.auto_branch=false` to opt into the base instance explicitl - `.env` (optional, only when explicitly materialized) - `.internal/compose.override.yml` - `.internal/compose.env.override.yml` + - `.internal/compose.runtime.override.yml` - `.internal/extra-hosts.json` - `.internal/lifecycle/state.json` and `.internal/lifecycle/*.log` - `.branch/compose..override.yml` + - `.branch/compose..runtime.override.yml` ## Key design choices diff --git a/docs/cli.md b/docs/cli.md index 7d1401cf..a78bee83 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -80,6 +80,20 @@ Agent-assisted alternative for a new repo: `hack init --with claude|codex|both`. project without `.hack/`, use `hack agent onboard`. See [Agent-first setup](guides/agent-first-setup.md). +### Browser URL preference + +`hack open` keeps `dev_host` as the primary routing identity, but automatically opens the OAuth +alias (for example, `myapp.hack.gy`) when `oauth.enabled` is true. Service shorthand and branch +instances follow the same preference, so `hack open api --branch feature-x` resolves the +branch-qualified alias URL. + +Set `open.prefer` in `.hack/hack.config.json` to `auto` (the default), `alias`, or `dev`. Override +one invocation with `hack open --prefer `. Explicit URLs and fully qualified host +targets are preserved. Selecting `alias` without an enabled OAuth alias fails with recovery +guidance instead of silently opening the dev host. For a custom `dev_host` outside Hack's managed +`.hack` namespace, `auto` keeps the development host because Hack does not synthesize a Caddy alias +route for it. + ## Running things (decision guide) - One-off command in a fresh service container (deps started as needed): `hack run `. @@ -87,8 +101,8 @@ project without `.hack/`, use `hack agent onboard`. See - Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is the way to run repo scripts; never read `.env` files directly. - Interactive host shell with injected env: `hack host shell --env --scope `. -- Call a service over HTTP (from the host or between containers): use its Caddy hostname - `https://.`; discover routable URLs with `hack open --json`. +- Browser/host URL: use `hack open --json`; OAuth aliases are preferred when enabled. +- Container-to-container traffic: use Compose DNS rather than routing back through Caddy. Service-scoped runtime changes do not run project-wide lifecycle hooks and do not start Compose dependencies implicitly: @@ -169,7 +183,7 @@ browser-facing URLs isolated when the same project runs in multiple worktrees. | `HACK_BRANCH` | Effective branch slug, or an empty string for the base instance | | `HACK_COMPOSE_PROJECT` | Effective Compose project name | | `HACK_DEV_HOST` / `HACK_DEV_URL` | Effective root development host and HTTPS URL | -| `HACK_ALIAS_HOST` / `HACK_ALIAS_URL` | Effective OAuth alias host and URL, when enabled | +| `HACK_ALIAS_HOST` / `HACK_ALIAS_URL` | Effective OAuth alias host and URL, when enabled and routed by Hack | | `HACK_SERVICE_NAME` | Current Compose service name | | `HACK_SERVICE_URL` | Current service's first public URL, when routable | | `HACK_SERVICE_URLS` | JSON array of every public URL for the current service | @@ -204,6 +218,11 @@ over generated metadata for backward compatibility. Existing containers receive their next `hack up` or `hack restart`; `hack exec` observes the values already stored in the running container. +Hack materializes this contract in generated, machine-local Compose overrides. Do not edit or +commit `.hack/.internal/compose.runtime.override.yml` or +`.hack/.branch/compose..runtime.override.yml`; runtime commands refresh them and Hack's +managed `.hack/.gitignore` covers them. + Linked worktrees also inherit the project secret key automatically from the primary checkout through the shared git common dir, so you don't need to copy `.hack.secret.key` by hand. Set `HACK_ENV_SECRET_KEY` for CI or fully detached environments. `hack doctor` flags divergent secret @@ -234,7 +253,9 @@ Hack owns a committed `.hack/.gitignore` (self-healing on `init`/`up`) that igno generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`). Keep it committed. If generated files ever leak into git, `hack doctor --fix` untracks them (the files -stay on disk). See [Architecture](architecture.md) for the full file map. +stay on disk). Runtime metadata is written to `.internal/compose.runtime.override.yml` for the base +instance and `.branch/compose..runtime.override.yml` for branch instances. See +[Architecture](architecture.md) for the full file map. The global config root defaults to `~/.hack`; override it with `HACK_HOME`. diff --git a/docs/guides/init-project.md b/docs/guides/init-project.md index 3fbb0df1..13f7642b 100644 --- a/docs/guides/init-project.md +++ b/docs/guides/init-project.md @@ -13,7 +13,8 @@ Notes: - `hack init` writes `.hack/` files (Compose + config). - `hack init` also scaffolds `.hack/hack.env.default.yaml`. See `docs/env.md`. - `hack up` starts the stack on an isolated network. -- `hack open` resolves the routed URL via the global proxy. +- `hack open` resolves the routed URL via the global proxy and prefers the OAuth alias when + `oauth.enabled` is true. Set `open.prefer` or pass `--prefer auto|alias|dev` to override. Optional: - `hack logs --pretty` for log tailing. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 305f6915..30e8abd0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -327,7 +327,7 @@ hack logs [service] [options] ## `hack open [target]` -Open a URL for the project (default: https://.hack) +Open a project URL (OAuth alias by default when enabled; otherwise dev host) ### Usage @@ -348,6 +348,7 @@ hack open [target] [options] | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | +| `--prefer ` | Browser host preference (auto uses the OAuth alias when enabled) | | `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | @@ -476,6 +477,7 @@ hack branch open [options] | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--prefer ` | Browser host preference (auto uses the OAuth alias when enabled) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 64e62074..56c2d5ef 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -101,6 +101,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Primary host comes from `dev_host` (default: `.hack`).", "Subdomain pattern is `.` (for example: `api.myapp.hack`).", "OAuth alias (when enabled) also routes `.` and `..` (default tld: `gy`).", + "Browser launches automatically prefer a routed OAuth alias when enabled; custom development hosts outside `.hack` stay on the primary host. Set `open.prefer` or pass `hack open --prefer ` to override.", "Not every compose service is routable: only services with Caddy labels and on `hack-dev` are exposed.", "Required labels for HTTP services: `caddy`, `caddy.reverse_proxy`, `caddy.tls=internal`.", "Quick checks: `hack open`, `hack open `, `hack open --json`.", @@ -127,7 +128,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`.", "Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`.", "Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored).", - "Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`.", + "Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.internal/compose.runtime.override.yml`, `.hack/.branch/compose..override.yml`, `.hack/.branch/compose..runtime.override.yml`.", "Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).", "Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.", "Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).", @@ -177,7 +178,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Command inside an already-running service container: `hack exec -- `.", "Host script that needs hack-stored env: `hack host exec --env --scope -- ` — this is THE way to run repo scripts; never read .env files directly.", "Interactive host shell with injected env: `hack host shell --env --scope `.", - "Call a service over HTTP (from the host or between containers): use its Caddy hostname `https://.`; discover routable URLs with `hack open --json`.", + "Browser/host URL: use `hack open --json` (OAuth aliases are preferred when enabled). Container-to-container traffic should use Compose DNS.", ], }, { diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 3697a2c5..8091a590 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "64098e5ae1ea"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "902b04fe1469"; diff --git a/src/cli/options.ts b/src/cli/options.ts index 52f6e8fb..eff1d50b 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -91,6 +91,15 @@ export const optJson = defineOption({ description: "Output JSON (machine-readable)", } as const); +export const optOpenPreference = defineOption({ + name: "prefer", + type: "string", + long: "--prefer", + valueHint: "", + description: + "Browser host preference (auto uses the OAuth alias when enabled)", +} as const); + export const optNoInteractive = defineOption({ name: "noInteractive", type: "boolean", diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 40762f07..80241e5c 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -6,9 +6,13 @@ import { defineOption, withHandler, } from "../cli/command.ts"; -import { optPath, optProject } from "../cli/options.ts"; +import { optOpenPreference, optPath, optProject } from "../cli/options.ts"; import { DEFAULT_PROJECT_TLD, HACK_PROJECT_DIR_PRIMARY } from "../constants.ts"; import { readBranchesFile, writeBranchesFile } from "../lib/branches.ts"; +import { + parseOpenHostPreference, + resolvePreferredOpenHost, +} from "../lib/open-host.ts"; import { openUrl } from "../lib/os.ts"; import type { ProjectContext } from "../lib/project.ts"; import { @@ -16,7 +20,7 @@ import { findProjectContext, readProjectConfig, readProjectDevHost, - resolveProjectOauthTld, + resolveProjectOauthAliasHost, sanitizeBranchSlug, sanitizeProjectSlug, } from "../lib/project.ts"; @@ -40,7 +44,7 @@ const branchAddPositionals = [{ name: "name", required: true }] as const; const branchListOptions = [optPath, optProject] as const; const branchRemoveOptions = [optPath, optProject] as const; const branchRemovePositionals = [{ name: "name", required: true }] as const; -const branchOpenOptions = [optPath, optProject] as const; +const branchOpenOptions = [optPath, optProject, optOpenPreference] as const; const branchOpenPositionals = [{ name: "name", required: true }] as const; const branchSpec = defineCommand({ @@ -298,7 +302,27 @@ const handleBranchOpen: CommandHandlerFor = async ({ (host): host is string => typeof host === "string" && host.length > 0 ); - const resolvedHost = applyBranchToHost({ host: devHost, branch, baseHosts }); + const preferenceRaw = args.options.prefer; + const optionPreference = parseOpenHostPreference(preferenceRaw); + if (preferenceRaw !== undefined && !optionPreference) { + throw new CliUsageError("--prefer must be 'auto', 'alias', or 'dev'"); + } + const preferred = resolvePreferredOpenHost({ + devHost, + aliasHost, + configPreference: cfg.open?.prefer, + ...(optionPreference ? { optionPreference } : {}), + }); + if (!preferred.ok) { + throw new CliUsageError( + "OAuth alias host is unavailable. Enable oauth.enabled or use --prefer dev." + ); + } + const resolvedHost = applyBranchToHost({ + host: preferred.host, + branch, + baseHosts, + }); const url = `https://${resolvedHost}`; logger.step({ message: `Opening ${url}` }); @@ -389,8 +413,10 @@ function resolveBranchAliasHost(opts: { readonly devHost: string; readonly cfg: Awaited>; }): string | null { - const tld = resolveProjectOauthTld(opts.cfg.oauth); - return tld ? `${opts.devHost}.${tld}` : null; + return resolveProjectOauthAliasHost({ + devHost: opts.devHost, + oauth: opts.cfg.oauth, + }); } function applyBranchToHost(opts: { diff --git a/src/commands/project.ts b/src/commands/project.ts index 369a0a21..32a3dbef 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -39,6 +39,7 @@ import { optFollow, optJson, optNoFollow, + optOpenPreference, optPath, optPretty, optProfile, @@ -146,6 +147,10 @@ import { resolveUseLoki, } from "../lib/logs.ts"; import { readNodesRegistry } from "../lib/nodes-registry.ts"; +import { + parseOpenHostPreference, + resolvePreferredOpenHost, +} from "../lib/open-host.ts"; import { openUrl } from "../lib/os.ts"; import { defaultProjectSlugFromPath, @@ -156,7 +161,7 @@ import { parseEnvConfigSelection, readProjectConfig, readProjectDevHost, - resolveProjectOauthTld, + resolveProjectOauthAliasHost, resolveWorktreeAutoBranch, sanitizeBranchSlug, sanitizeProjectSlug, @@ -439,7 +444,13 @@ const logsOptions = [ optUntil, ] as const; const logsPositionals = [{ name: "service", required: false }] as const; -const openOptions = [optPath, optProject, optBranch, optJson] as const; +const openOptions = [ + optPath, + optProject, + optBranch, + optOpenPreference, + optJson, +] as const; const openPositionals = [{ name: "target", required: false }] as const; const serviceLifecyclePositionals = [ { name: "services", required: false, multiple: true }, @@ -551,7 +562,8 @@ export const logsCommand = withHandler(logsSpec, handleLogs); const openSpec = defineCommand({ name: "open", - summary: "Open a URL for the project (default: https://.hack)", + summary: + "Open a project URL (OAuth alias by default when enabled; otherwise dev host)", group: "Project", options: openOptions, positionals: openPositionals, @@ -3568,8 +3580,10 @@ function resolveBranchAliasHost(opts: { readonly devHost: string; readonly cfg: Awaited>; }): string | null { - const tld = resolveProjectOauthTld(opts.cfg.oauth); - return tld ? `${opts.devHost}.${tld}` : null; + return resolveProjectOauthAliasHost({ + devHost: opts.devHost, + oauth: opts.cfg.oauth, + }); } async function touchBranchUsageIfNeeded(opts: { @@ -8198,10 +8212,35 @@ async function handleOpen({ ); const targetRaw = (args.positionals.target ?? "").trim(); - const rawHost = resolveRawHost({ targetRaw, devHost }); - const resolvedHost = branch - ? applyBranchToHost({ host: rawHost, branch, baseHosts }) - : rawHost; + const preferenceRaw = args.options.prefer; + const optionPreference = parseOpenHostPreference(preferenceRaw); + if (preferenceRaw !== undefined && !optionPreference) { + throw new CliUsageError("--prefer must be 'auto', 'alias', or 'dev'"); + } + const usesProjectBaseHost = + targetRaw === "" || + targetRaw === "www" || + (targetRaw !== "logs" && + !hasUrlScheme(targetRaw) && + !targetRaw.includes(".")); + const preferred = resolvePreferredOpenHost({ + devHost, + aliasHost, + configPreference: cfg.open?.prefer, + ...(optionPreference ? { optionPreference } : {}), + }); + if (usesProjectBaseHost && !preferred.ok) { + throw new CliUsageError( + "OAuth alias host is unavailable. Enable oauth.enabled or use --prefer dev." + ); + } + const baseHost = + usesProjectBaseHost && preferred.ok ? preferred.host : devHost; + const rawHost = resolveRawHost({ targetRaw, devHost: baseHost }); + const resolvedHost = + branch && usesProjectBaseHost + ? applyBranchToHost({ host: rawHost, branch, baseHosts }) + : rawHost; const url = resolveOpenUrl({ targetRaw, resolvedHost }); if (json) { diff --git a/src/lib/open-host.ts b/src/lib/open-host.ts new file mode 100644 index 00000000..cfa20dac --- /dev/null +++ b/src/lib/open-host.ts @@ -0,0 +1,49 @@ +export type OpenHostPreference = "auto" | "alias" | "dev"; + +export type PreferredOpenHostResult = + | { + readonly ok: true; + readonly host: string; + readonly preference: OpenHostPreference; + } + | { + readonly ok: false; + readonly preference: "alias"; + readonly reason: "alias-unavailable"; + }; + +export function parseOpenHostPreference( + value: unknown +): OpenHostPreference | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "auto" || normalized === "alias" || normalized === "dev") { + return normalized; + } + return null; +} + +/** + * Selects the browser-facing project host without changing the underlying + * primary routing identity. Auto prefers the OAuth alias when one exists. + */ +export function resolvePreferredOpenHost(opts: { + readonly devHost: string; + readonly aliasHost: string | null; + readonly configPreference?: OpenHostPreference; + readonly optionPreference?: OpenHostPreference; +}): PreferredOpenHostResult { + const preference = opts.optionPreference ?? opts.configPreference ?? "auto"; + if (preference === "dev") { + return { ok: true, host: opts.devHost, preference }; + } + if (opts.aliasHost) { + return { ok: true, host: opts.aliasHost, preference }; + } + if (preference === "alias") { + return { ok: false, preference, reason: "alias-unavailable" }; + } + return { ok: true, host: opts.devHost, preference }; +} diff --git a/src/lib/project.ts b/src/lib/project.ts index fd8351d7..3d1158ea 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -1,6 +1,7 @@ import { basename, dirname, resolve } from "node:path"; import { DEFAULT_OAUTH_ALIAS_TLD, + DEFAULT_PROJECT_TLD, HACK_PROJECT_DIR_LEGACY, HACK_PROJECT_DIR_PRIMARY, PROJECT_COMPOSE_FILENAME, @@ -11,6 +12,10 @@ import { import { parseDotEnv } from "./env.ts"; import { pathExists, readTextFile } from "./fs.ts"; import { getRecord, getString, isRecord } from "./guards.ts"; +import { + type OpenHostPreference, + parseOpenHostPreference, +} from "./open-host.ts"; import { findUpFile } from "./path.ts"; export type ProjectDirName = @@ -177,6 +182,7 @@ export interface ProjectConfig { }; readonly logs?: ProjectLogsConfig; readonly oauth?: ProjectOauthConfig; + readonly open?: ProjectOpenConfig; readonly internal?: ProjectInternalConfig; readonly worktree?: ProjectWorktreeConfig; readonly sessions?: ProjectSessionsConfig; @@ -186,6 +192,11 @@ export interface ProjectConfig { readonly parseError?: string; } +export interface ProjectOpenConfig { + /** Browser host preference for `hack open` and `hack branch open`. */ + readonly prefer?: OpenHostPreference; +} + export type ProjectOwnershipMode = "local" | "shared"; export type ProjectOwnershipOwnerType = "user" | "team" | "organization"; export type ProjectOwnershipManager = "local" | "broker"; @@ -351,6 +362,22 @@ export function resolveProjectOauthTld( return tld && tld.length > 0 ? tld : DEFAULT_OAUTH_ALIAS_TLD; } +/** + * Returns the OAuth alias only when Hack's generated Caddy routes can serve it. + * Custom development hosts outside the managed `.hack` namespace retain their + * primary host unless the project declares and routes an alias itself. + */ +export function resolveProjectOauthAliasHost(opts: { + readonly devHost: string; + readonly oauth: ProjectOauthConfig | undefined; +}): string | null { + const tld = resolveProjectOauthTld(opts.oauth); + if (!(tld && opts.devHost.endsWith(`.${DEFAULT_PROJECT_TLD}`))) { + return null; + } + return `${opts.devHost}.${tld}`; +} + export async function readProjectConfig( ctx: ProjectContext ): Promise { @@ -428,6 +455,7 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { (envConfig ? getString(envConfig, "default_overlay") : undefined); const logs = parseLogsConfig(getRecord(value, "logs")); const oauth = parseOauthConfig(getRecord(value, "oauth")); + const parsedOpen = parseOpenConfig(getRecord(value, "open")); const internal = parseInternalConfig(getRecord(value, "internal")); const worktree = parseWorktreeConfig(getRecord(value, "worktree")); const sessions = parseSessionsConfig(getRecord(value, "sessions")); @@ -458,6 +486,7 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { ] : []), ...(parsedOwnership.parseError ? [parsedOwnership.parseError] : []), + ...(parsedOpen.parseError ? [parsedOpen.parseError] : []), ]; return { @@ -469,6 +498,7 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { : {}), ...(logs ? { logs } : {}), ...(oauth ? { oauth } : {}), + ...(parsedOpen.config ? { open: parsedOpen.config } : {}), ...(internal ? { internal } : {}), ...(worktree ? { worktree } : {}), ...(sessions ? { sessions } : {}), @@ -479,6 +509,22 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { }; } +function parseOpenConfig(value: Record | undefined): { + readonly config?: ProjectOpenConfig; + readonly parseError?: string; +} { + if (!value || value.prefer === undefined) { + return {}; + } + const prefer = parseOpenHostPreference(value.prefer); + if (!prefer) { + return { + parseError: "Project open.prefer must be 'auto', 'alias', or 'dev'.", + }; + } + return { config: { prefer } }; +} + export function defaultProjectOwnership(): ProjectOwnershipConfig { return { mode: "local", diff --git a/src/templates.ts b/src/templates.ts index affec8f9..a40a8d25 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -359,6 +359,9 @@ export function renderProjectConfigJson(opts: { enabled: oauthEnabled, ...(oauthTld ? { tld: oauthTld } : {}), }, + open: { + prefer: "auto", + }, }; return `${JSON.stringify(config, null, 2)}\n`; @@ -415,6 +418,13 @@ export function renderProjectConfigSchemaJson(): string { tld: { type: "string" }, }, }, + open: { + type: "object", + additionalProperties: true, + properties: { + prefer: { type: "string", enum: ["auto", "alias", "dev"] }, + }, + }, worktree: { type: "object", additionalProperties: true, diff --git a/tests/e2e/fixture.ts b/tests/e2e/fixture.ts index 2731cb35..eeceb935 100644 --- a/tests/e2e/fixture.ts +++ b/tests/e2e/fixture.ts @@ -61,6 +61,7 @@ export async function createMonorepoFixture(opts: { readonly withHackConfig: boolean; readonly name?: string; readonly lifecycle?: LifecycleFixtureOptions; + readonly oauthEnabled?: boolean; }): Promise { const name = opts.name ?? randomProjectName(); const devHost = `${name}.hack`; @@ -88,6 +89,7 @@ export async function createMonorepoFixture(opts: { name, devHost, lifecycle: opts.lifecycle, + oauthEnabled: opts.oauthEnabled, }); } @@ -294,6 +296,7 @@ async function writeHackConfig(opts: { readonly name: string; readonly devHost: string; readonly lifecycle?: LifecycleFixtureOptions; + readonly oauthEnabled?: boolean; }): Promise { await mkdir(opts.hackDir, { recursive: true }); @@ -312,9 +315,12 @@ async function writeHackConfig(opts: { tls: opts.lifecycle?.disableInternal !== true, }, oauth: { - enabled: false, + enabled: opts.oauthEnabled === true, tld: "gy", }, + open: { + prefer: "auto", + }, ...(lifecycle ? { lifecycle } : {}), }; await Bun.write( diff --git a/tests/e2e/scenarios/worktree-branch-default.ts b/tests/e2e/scenarios/worktree-branch-default.ts index 00813168..9a187b66 100644 --- a/tests/e2e/scenarios/worktree-branch-default.ts +++ b/tests/e2e/scenarios/worktree-branch-default.ts @@ -26,6 +26,7 @@ export const worktreeBranchDefaultScenario: Scenario = { const fixture = await createMonorepoFixture({ parentDir: ctx.tempRoot, withHackConfig: true, + oauthEnabled: true, }); const worktreePath = await addLinkedWorktree({ fixture, branch: BRANCH }); @@ -41,8 +42,8 @@ export const worktreeBranchDefaultScenario: Scenario = { const primaryUrl = extractJsonObject({ text: primaryOpen.stdout })?.url ?? ""; expect({ - that: primaryUrl === `https://${fixture.devHost}`, - message: `primary open --json should resolve https://${fixture.devHost}, got "${primaryUrl}"`, + that: primaryUrl === `https://${fixture.devHost}.gy`, + message: `primary open --json should prefer https://${fixture.devHost}.gy, got "${primaryUrl}"`, result: primaryOpen, }); expect({ @@ -52,6 +53,23 @@ export const worktreeBranchDefaultScenario: Scenario = { result: primaryOpen, }); + const serviceOpen = await ctx.cli({ + args: ["open", "api", "--json"], + cwd: fixture.root, + }); + expectExit({ + result: serviceOpen, + codes: [0], + message: "hack open service shorthand should succeed", + }); + const serviceUrl = + extractJsonObject({ text: serviceOpen.stdout })?.url ?? ""; + expect({ + that: serviceUrl === `https://api.${fixture.devHost}.gy`, + message: `service shorthand should prefer the OAuth alias, got "${serviceUrl}"`, + result: serviceOpen, + }); + const worktreeOpen = await ctx.cli({ args: ["open", "--json"], cwd: worktreePath, @@ -64,8 +82,8 @@ export const worktreeBranchDefaultScenario: Scenario = { const worktreeUrl = extractJsonObject({ text: worktreeOpen.stdout })?.url ?? ""; expect({ - that: worktreeUrl.includes(BRANCH), - message: `worktree open --json URL should carry the branch slug "${BRANCH}", got "${worktreeUrl}"`, + that: worktreeUrl === `https://${BRANCH}.${fixture.devHost}.gy`, + message: `worktree open --json should prefer the branch alias URL, got "${worktreeUrl}"`, result: worktreeOpen, }); expect({ @@ -81,6 +99,196 @@ export const worktreeBranchDefaultScenario: Scenario = { result: worktreeOpen, }); + const devOverride = await ctx.cli({ + args: ["open", "--json", "--prefer", "dev"], + cwd: fixture.root, + }); + expectExit({ + result: devOverride, + codes: [0], + message: "hack open --prefer dev should succeed", + }); + const devOverrideUrl = + extractJsonObject({ text: devOverride.stdout })?.url ?? ""; + expect({ + that: devOverrideUrl === `https://${fixture.devHost}`, + message: `--prefer dev should select the primary dev host, got "${devOverrideUrl}"`, + result: devOverride, + }); + + const setProjectPreference = await ctx.cli({ + args: ["config", "set", "open.prefer", "dev"], + cwd: fixture.root, + }); + expectExit({ + result: setProjectPreference, + codes: [0], + message: "hack config set open.prefer dev should succeed", + }); + const configuredOpen = await ctx.cli({ + args: ["open", "--json"], + cwd: fixture.root, + }); + expectExit({ + result: configuredOpen, + codes: [0], + message: "hack open should honor open.prefer from project config", + }); + const configuredUrl = + extractJsonObject({ text: configuredOpen.stdout })?.url ?? + ""; + expect({ + that: configuredUrl === `https://${fixture.devHost}`, + message: `open.prefer=dev should select the primary host, got "${configuredUrl}"`, + result: configuredOpen, + }); + + const aliasOverride = await ctx.cli({ + args: ["open", "--json", "--prefer", "alias"], + cwd: fixture.root, + }); + expectExit({ + result: aliasOverride, + codes: [0], + message: "CLI alias preference should override project config", + }); + const aliasOverrideUrl = + extractJsonObject({ text: aliasOverride.stdout })?.url ?? ""; + expect({ + that: aliasOverrideUrl === `https://${fixture.devHost}.gy`, + message: `--prefer alias should override open.prefer=dev, got "${aliasOverrideUrl}"`, + result: aliasOverride, + }); + + const disableOauth = await ctx.cli({ + args: ["config", "set", "oauth.enabled", "false"], + cwd: fixture.root, + }); + expectExit({ + result: disableOauth, + codes: [0], + message: "hack config set oauth.enabled false should succeed", + }); + const unavailableAlias = await ctx.cli({ + args: ["open", "--json", "--prefer", "alias"], + cwd: fixture.root, + }); + expectExit({ + result: unavailableAlias, + codes: [1], + message: "explicit alias preference should fail without OAuth aliasing", + }); + expect({ + that: + unavailableAlias.combined.includes("OAuth alias host is unavailable") && + unavailableAlias.combined.includes("--prefer dev"), + message: "unavailable alias failure should include recovery guidance", + result: unavailableAlias, + }); + + const invalidPreference = await ctx.cli({ + args: ["open", "--json", "--prefer", "primary"], + cwd: fixture.root, + }); + expectExit({ + result: invalidPreference, + codes: [1], + message: "invalid browser host preference should fail", + }); + expect({ + that: invalidPreference.combined.includes( + "--prefer must be 'auto', 'alias', or 'dev'" + ), + message: "invalid preference should list supported values", + result: invalidPreference, + }); + + const explicitUrl = "https://example.com/callback?source=hack"; + const explicit = await ctx.cli({ + args: ["open", explicitUrl, "--json", "--prefer", "dev"], + cwd: fixture.root, + }); + expectExit({ + result: explicit, + codes: [0], + message: "hack open should preserve an explicit URL", + }); + const preservedUrl = + extractJsonObject({ text: explicit.stdout })?.url ?? ""; + expect({ + that: preservedUrl === explicitUrl, + message: `explicit URL should remain unchanged, got "${preservedUrl}"`, + result: explicit, + }); + + const explicitHost = await ctx.cli({ + args: ["open", fixture.devHost, "--json"], + cwd: worktreePath, + }); + expectExit({ + result: explicitHost, + codes: [0], + message: "hack open should preserve an explicit fully qualified host", + }); + const preservedHostUrl = + extractJsonObject({ text: explicitHost.stdout })?.url ?? ""; + expect({ + that: preservedHostUrl === `https://${fixture.devHost}`, + message: `explicit fully qualified host should remain unchanged, got "${preservedHostUrl}"`, + result: explicitHost, + }); + + for (const [key, value] of [ + ["oauth.enabled", "true"], + ["open.prefer", "auto"], + ["dev_host", "demo.test"], + ] as const) { + const configured = await ctx.cli({ + args: ["config", "set", key, value], + cwd: fixture.root, + }); + expectExit({ + result: configured, + codes: [0], + message: `hack config set ${key} should succeed`, + }); + } + const customHostOpen = await ctx.cli({ + args: ["open", "--json"], + cwd: fixture.root, + }); + expectExit({ + result: customHostOpen, + codes: [0], + message: "automatic preference should support a custom dev host", + }); + const customHostUrl = + extractJsonObject({ text: customHostOpen.stdout })?.url ?? + ""; + expect({ + that: customHostUrl === "https://demo.test", + message: `auto should retain a custom host without a generated alias route, got "${customHostUrl}"`, + result: customHostOpen, + }); + const customAliasOpen = await ctx.cli({ + args: ["open", "--json", "--prefer", "alias"], + cwd: fixture.root, + }); + expectExit({ + result: customAliasOpen, + codes: [1], + message: + "explicit alias should fail when Hack did not generate its route", + }); + expect({ + that: customAliasOpen.combined.includes( + "OAuth alias host is unavailable" + ), + message: + "custom host alias failure should explain that it is unavailable", + result: customAliasOpen, + }); + const optOut = await ctx.cli({ args: ["open", "--json", "--branch", "main-instance"], cwd: worktreePath, diff --git a/tests/open-host.test.ts b/tests/open-host.test.ts new file mode 100644 index 00000000..6c219e95 --- /dev/null +++ b/tests/open-host.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { + parseOpenHostPreference, + resolvePreferredOpenHost, +} from "../src/lib/open-host.ts"; + +describe("open host preference", () => { + test("parses supported values case-insensitively", () => { + expect(parseOpenHostPreference(" AUTO ")).toBe("auto"); + expect(parseOpenHostPreference("Alias")).toBe("alias"); + expect(parseOpenHostPreference("dev")).toBe("dev"); + expect(parseOpenHostPreference("primary")).toBeNull(); + expect(parseOpenHostPreference(true)).toBeNull(); + }); + + test("auto prefers an available OAuth alias", () => { + expect( + resolvePreferredOpenHost({ + devHost: "demo.hack", + aliasHost: "demo.hack.gy", + }) + ).toEqual({ + ok: true, + host: "demo.hack.gy", + preference: "auto", + }); + }); + + test("auto falls back to the development host without OAuth", () => { + expect( + resolvePreferredOpenHost({ + devHost: "demo.hack", + aliasHost: null, + }) + ).toEqual({ ok: true, host: "demo.hack", preference: "auto" }); + }); + + test("CLI preference overrides project config", () => { + expect( + resolvePreferredOpenHost({ + devHost: "demo.hack", + aliasHost: "demo.hack.gy", + configPreference: "alias", + optionPreference: "dev", + }) + ).toEqual({ ok: true, host: "demo.hack", preference: "dev" }); + }); + + test("explicit alias preference fails when OAuth is unavailable", () => { + expect( + resolvePreferredOpenHost({ + devHost: "demo.hack", + aliasHost: null, + configPreference: "alias", + }) + ).toEqual({ + ok: false, + preference: "alias", + reason: "alias-unavailable", + }); + }); +}); diff --git a/tests/project-config.test.ts b/tests/project-config.test.ts index 7992bece..52985e66 100644 --- a/tests/project-config.test.ts +++ b/tests/project-config.test.ts @@ -9,6 +9,7 @@ import { } from "../src/constants.ts"; import { readProjectConfig, + resolveProjectOauthAliasHost, resolveProjectOauthTld, } from "../src/lib/project.ts"; @@ -77,6 +78,7 @@ test("readProjectConfig parses json config fields", async () => { clear_on_down: true, }, oauth: { enabled: true, tld: "gy" }, + open: { prefer: "alias" }, internal: { dns: true, tls: true, @@ -104,6 +106,7 @@ test("readProjectConfig parses json config fields", async () => { expect(cfg.logs?.clearOnDown).toBe(true); expect(cfg.oauth?.enabled).toBe(true); expect(cfg.oauth?.tld).toBe("gy"); + expect(cfg.open?.prefer).toBe("alias"); expect(cfg.internal?.dns).toBe(true); expect(cfg.internal?.tls).toBe(true); expect(cfg.internal?.extraHosts).toEqual({ @@ -118,6 +121,23 @@ test("readProjectConfig parses json config fields", async () => { }); }); +test("readProjectConfig rejects an invalid open preference", async () => { + const ctx = await createProjectDir(); + await writeFile( + ctx.configFile, + JSON.stringify({ + open: { prefer: "primary" }, + }) + ); + + const cfg = await readProjectConfig(ctx); + + expect(cfg.open).toBeUndefined(); + expect(cfg.parseError).toBe( + "Project open.prefer must be 'auto', 'alias', or 'dev'." + ); +}); + test("readProjectConfig captures parse errors", async () => { const ctx = await createProjectDir(); await writeFile(ctx.configFile, "{ invalid json"); @@ -225,6 +245,27 @@ test("resolveProjectOauthTld falls back to default when enabled", () => { expect(resolveProjectOauthTld({ enabled: false })).toBeNull(); }); +test("resolveProjectOauthAliasHost only returns aliases Hack routes", () => { + expect( + resolveProjectOauthAliasHost({ + devHost: "demo.hack", + oauth: { enabled: true, tld: "gy" }, + }) + ).toBe("demo.hack.gy"); + expect( + resolveProjectOauthAliasHost({ + devHost: "demo.test", + oauth: { enabled: true, tld: "gy" }, + }) + ).toBeNull(); + expect( + resolveProjectOauthAliasHost({ + devHost: "demo.hack", + oauth: { enabled: false, tld: "gy" }, + }) + ).toBeNull(); +}); + test("readProjectConfig maps startup shorthand into lifecycle", async () => { const ctx = await createProjectDir(); await writeFile( diff --git a/tests/templates.test.ts b/tests/templates.test.ts index e16625a2..d8334e83 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -1,7 +1,41 @@ import { expect, test } from "bun:test"; -import { renderProjectEnvConfigYaml } from "../src/templates.ts"; +import { + renderProjectConfigJson, + renderProjectConfigSchemaJson, + renderProjectEnvConfigYaml, +} from "../src/templates.ts"; test("renderProjectEnvConfigYaml emits an object-valued global scope", () => { expect(renderProjectEnvConfigYaml()).toContain(" global: {}"); }); + +test("renderProjectConfigJson makes automatic browser host preference explicit", () => { + const config = JSON.parse( + renderProjectConfigJson({ + name: "demo", + devHost: "demo.hack", + oauth: { enabled: true, tld: "gy" }, + }) + ) as { readonly open?: { readonly prefer?: string } }; + + expect(config.open?.prefer).toBe("auto"); +}); + +test("project config schema exposes browser host preference values", () => { + const schema = JSON.parse(renderProjectConfigSchemaJson()) as { + readonly properties?: { + readonly open?: { + readonly properties?: { + readonly prefer?: { readonly enum?: readonly string[] }; + }; + }; + }; + }; + + expect(schema.properties?.open?.properties?.prefer?.enum).toEqual([ + "auto", + "alias", + "dev", + ]); +});