diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1fed851b..b55b0b4a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -7,6 +7,7 @@ on: - "plan/**" - "feature/**" - "graphify/**" + - "core-cli/**" pull_request: schedule: # Related moving latest images refresh weekly; re-run the full compatibility @@ -41,6 +42,10 @@ jobs: - name: CLI contract run: tests/cli-contract.sh + - name: Execution surface contract + run: bash tests/execution-contract.sh + + - name: Environment contract run: tests/env-contract.sh diff --git a/docs/plans/lds-core-cli-hardening-plan.md b/docs/plans/lds-core-cli-hardening-plan.md deleted file mode 100644 index f4369ce4..00000000 --- a/docs/plans/lds-core-cli-hardening-plan.md +++ /dev/null @@ -1,496 +0,0 @@ -# LocalDevStack — Core & CLI Execution Surface Hardening Plan - -## Status - -Planning branch: `graphify/integration-hardening` - -Repository: `infocyph/LocalDevStack` - -Status: **active plan — current-state review complete; implementation not started** - -This file is the single active LocalDevStack development plan after completion of the -Docker ecosystem/integration program. The previous `docs/plans/docker-ecosystem/*` -plans are complete and intentionally removed. - -## Objective - -Make `lds core`, `lds cli`, `lds stack exec`, and `lds tools` feel like one coherent -execution system rather than four independently evolved Docker-shell paths. - -The final design should preserve the convenience of the existing commands while -eliminating: - -- duplicated container-running checks; -- duplicated Bash/sh fallback logic; -- forced TTY allocation for non-interactive commands; -- command flattening through `$*`; -- raw container-name assumptions; -- implicit uppercasing of container targets; -- domain/container/service ambiguity; -- behavior drift between `core`, `cli`, `stack exec`, and `tools exec`. - -The host CLI remains the control plane. Docker remains an implementation detail. - ---- - -# 1. Current behavior - -## 1.1 `lds cli` - -Current contract: - -```text -lds cli -lds cli -``` - -Today it: - -1. requires an exact Docker container identifier/name; -2. checks that the container exists and is running; -3. always invokes `docker exec -it`; -4. joins command arguments with `$*`; -5. runs explicit commands through a login Bash shell when Bash exists; -6. falls back to `sh` otherwise. - -This works interactively, but the command path is not argv-safe and is awkward for -piped/non-TTY automation. - -## 1.2 `lds core` - -Current contract: - -```text -lds core -lds core -lds core -``` - -Today it: - -- discovers domains through `domain-which`; -- prompts interactively when no target is supplied; -- resolves a domain to application/container/docroot; -- forces Node applications to `/app`; -- otherwise uses the resolved document root with `/app`/root fallback; -- treats a non-domain target as a container name; -- uppercases raw container targets before `docker exec`; -- opens a shell only; it does not have a proper argv-preserving command mode. - -## 1.3 Overlapping execution surfaces - -The repository also has: - -```text -lds stack exec [cmd...] -lds exec [cmd...] -lds tools sh -lds tools exec "" -``` - -Each path currently owns part of the same problem: - -- resolve a target; -- require a running container; -- determine interactive vs non-interactive execution; -- choose Bash or sh; -- optionally select a working directory; -- preserve command arguments; -- choose Docker/Compose execution semantics. - -That shared substrate should exist once. - ---- - -# 2. Design decisions - -## 2.1 Command roles - -### `lds cli` — generic container/service execution - -`cli` is the low-level execution surface. - -Target contract: - -```text -lds cli -lds cli -- [args...] -lds cli [args...] -``` - -A target may be: - -1. a service in the current Compose project; -2. an exact running container name/ID. - -Resolution must be deterministic and scoped to the current LocalDevStack project before -falling back to an explicit Docker container. - -No implicit case conversion. - -When no command is supplied, open an interactive shell. - -When a command is supplied, preserve argv exactly. Do not flatten the command through -`$*`. - -### `lds core` — application/domain-aware execution - -`core` remains the ergonomic application entry point. - -Target contract: - -```text -lds core -lds core -lds core -- [args...] -lds core -lds core -- [args...] -``` - -Rules: - -- no target: discover domains and prompt when interactive; -- one discovered domain: select it automatically; -- non-TTY with multiple domains: print the stable domain list and fail with actionable usage; -- domain target: resolve application/container/docroot through Tools; -- Node target: working directory `/app`; -- other domain target: resolved docroot, then `/app`, then `/` fallback; -- service/container target: use the same resolver as `lds cli`; -- explicit command: execute in the resolved application working directory; -- no command: open the application shell there. - -`core` must delegate execution rather than owning Docker shell mechanics itself. - -## 2.2 Preserve `stack exec` - -`lds stack exec` remains the clearly Compose-service-oriented form. - -It should use the same internal argv/TTY/shell helpers as `cli`, while keeping its -service-only contract. - -## 2.3 Preserve `tools` - -`lds tools` remains server-tools-specific: - -```text -lds tools sh -lds tools exec ... -lds tools file ... -``` - -It should reuse the common container execution layer where applicable instead of carrying -another independent shell implementation. - -## 2.4 TTY rules - -TTY behavior must be based on the actual operation: - -- interactive shell: `-it`; -- interactive explicit command with terminal stdin/stdout: `-it` where appropriate; -- piped/non-interactive command: `-i`, never force `-t`; -- command that needs neither stdin nor TTY: no unnecessary TTY requirement. - -Windows/Git Bash, WSL, Linux and Docker Desktop behavior must remain covered. - -## 2.5 Command safety - -Explicit command execution must be argv-preserving. - -Avoid: - -```bash -local cmd="$*" -sh -lc "$cmd" -``` - -for ordinary command forwarding. - -If a shell-expression mode is retained for compatibility, it must be explicit and -separate from normal argv execution. - ---- - -# 3. Internal execution architecture - -Introduce one private execution substrate in the host CLI layer. - -Provisional responsibilities: - -```text -_container_resolve_target -_container_require_running -_container_exec_flags -_container_exec_argv -_container_open_shell -_container_exec_in_dir -_core_domain_list -_core_domain_resolve -``` - -Exact names may change during implementation; responsibilities should not. - -## 3.1 Target resolver - -The common resolver should return canonical information rather than only a string. - -Conceptually: - -```text -requested target - ↓ -current Compose service? - ↓ no -exact Docker container? - ↓ -canonical container id/name -``` - -Requirements: - -- prefer current-project Compose service resolution; -- do not accidentally select a similarly named container from another project; -- exact explicit container names/IDs remain supported; -- return useful errors for stopped/missing/ambiguous targets. - -## 3.2 Shell resolver - -Interactive shell behavior: - -1. Bash when available; -2. sh otherwise. - -The helper should support an optional working directory without embedding untrusted -paths into a command string. - -## 3.3 Domain resolver - -`core` should own domain semantics, not Docker mechanics. - -The resolver should return: - -```text -domain -application type -container target -working directory -``` - -The current repeated `domain-which` calls should be wrapped behind one LocalDevStack -helper so failure handling and diagnostics are centralized. - -Do not move domain ownership out of Tools; LocalDevStack only consumes the control-plane -result. - ---- - -# 4. Current findings to address - -| Area | Current issue | Target | -| --- | --- | --- | -| `cli` target | exact raw container only | Compose service + exact container | -| `cli` command | `$*` flattened into shell string | preserve argv | -| `cli` TTY | always `-it` | adaptive TTY | -| `core` raw target | uppercases container name | canonical resolver | -| `core` command mode | shell only | shell or command | -| `core` domain checks | repeated Tools/container checks | shared domain resolver | -| `stack exec` | separate execution mechanics | shared substrate | -| `tools exec` | separate shell-string mechanics | shared substrate | -| shell fallback | repeated Bash/sh snippets | one helper | -| errors | command-specific wording/behavior | consistent target diagnostics | -| tests | surfaces tested indirectly | explicit execution matrix | -| docs/help | terse semantics | clearly distinguish cli/core/stack exec/tools | - ---- - -# 5. Compatibility policy - -Preserve these existing forms: - -```text -lds cli -lds cli -lds core -lds core -lds core -lds stack exec [command...] -lds exec [command...] -lds tools sh -lds tools exec ... -``` - -Behavior may become stricter only where the current behavior is unsafe or ambiguous. - -Specifically: - -- stop uppercasing arbitrary `core` targets; -- stop forcing TTYs for non-interactive commands; -- stop flattening normal command argv; -- reject ambiguous targets rather than guessing. - -No dependency on project programming language should be introduced. - ---- - -# 6. Implementation batches - -## Batch 0 — baseline and command contract - -Status: **complete for planning** - -- [x] inventory `core`, `cli`, `stack exec`, `tools`, and top-level dispatch; -- [x] identify duplicated execution responsibilities; -- [x] identify argv/TTY/container-resolution gaps; -- [x] define command roles and compatibility policy; -- [ ] add explicit baseline tests for current supported invocation forms before refactor. - -Exit criterion: current supported behavior is captured by tests. - -## Batch 1 — shared container execution substrate - -- [ ] implement current-project service/container resolution; -- [ ] implement running-container validation; -- [ ] implement adaptive `docker exec` flags; -- [ ] implement argv-preserving execution; -- [ ] implement Bash/sh interactive shell selection; -- [ ] implement optional working-directory execution; -- [ ] add unit/contract fixtures for resolver and TTY behavior. - -Exit criterion: no user-facing command needs its own Docker shell mechanics. - -## Batch 2 — harden `lds cli` - -- [ ] move `cmd_cli` onto the shared execution substrate; -- [ ] support current-project Compose service names; -- [ ] preserve explicit container names/IDs; -- [ ] remove forced uppercase/case assumptions; -- [ ] preserve argv exactly for explicit commands; -- [ ] support piped/non-TTY commands without `-t`; -- [ ] keep no-command interactive shell behavior; -- [ ] add missing/stopped/ambiguous target tests. - -Exit criterion: `cli` is the reliable low-level execution command. - -## Batch 3 — harden `lds core` - -- [ ] centralize domain discovery; -- [ ] centralize domain -> app/container/docroot resolution; -- [ ] preserve stable interactive domain picker; -- [ ] preserve useful non-TTY domain listing; -- [ ] delegate service/container fallback to the common resolver; -- [ ] remove raw target uppercasing; -- [ ] add explicit command execution after domain/container target; -- [ ] preserve Node `/app` behavior; -- [ ] preserve document-root fallback behavior; -- [ ] test PHP, Node, service, container and non-TTY flows. - -Exit criterion: `core` is application-aware but contains no duplicate Docker execution logic. - -## Batch 4 — unify adjacent execution surfaces - -- [ ] move `cmd_exec` / `stack exec` onto shared helpers without changing its service-only UX; -- [ ] move `tools sh` onto shared shell helper; -- [ ] move `tools exec` away from unsafe command flattening where compatibility allows; -- [ ] review `cmd_ui`/other direct `docker exec` users for helper reuse where relevant; -- [ ] keep specialized commands specialized; do not over-abstract unrelated Docker operations. - -Exit criterion: interactive/command execution semantics are consistent across LDS. - -## Batch 5 — CLI routing and UX cleanup - -- [ ] review top-level dynamic `cmd_$cmd` routing for discoverability and collision safety; -- [ ] ensure canonical grouped commands and shortcuts remain intentional; -- [ ] standardize usage/error text; -- [ ] standardize exit codes for missing target, unknown target, stopped target and no-TTY prompt; -- [ ] make `--` command separation work consistently; -- [ ] ensure help output clearly explains `core` vs `cli` vs `stack exec` vs `tools`. - -Exit criterion: users can predict which execution command to use without knowing LDS internals. - -## Batch 6 — cross-platform and automation hardening - -- [ ] Linux interactive shell coverage; -- [ ] Linux piped stdin/non-TTY coverage; -- [ ] Windows/Git Bash TTY compatibility; -- [ ] WSL behavior review; -- [ ] Docker Desktop behavior review; -- [ ] paths containing spaces; -- [ ] commands containing spaces/quotes/shell metacharacters as argv; -- [ ] SIGINT/exit-code propagation; -- [ ] no accidental host shell interpolation. - -Exit criterion: execution behavior is stable across supported host environments. - -## Batch 7 — documentation and release hardening - -- [ ] update `docs/reference/cli.rst`; -- [ ] update embedded `lds help` / `help --markdown`; -- [ ] update README examples only where useful; -- [ ] update docs contracts; -- [ ] run ShellCheck/static/contracts; -- [ ] run Compose/network/permission contracts; -- [ ] run Windows bridge contract; -- [ ] final full CLI surface review; -- [ ] remove this plan when every item is complete. - -Exit criterion: all checks green and no completed planning artifact remains. - ---- - -# 7. Required regression matrix - -At minimum cover: - -| Command | Interactive | Non-interactive | Domain | Service | Container | -| --- | --- | --- | --- | --- | --- | -| `lds cli` | yes | yes | n/a | yes | yes | -| `lds core` | yes | yes | yes | yes | yes | -| `lds stack exec` | yes | yes | n/a | yes | n/a | -| `lds tools` | yes | yes | n/a | server-tools | server-tools | - -Also verify: - -- stopped target; -- missing target; -- ambiguous service/container naming; -- one-domain auto-selection; -- multi-domain no-TTY output; -- PHP domain working directory; -- Node domain `/app`; -- Bash unavailable -> sh; -- stdin pipe reaches child command; -- child exit status reaches caller; -- `Ctrl-C` reaches interactive child; -- command arguments are not reinterpreted by the host shell. - ---- - -# 8. Non-goals - -Do not use this work to: - -- redesign Compose service definitions; -- move domain ownership out of docker-tools; -- introduce a new programming-language/runtime dependency; -- replace Docker Compose; -- merge unrelated service lifecycle commands; -- remove existing command aliases merely for aesthetic cleanup; -- turn every direct Docker call into a generic abstraction. - -The goal is a coherent execution surface, not abstraction for its own sake. - ---- - -# 9. Completion definition - -This plan is complete when: - -1. `lds cli` is the dependable low-level service/container execution surface; -2. `lds core` is the dependable application/domain execution surface; -3. both support correct interactive and non-interactive behavior; -4. argv, stdin, TTY, exit codes and working directories are preserved correctly; -5. `stack exec` and `tools` reuse the same execution substrate where appropriate; -6. current-project scoping prevents accidental cross-project container selection; -7. help/docs make the command boundaries obvious; -8. Linux/Windows/WSL contracts are green; -9. no completed predecessor plan files remain; -10. this plan itself is removed after implementation is fully released. diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 3ddbf066..410eca2b 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -155,23 +155,57 @@ Shortcuts:: lds notify ... lds ui -Tools Control Plane -------------------- +Tools and Execution Surfaces +---------------------------- -:: +LocalDevStack exposes four related execution surfaces with distinct roles. - lds tools sh - lds tools exec "" - lds tools file +``lds cli`` is the generic low-level service/container executor:: + + lds cli + lds cli -- [args...] + lds cli [args...] + +Current-project Compose services are resolved first. Exact running Docker container +names/IDs remain supported as a fallback. Explicit commands preserve argv literally; +they are not flattened through a host/container shell. With no command, ``cli`` opens +an interactive Bash shell when available, otherwise ``sh``. + +``lds core`` adds domain/application awareness:: + + lds core + lds core + lds core -- [args...] + lds core + lds core -- [args...] -Open a generic container shell or run a command:: +A domain is resolved through Tools ``domain-which``. Node applications use ``/app``; +other applications use the resolved document root when it exists, then ``/app`` and +``/`` as fallbacks. With no target, one discovered domain is selected automatically; +multiple domains require an interactive picker. A non-TTY invocation with multiple +domains prints the stable domain list and exits with actionable usage. - lds cli - lds cli +``lds stack exec`` / ``lds exec`` stay service-only:: + + lds stack exec [--] [command...] + lds exec [--] [command...] + +They use the same argv/TTY/shell substrate as ``cli`` but intentionally do not fall +back to arbitrary external containers. + +``lds tools`` is server-tools-specific:: + + lds tools sh + lds tools exec [args...] + lds tools shell-exec '' + lds tools file -Resolve a domain/container to its application shell:: +``tools exec`` preserves argv. Use ``tools shell-exec`` only when shell syntax +(pipelines, redirections, compound expressions) is intentionally required. - lds core [domain|container] +TTY allocation follows the operation: interactive shells/programs require a real TTY; +piped commands keep stdin with ``-i`` but never force ``-t``; non-interactive commands +without terminal stdin do not allocate unnecessary TTY flags. Secrets ------- diff --git a/lds b/lds index 9b7ef6bb..9ad68118 100755 --- a/lds +++ b/lds @@ -472,6 +472,12 @@ source "$DIR/lib/profiles.sh" # shellcheck source=lib/certificates.sh source "$DIR/lib/certificates.sh" +############################################################################### +# SHARED CONTAINER / DOMAIN EXECUTION +############################################################################### +# shellcheck source=lib/execution.sh +source "$DIR/lib/execution.sh" + ############################################################################### # SERVICE / STACK OPERATIONS ############################################################################### @@ -766,10 +772,11 @@ cmd_help() { ## Tools / shells / secrets - `lds tools sh` -- `lds tools exec ""` +- `lds tools exec [args...]` +- `lds tools shell-exec ''` - `lds tools file ` -- `lds cli [cmd...]` -- `lds core [domain|container]` +- `lds cli [--] [command...]` +- `lds core [domain|service|container] [--] [command...]` - `lds secrets ` ## AI consumer @@ -818,11 +825,11 @@ ${CYAN}Stack:${NC} up|start|down|stop|restart|reboot|status|ps|logs|exec|events clean --yes [--volumes] [--global] (LocalDevStack-scoped unless --global) -${CYAN}Domains:${NC} +${CYAN}Domains / Execution:${NC} domain add|rm|ls host add|rm|list Legacy aliases - core [domain|container] - cli [cmd...] + core [domain|service|container] [--] [command...] + cli [--] [command...] ${CYAN}Setup / Profiles:${NC} setup init|permissions|domain|profile|profiles @@ -849,7 +856,10 @@ ${CYAN}Config:${NC} urls ${CYAN}Tools / Secrets:${NC} - tools sh|exec|file + tools sh + tools exec [args...] + tools shell-exec '' + tools file secrets ${CYAN}AI:${NC} @@ -878,6 +888,20 @@ EOF } +_dispatch_builtin_command() { + local cmd="${1:-}" + shift || true + + case "$cmd" in + stack|domain|support|bundle|graphify|diag|sniff|config|images|doctor|run| up|start|stop|down|restart|reboot|ps|status|logs|urls|open|profiles| secrets|cert|host|ui|exec|events|clean|rebuild|tools|http|cli|core| setup|certificate|notify) + "cmd_$cmd" "$@" + ;; + *) + return 127 + ;; + esac +} + ############################################################################### # 7. MAIN ############################################################################### @@ -953,9 +977,11 @@ main() { llm) cmd_llm "$@" ;; vpn-fix) cmd_vpn_fix "$@" ;; *) - if declare -F "cmd_$cmd" >/dev/null 2>&1; then - "cmd_$cmd" "$@" + if _dispatch_builtin_command "$cmd" "$@"; then + : else + local rc=$? + [[ "$rc" -eq 127 ]] || return "$rc" exec "$DIR/bin/tool-runner" "$cmd" "$@" fi ;; diff --git a/lib/execution.sh b/lib/execution.sh new file mode 100644 index 00000000..c8858eb2 --- /dev/null +++ b/lib/execution.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Shared container/domain execution substrate for LDS host commands. + +LDS_CONTAINER_ID='' +LDS_CONTAINER_NAME='' +LDS_CONTAINER_SERVICE='' +LDS_CONTAINER_KIND='' +LDS_CONTAINER_ERROR='' +LDS_CORE_DOMAIN='' +LDS_CORE_APP='' +LDS_CORE_CONTAINER='' +LDS_CORE_WORKDIR='' +declare -a LDS_CONTAINER_EXEC_FLAGS=() + +_container_reset_resolution() { + LDS_CONTAINER_ID='' + LDS_CONTAINER_NAME='' + LDS_CONTAINER_SERVICE='' + LDS_CONTAINER_KIND='' + LDS_CONTAINER_ERROR='' +} + +_container_docker() { + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then + MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker "$@" + else + docker "$@" + fi +} + +_container_service_candidate() { + local requested="${1:-}" candidate + [[ -n "$requested" ]] || return 1 + + candidate="$requested" + if declare -F resolve_service >/dev/null 2>&1; then + candidate="$(resolve_service "$requested" 2>/dev/null || true)" + [[ -n "$candidate" ]] || candidate="$requested" + fi + + if declare -F compose_service_exists >/dev/null 2>&1 && compose_service_exists "$candidate"; then + printf '%s' "$candidate" + return 0 + fi + return 1 +} + +_container_resolve_target() { + local requested="${1:-}" service='' id='' name='' running='' ids='' + _container_reset_resolution + + if [[ -z "$requested" ]]; then + LDS_CONTAINER_ERROR='missing' + return 64 + fi + + service="$(_container_service_candidate "$requested" || true)" + if [[ -n "$service" ]]; then + ids="$(docker_compose ps -a -q "$service" 2>/dev/null | sed '/^[[:space:]]*$/d' || true)" + if [[ -z "$ids" ]]; then + LDS_CONTAINER_ERROR='stopped' + LDS_CONTAINER_SERVICE="$service" + return 69 + fi + if [[ "$(printf '%s\n' "$ids" | wc -l | tr -d '[:space:]')" -ne 1 ]]; then + LDS_CONTAINER_ERROR='ambiguous' + LDS_CONTAINER_SERVICE="$service" + return 65 + fi + id="$(printf '%s\n' "$ids" | sed -n '1p')" + running="$(_container_docker inspect -f '{{.State.Running}}' "$id" 2>/dev/null || true)" + if [[ "$running" != true ]]; then + LDS_CONTAINER_ERROR='stopped' + LDS_CONTAINER_SERVICE="$service" + LDS_CONTAINER_ID="$id" + return 69 + fi + name="$(_container_docker inspect -f '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true)" + LDS_CONTAINER_ID="$id" + LDS_CONTAINER_NAME="${name:-$id}" + LDS_CONTAINER_SERVICE="$service" + LDS_CONTAINER_KIND='service' + return 0 + fi + + if ! _container_docker inspect "$requested" >/dev/null 2>&1; then + LDS_CONTAINER_ERROR='missing' + return 67 + fi + running="$(_container_docker inspect -f '{{.State.Running}}' "$requested" 2>/dev/null || true)" + if [[ "$running" != true ]]; then + LDS_CONTAINER_ERROR='stopped' + return 69 + fi + id="$(_container_docker inspect -f '{{.Id}}' "$requested" 2>/dev/null || true)" + name="$(_container_docker inspect -f '{{.Name}}' "$requested" 2>/dev/null | sed 's#^/##' || true)" + LDS_CONTAINER_ID="${id:-$requested}" + LDS_CONTAINER_NAME="${name:-$requested}" + LDS_CONTAINER_KIND='container' + return 0 +} + +_container_require_running() { + local requested="${1:-}" rc=0 + if _container_resolve_target "$requested"; then + return 0 + else + rc=$? + fi + + case "$LDS_CONTAINER_ERROR" in + missing) die "Unknown service/container: $requested" ;; + stopped) + if [[ -n "$LDS_CONTAINER_SERVICE" ]]; then + die "Service is not running: $LDS_CONTAINER_SERVICE" + fi + die "Container is not running: $requested" + ;; + ambiguous) die "Service resolves to multiple containers; specify an exact container: $requested" ;; + *) die "Unable to resolve service/container: $requested (exit $rc)" ;; + esac +} + +_container_stdin_is_tty() { [[ -t 0 ]]; } +_container_stdout_is_tty() { [[ -t 1 ]]; } + +_container_exec_flags() { + local mode="${1:-command}" + LDS_CONTAINER_EXEC_FLAGS=() + + if [[ "$mode" == shell ]]; then + if ! _container_stdin_is_tty || ! _container_stdout_is_tty; then + LDS_CONTAINER_ERROR='tty-required' + return 64 + fi + LDS_CONTAINER_EXEC_FLAGS=(-i -t) + return 0 + fi + + _container_stdin_is_tty && LDS_CONTAINER_EXEC_FLAGS+=(-i) + if _container_stdin_is_tty && _container_stdout_is_tty; then + LDS_CONTAINER_EXEC_FLAGS+=(-t) + fi +} + +_container_exec_argv() { + local ctr="${1:-}" workdir="${2:-}" + shift 2 || true + [[ -n "$ctr" ]] || die "container id/name required" + (($#)) || die "container command required" + + _container_exec_flags command + local -a args=(exec "${LDS_CONTAINER_EXEC_FLAGS[@]}") + [[ -n "$workdir" ]] && args+=(-w "$workdir") + args+=("$ctr" "$@") + _container_docker "${args[@]}" +} + +_container_exec_interactive_argv() { + local ctr="${1:-}" workdir="${2:-}" + shift 2 || true + [[ -n "$ctr" ]] || die "container id/name required" + (($#)) || die "container command required" + + if ! _container_exec_flags shell; then + die "Interactive command requires a TTY" + fi + local -a args=(exec "${LDS_CONTAINER_EXEC_FLAGS[@]}") + [[ -n "$workdir" ]] && args+=(-w "$workdir") + args+=("$ctr" "$@") + _container_docker "${args[@]}" +} + +_container_shell_path() { + local ctr="${1:-}" shell + [[ -n "$ctr" ]] || return 1 + shell="$(_container_docker exec "$ctr" sh -c ' + if command -v bash >/dev/null 2>&1; then + printf "%s" bash + else + printf "%s" sh + fi + ' 2>/dev/null || true)" + [[ "$shell" == bash || "$shell" == sh ]] || shell=sh + printf '%s' "$shell" +} + +_container_existing_workdir() { + local ctr="${1:-}" preferred="${2:-}" resolved='' + [[ -n "$ctr" ]] || return 1 + + if [[ -n "$preferred" ]] && _container_docker exec "$ctr" sh -c '[ -d "$1" ]' sh "$preferred" >/dev/null 2>&1; then + resolved="$preferred" + elif _container_docker exec "$ctr" sh -c '[ -d /app ]' >/dev/null 2>&1; then + resolved='/app' + else + resolved='/' + fi + printf '%s' "$resolved" +} + +_container_open_shell() { + local ctr="${1:-}" workdir="${2:-}" shell + [[ -n "$ctr" ]] || die "container id/name required" + + if ! _container_exec_flags shell; then + die "Interactive shell requires a TTY" + fi + shell="$(_container_shell_path "$ctr")" + + local -a args=(exec "${LDS_CONTAINER_EXEC_FLAGS[@]}") + [[ -n "$workdir" ]] && args+=(-w "$workdir") + args+=("$ctr" "$shell") + [[ "$shell" == bash ]] && args+=(--login) + _container_docker "${args[@]}" +} + +_core_domain_list() { + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + _container_docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | + sed '/^[[:space:]]*$/d' | + LC_ALL=C sort -u +} + +_core_domain_resolve() { + local domain="${1:-}" tools_ctr app container wd + [[ -n "$domain" ]] || die "domain required" + + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + + app="$(_container_docker exec "$tools_ctr" domain-which --app --quiet "$domain" 2>/dev/null)" || + die "Unknown domain: $domain" + container="$(_container_docker exec "$tools_ctr" domain-which --container --quiet "$domain" 2>/dev/null)" || + die "No container resolved for: $domain" + wd="$(_container_docker exec "$tools_ctr" domain-which --docroot --quiet "$domain" 2>/dev/null || true)" + [[ -n "$container" ]] || die "No container resolved for: $domain" + + _container_require_running "$container" + container="$LDS_CONTAINER_ID" + + if [[ "$app" == node ]]; then + wd='/app' + fi + wd="$(_container_existing_workdir "$container" "${wd:-/app}")" + + LDS_CORE_DOMAIN="$domain" + LDS_CORE_APP="$app" + LDS_CORE_CONTAINER="$container" + LDS_CORE_WORKDIR="$wd" +} diff --git a/lib/services.sh b/lib/services.sh index c5a4de5c..294138a2 100644 --- a/lib/services.sh +++ b/lib/services.sh @@ -510,23 +510,29 @@ cmd_ui() { local ctr ctr="$(_project_tools_container_running || true)" [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec -it "$ctr" lazydocker + _container_exec_interactive_argv "$ctr" "" lazydocker } # ───────────────────────────────────────────────────────────────────────────── # 6f. EXEC / EVENTS / CLEAN / DISK # ───────────────────────────────────────────────────────────────────────────── cmd_exec() { - local svc="${1:-}" + local requested="${1:-}" shift || true - [[ -n "$svc" ]] || die "exec [cmd...]" - local s - s="$(resolve_service "$svc" || true)" - [[ -n "$s" ]] || die "Unknown service: $svc" - if [[ $# -gt 0 ]]; then - docker_compose exec "$s" "$@" + [[ -n "$requested" ]] || die "stack exec [--] [command...]" + + local service + service="$(_container_service_candidate "$requested" || true)" + [[ -n "$service" ]] || die "Unknown service: $requested" + + _container_require_running "$service" + local ctr="$LDS_CONTAINER_ID" + + [[ "${1:-}" == -- ]] && shift || true + if (($#)); then + _container_exec_argv "$ctr" "" "$@" else - docker_compose exec "$s" sh -lc 'command -v bash >/dev/null 2>&1 && exec bash || exec sh' + _container_open_shell "$ctr" "" fi } @@ -813,118 +819,105 @@ cmd_rebuild() { docker_shell() { - local c="${1:-}" - [[ -n "$c" ]] || die "container name required" - if docker exec "$c" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then - exec docker exec -it "$c" bash - else - exec docker exec -it "$c" sh - fi + local target="${1:-}" workdir="${2:-}" + [[ -n "$target" ]] || die "container name required" + _container_require_running "$target" + _container_open_shell "$LDS_CONTAINER_ID" "$workdir" } + cmd_tools() { local sub="${1:-sh}" shift || true local ctr ctr="$(_project_tools_container_running || true)" [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + case "${sub,,}" in sh | shell | "") - docker_shell "$ctr" + _container_open_shell "$ctr" "" ;; exec) - [[ $# -gt 0 ]] || die "tools exec " - docker exec -it "$ctr" sh -lc "$*" + [[ $# -gt 0 ]] || die "tools exec [args...]" + [[ "${1:-}" == -- ]] && shift || true + [[ $# -gt 0 ]] || die "tools exec [args...]" + if [[ $# -eq 1 && "$1" == *[[:space:]]* ]]; then + die "tools exec preserves argv; use: lds tools shell-exec ''" + fi + _container_exec_argv "$ctr" "" "$@" + ;; + shell-exec) + [[ $# -eq 1 ]] || die "tools shell-exec ''" + _container_exec_argv "$ctr" "" sh -lc "$1" ;; file) local p="${1:-}" [[ -n "$p" ]] || die "tools file " - docker exec -it "$ctr" sh -lc "ls -la -- \"$p\" 2>/dev/null || true; echo; sed -n '1,200p' -- \"$p\" 2>/dev/null || true" + _container_exec_argv "$ctr" "" sh -c ' + ls -la -- "$1" 2>/dev/null || true + echo + sed -n "1,200p" -- "$1" 2>/dev/null || true + ' sh "$p" ;; *) - die "tools " + die "tools " ;; esac } + cmd_http() { [[ ${1:-} == reload ]] && http_reload; } + cmd_cli() { - local ctr="${1:-}" + local target="${1:-}" shift || true + [[ -n "$target" ]] || die "Usage: lds cli [--] [command...]" - [[ -n "$ctr" ]] || die "Usage: lds cli [cmd...]" - - docker inspect "$ctr" >/dev/null 2>&1 || die "Container not found: $ctr" - docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || die "Container not running: $ctr" + _container_require_running "$target" + local ctr="$LDS_CONTAINER_ID" - # If user provided a command, run it; otherwise open an interactive shell. - if [[ "$#" -gt 0 ]]; then - local cmd="$*" - docker exec -it "$ctr" sh -lc ' - if command -v bash >/dev/null 2>&1; then - exec bash --login -lc "$1" - fi - exec sh -lc "$1" - ' sh "$cmd" - return + [[ "${1:-}" == -- ]] && shift || true + if (($#)); then + _container_exec_argv "$ctr" "" "$@" + else + _container_open_shell "$ctr" "" fi - - docker exec -it "$ctr" sh -lc ' - if command -v bash >/dev/null 2>&1; then - exec bash --login - fi - exec sh - ' } cmd_core() { - # Usage: - # lds core -> open correct container for that domain (PHP/Node) - # lds core -> open a shell in that container - # lds core -> list domains and let user pick - local target="${1:-}" - - # domain regex (same as domain-which/mkhost family) local re='^([a-zA-Z0-9]([-a-zA-Z0-9]{0,61}[a-zA-Z0-9])?\.)+(localhost|local|test|loc|[a-zA-Z]{2,})$' + local -a command=() - # If no target -> prompt from domain-which list - if [[ -z "$target" ]]; then - local tools_ctr - tools_ctr="$(_project_tools_container_running || true)" - [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + if [[ -n "$target" ]]; then + shift || true + [[ "${1:-}" == -- ]] && shift || true + command=("$@") + fi + if [[ -z "$target" ]]; then local -a domains=() - mapfile -t domains < <(docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | sed '/^[[:space:]]*$/d' || true) + mapfile -t domains < <(_core_domain_list) + (("${#domains[@]}" > 0)) || die "No domains found" - ((${#domains[@]} > 0)) || die "No domains found" - - # stable ordering - IFS=$'\n' domains=($(printf '%s\n' "${domains[@]}" | LC_ALL=C sort -u)) - - if ((${#domains[@]} == 1)); then + if (("${#domains[@]}" == 1)); then target="${domains[0]}" + elif [[ ! -t 0 || ! -t 1 ]]; then + printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 + local i d + i=1 + for d in "${domains[@]}"; do + printf " %2d) %s\n" "$i" "$d" >&2 + ((i++)) + done + die "No TTY to prompt. Use: lds core [--] [command...]" else - if [[ ! -t 0 ]]; then - printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 - local i=1 - local d - for d in "${domains[@]}"; do - printf " %2d) %s\n" "$i" "$d" >&2 - ((i++)) - done - die "No TTY to prompt. Use: lds core " - fi - printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 - local i=1 d + local i=1 d ans='' for d in "${domains[@]}"; do printf " %b%2d)%b %s\n" "$CYAN" "$i" "$NC" "$d" >&2 ((i++)) done - - local ans="" while true; do read -r -p "Enter number (1-${#domains[@]}): " ans - ans="$(echo "$ans" | xargs)" [[ "$ans" =~ ^[0-9]+$ ]] || { printf "%bInvalid input.%b\n" "$YELLOW" "$NC" >&2 continue @@ -939,30 +932,21 @@ cmd_core() { fi fi - # If target looks like a domain -> resolve via domain-which then shell in + local ctr workdir='' if [[ "$target" =~ $re ]]; then - local tools_ctr - tools_ctr="$(_project_tools_container_running || true)" - [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - - local app container wd - app="$(docker exec "$tools_ctr" domain-which --app --quiet "$target" 2>/dev/null)" || die "Unknown domain: $target" - container="$(docker exec "$tools_ctr" domain-which --container --quiet "$target" 2>/dev/null)" || die "No container resolved for: $target" - wd="$(docker exec "$tools_ctr" domain-which --docroot --quiet "$target" 2>/dev/null)" || true - [[ -n "${container:-}" ]] || die "No container resolved for: $target" - - # Node apps should always land at /app. Others follow resolved docroot. - if [[ "${app:-}" == "node" ]]; then - wd="/app" - fi - [[ -n "${wd:-}" ]] || wd="/app" - - docker exec -it "$container" bash -lc "cd \"$wd\" 2>/dev/null || cd /app 2>/dev/null || cd /; exec bash" - return 0 + _core_domain_resolve "$target" + ctr="$LDS_CORE_CONTAINER" + workdir="$LDS_CORE_WORKDIR" + else + _container_require_running "$target" + ctr="$LDS_CONTAINER_ID" fi - # Otherwise treat target as a container name - docker exec -it "$(printf '%s' "$target" | tr '[:lower:]' '[:upper:]')" sh -lc 'exec bash -i || exec sh' + if (("${#command[@]}" > 0)); then + _container_exec_argv "$ctr" "$workdir" "${command[@]}" + else + _container_open_shell "$ctr" "$workdir" + fi } cmd_setup() { diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 1dbb172a..e7609514 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -20,9 +20,8 @@ runner="$ROOT/docs/guides/ad-hoc-runner.rst" notify="$ROOT/docs/guides/notifications.rst" secrets="$ROOT/docs/guides/secrets-sops-age.rst" cli="$ROOT/docs/reference/cli.rst" -plan="$ROOT/docs/plans/lds-core-cli-hardening-plan.md" -for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$ops" "$runner" "$notify" "$secrets" "$cli" "$plan"; do +for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$ops" "$runner" "$notify" "$secrets" "$cli"; do assert_file "$file" done @@ -145,11 +144,18 @@ pass "documentation toctree targets exist" help_md="$("$ROOT/lds" help --markdown)" -for required in 'lds profiles add ' 'lds support trace ' 'lds support bundle [--redact|--full] [output.zip]' 'lds cli [cmd...]' 'lds run shell|ps|logs|stop|rm|open' 'MongoDB:' 'Elasticsearch:'; do +for required in 'lds profiles add ' 'lds support trace ' 'lds support bundle [--redact|--full] [output.zip]' 'lds cli [--] [command...]' 'lds core [domain|service|container] [--] [command...]' 'lds tools shell-exec' 'lds run shell|ps|logs|stop|rm|open' 'MongoDB:' 'Elasticsearch:'; do assert_contains "$help_md" "$required" done pass "embedded CLI help covers documented command groups" +assert_file_contains "$cli" 'Current-project Compose services are resolved first.' +assert_file_contains "$cli" 'Explicit commands preserve argv literally' +assert_file_contains "$cli" 'interactive Bash shell when available' +assert_file_contains "$cli" 'piped commands keep stdin with' +assert_file_contains "$cli" 'tools exec' +pass "CLI reference documents shared execution semantics" + for stale in LDS_TOOLS_IMAGE LDS_RUNNER_IMAGE LDS_NGINX_IMAGE LDS_APACHE_IMAGE; do if grep -RqsF "$stale" "$ROOT/README.md" "$ROOT/docs" --exclude-dir=plans; then @@ -159,13 +165,7 @@ done assert_file_contains "$ai" 'Both provider definitions live in ``docker/compose/companion.yaml``' pass "docs reflect fixed infrastructure images and ephemeral AI overrides" -assert_file_contains "$plan" 'lds cli' -assert_file_contains "$plan" 'lds core' -assert_file_contains "$plan" 'shared container execution substrate' -assert_file_contains "$plan" 'preserve argv' -assert_file_contains "$plan" 'adaptive `docker exec` flags' -assert_file_contains "$plan" 'Windows/Git Bash' -assert_file_contains "$plan" 'remove this plan when every item is complete' -[[ ! -d "$ROOT/docs/plans/docker-ecosystem" ]] || - fail "completed docker-ecosystem planning directory still exists" -pass "active Core/CLI hardening plan is canonical and completed ecosystem plans are retired" +if [[ -d "$ROOT/docs/plans" ]] && find "$ROOT/docs/plans" -type f -print -quit | grep -q .; then + fail "completed planning artifact remains under docs/plans" +fi +pass "completed LocalDevStack planning artifacts are retired" diff --git a/tests/execution-contract.sh b/tests/execution-contract.sh new file mode 100644 index 00000000..e55c9513 --- /dev/null +++ b/tests/execution-contract.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +die() { + printf 'die:%s\n' "$*" >&2 + return 97 +} + +lds_project() { printf '%s' testproject; } +resolve_service() { + case "${1:-}" in + web|WEB) printf '%s' web ;; + scaled) printf '%s' scaled ;; + stopped) printf '%s' stopped ;; + *) printf '%s' "${1:-}" ;; + esac +} +compose_service_exists() { + case "${1:-}" in web|scaled|stopped) return 0 ;; *) return 1 ;; esac +} + +docker_compose() { + case "$*" in + "ps -a -q web") printf '%s\n' cid-web ;; + "ps -a -q scaled") printf '%s\n' cid-a cid-b ;; + "ps -a -q stopped") printf '%s\n' cid-stopped ;; + *) return 1 ;; + esac +} + +DOCKER_LOG='' +DOCKER_HAS_BASH=1 +DOCKER_FORCE_RC=0 +DOCKER_MSYS_SEEN='' +docker() { + if [[ "${1:-}" == inspect ]]; then + shift + if [[ "${1:-}" == -f ]]; then + local fmt="$2" target="$3" + case "$fmt|$target" in + '{{.State.Running}}|cid-web'|'{{.State.Running}}|ExactCase') printf '%s\n' true ;; + '{{.State.Running}}|cid-stopped') printf '%s\n' false ;; + '{{.Id}}|ExactCase') printf '%s\n' exact-id ;; + '{{.Name}}|cid-web') printf '%s\n' /testproject-web-1 ;; + '{{.Name}}|ExactCase') printf '%s\n' /ExactCase ;; + *) return 1 ;; + esac + return + fi + case "${1:-}" in ExactCase|cid-web|cid-stopped) return 0 ;; *) return 1 ;; esac + fi + + if [[ "${1:-}" == exec ]]; then + DOCKER_MSYS_SEEN="${MSYS_NO_PATHCONV:-}|${MSYS2_ARG_CONV_EXCL:-}" + export DOCKER_MSYS_SEEN + local serialized='' arg + for arg in "$@"; do + printf -v serialized '%s <%s>' "$serialized" "$arg" + done + DOCKER_LOG="$serialized" + export DOCKER_LOG + # Shell detection / workdir probes. + if [[ "$*" == *" sh -c "* ]]; then + if [[ "$*" == *'command -v bash'* ]]; then + if ((DOCKER_HAS_BASH)); then printf '%s' bash; else printf '%s' sh; fi + return 0 + fi + if [[ "$*" == *'[ -d "$1" ]'* ]]; then + local last_arg="${!#}" + [[ "$last_arg" == /srv/app ]] && return 0 + return 1 + fi + if [[ "$*" == *'[ -d /app ]'* ]]; then return 0; fi + fi + ((DOCKER_FORCE_RC == 0)) || return "$DOCKER_FORCE_RC" + return 0 + fi + return 0 +} + +# shellcheck source=lib/execution.sh +source "$ROOT/lib/execution.sh" + +_container_resolve_target web +[[ "$LDS_CONTAINER_KIND" == service ]] || fail "service target kind not resolved" +[[ "$LDS_CONTAINER_ID" == cid-web ]] || fail "service target id drifted" +[[ "$LDS_CONTAINER_SERVICE" == web ]] || fail "service target name drifted" +pass "service-first container resolution" + +_container_resolve_target ExactCase +[[ "$LDS_CONTAINER_KIND" == container ]] || fail "exact container target not preserved" +[[ "$LDS_CONTAINER_NAME" == ExactCase ]] || fail "explicit container case was changed" +[[ "$LDS_CONTAINER_ID" == exact-id ]] || fail "explicit container id not canonicalized" +pass "exact container fallback preserves case" + +set +e +_container_resolve_target scaled +rc=$? +set -e +[[ "$rc" -eq 65 && "$LDS_CONTAINER_ERROR" == ambiguous ]] || + fail "scaled service did not report ambiguity" +set +e +_container_resolve_target stopped +rc=$? +set -e +[[ "$rc" -eq 69 && "$LDS_CONTAINER_ERROR" == stopped ]] || + fail "stopped service did not report stopped state" +set +e +_container_resolve_target missing +rc=$? +set -e +[[ "$rc" -eq 67 && "$LDS_CONTAINER_ERROR" == missing ]] || + fail "missing target did not report missing state" +pass "resolver distinguishes ambiguous stopped and missing targets" + +_container_stdin_is_tty() { return 1; } +_container_stdout_is_tty() { return 1; } +_container_exec_flags command +[[ "${#LDS_CONTAINER_EXEC_FLAGS[@]}" -eq 0 ]] || fail "non-interactive command allocated exec flags" + +_container_stdin_is_tty() { return 0; } +_container_stdout_is_tty() { return 1; } +_container_exec_flags command +[[ "${LDS_CONTAINER_EXEC_FLAGS[*]}" == "-i" ]] || fail "piped stdin did not preserve -i without -t" + +_container_stdin_is_tty() { return 0; } +_container_stdout_is_tty() { return 0; } +_container_exec_flags command +[[ "${LDS_CONTAINER_EXEC_FLAGS[*]}" == "-i -t" ]] || fail "interactive command did not allocate -it" +pass "TTY flags adapt to actual stdin/stdout" + +DOCKER_LOG='' +_container_exec_argv cid-web /srv/app printf '%s\n' 'hello world' '$(touch /tmp/nope)' 'a;b' +[[ "$DOCKER_LOG" == *'<-w> <%s\n> <$(touch /tmp/nope)> '* ]] || + fail "command argv was flattened or reinterpreted: $DOCKER_LOG" +pass "explicit command argv stays literal" + +DOCKER_LOG='' +_container_exec_argv cid-web '/srv/My App' printf '%s' 'spaced value' +[[ "$DOCKER_LOG" == *'<-w> <%s> '* ]] || + fail "working directory with spaces was not preserved: $DOCKER_LOG" + +MSYSTEM=MINGW64 +DOCKER_MSYS_SEEN='' +_container_exec_argv cid-web '/app' true +unset MSYSTEM +[[ "$DOCKER_MSYS_SEEN" == '1|*' ]] || + fail "Git Bash docker path-conversion guard was not applied" +pass "paths with spaces and Git Bash path conversion are protected" + +DOCKER_FORCE_RC=42 +set +e +_container_exec_argv cid-web '' child-command +rc=$? +set -e +DOCKER_FORCE_RC=0 +[[ "$rc" -eq 42 ]] || fail "child exit status did not propagate" +pass "child command exit status propagates" + +_container_stdin_is_tty() { return 1; } +_container_stdout_is_tty() { return 1; } +set +e +( + die() { exit 97; } + _container_open_shell cid-web '' >/dev/null 2>&1 +) +rc=$? +set -e +[[ "$rc" -ne 0 ]] || fail "interactive shell unexpectedly ran without a TTY" +pass "interactive shell rejects non-TTY invocation" + +DOCKER_HAS_BASH=0 +[[ "$(_container_shell_path cid-web)" == sh ]] || fail "Bash-unavailable container did not fall back to sh" +DOCKER_HAS_BASH=1 +[[ "$(_container_shell_path cid-web)" == bash ]] || fail "Bash-capable container did not prefer bash" +pass "interactive shell resolver prefers Bash and falls back to sh" + +[[ "$(_container_existing_workdir cid-web /srv/app)" == /srv/app ]] || + fail "existing preferred workdir was not preserved" +[[ "$(_container_existing_workdir cid-web /missing)" == /app ]] || + fail "missing preferred workdir did not fall back to /app" +pass "working-directory fallback is deterministic" + +# Domain metadata is centralized behind one helper. +_project_tools_container_running() { printf '%s' tools-id; } +docker() { + if [[ "${1:-}" == exec && "${2:-}" == tools-id && "${3:-}" == domain-which ]]; then + case "${4:-}" in + --app) printf '%s\n' node ;; + --container) printf '%s\n' ExactCase ;; + --docroot) printf '%s\n' /srv/site ;; + --list-domains) printf '%s\n' z.localhost a.localhost a.localhost ;; + esac + return 0 + fi + if [[ "${1:-}" == inspect ]]; then + shift + if [[ "${1:-}" == -f ]]; then + case "$2|$3" in + '{{.State.Running}}|ExactCase') printf '%s\n' true ;; + '{{.Id}}|ExactCase') printf '%s\n' exact-id ;; + '{{.Name}}|ExactCase') printf '%s\n' /ExactCase ;; + *) return 1 ;; + esac + return + fi + [[ "${1:-}" == ExactCase ]] && return 0 + fi + if [[ "${1:-}" == exec && "${2:-}" == exact-id && "${3:-}" == sh && "${4:-}" == -c ]]; then + [[ "${*:5}" == *'[ -d "$1" ]'* ]] && return 1 + [[ "${*:5}" == *'[ -d /app ]'* ]] && return 0 + fi + return 0 +} +mapfile -t domains < <(_core_domain_list) +[[ "${domains[*]}" == "a.localhost z.localhost" ]] || fail "domain list is not stable/unique" +_core_domain_resolve example.localhost +[[ "$LDS_CORE_APP" == node ]] || fail "domain app type missing" +[[ "$LDS_CORE_CONTAINER" == exact-id ]] || fail "domain container was not canonicalized" +[[ "$LDS_CORE_WORKDIR" == /app ]] || fail "Node domain did not force /app" +pass "domain discovery and metadata resolution are centralized" + +assert_file_contains "$ROOT/lib/services.sh" '_container_exec_argv' +assert_file_contains "$ROOT/lib/services.sh" '_container_open_shell' +assert_file_contains "$ROOT/lib/services.sh" '_core_domain_resolve' +if grep -q 'local cmd="$\*"' "$ROOT/lib/services.sh"; then + fail "services execution still flattens normal command argv" +fi +if grep -q "tr '[:lower:]' '[:upper:]'" "$ROOT/lib/services.sh"; then + fail "core still uppercases raw container targets" +fi +pass "user-facing execution surfaces delegate to shared substrate"