diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1fed851b..1e919c90 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -7,6 +7,8 @@ on: - "plan/**" - "feature/**" - "graphify/**" + - "core-cli/**" + - "lds-core-cli/**" pull_request: schedule: # Related moving latest images refresh weekly; re-run the full compatibility @@ -41,6 +43,14 @@ jobs: - name: CLI contract run: tests/cli-contract.sh + - name: Execution baseline contract + run: tests/execution-contract.sh + + - name: Container execution substrate contract + run: tests/container-exec-contract.sh + + + - name: Environment contract run: tests/env-contract.sh diff --git a/README.md b/README.md index 43ba0073..607f039e 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,36 @@ lds clean --global --yes That global mode can remove unrelated stopped containers, unused images/networks, build cache, and optionally volumes. `lds down --volumes --yes` is also destructive and should not be used for normal upgrades. +## Execution and shells + +`lds shell` is the canonical execution navigator. With no arguments it presents a numbered catalog grouped as **Applications / Domains**, **Application Directories**, **Services**, **Containers**, and **Utilities**; choose by number, exact name, or a qualified selector. + +```bash +# Interactive grouped selector. +lds shell + +# Domain/application-aware shell and command. +lds shell project.localhost +lds shell project.localhost -- php artisan about + +# Current-project service or exact container. +lds shell php84 -- php -v + +# Direct server-tools /app child fallback. +lds shell billing + +# Reserved Tools target. +lds shell tools -- jq --version + +# Intentional shell syntax or interactive TUI. +lds shell tools --shell 'printf "%s\n" "hello world" | cat' +lds shell tools --interactive lazydocker +``` + +Explicit unqualified targets resolve in this order: discovered domain, reserved `tools`, exact current-project service, exact container, then an exact direct child under `server-tools:/app`. Qualified `domain:`, `app:`, `service:`, `container:`, and `utility:tools` selectors bypass collisions. Image names are not implicitly instantiated. + +Normal commands preserve argv rather than being flattened into a shell string. Interactive shells/TUIs require a real TTY; piped/non-interactive commands do not force one. `core`, `cli`, `stack exec`, `exec`, and execution-oriented `tools` commands remain compatibility surfaces during migration. See `docs/reference/cli.rst` for target resolution and exit-code details. + ## Ad-hoc Dockerfile runner From a directory containing a Dockerfile: diff --git a/docs/concepts/architecture.rst b/docs/concepts/architecture.rst index 68808a58..bb6a0aca 100644 --- a/docs/concepts/architecture.rst +++ b/docs/concepts/architecture.rst @@ -20,6 +20,23 @@ Host Orchestration ``configuration/compose/`` Generated runtime Compose fragments discovered and merged into the effective stack. +Execution Navigation +-------------------- + +``lds shell`` is the canonical host-side execution and navigation surface. It resolves +discovered domains, direct application directories under ``server-tools:/app``, exact +current-project Compose services, exact Docker containers, and the trusted Tools target +into one normalized execution context. + +Unqualified targets resolve deterministically in this order: discovered domain, +``tools``, exact Compose service, exact Docker container, then an exact direct child +under ``/app``. Qualified ``domain:``, ``app:``, ``service:``, ``container:``, and +``utility:tools`` selectors bypass collisions. + +The compatibility commands ``core``, ``cli``, ``stack exec``, and the +execution-oriented ``tools`` subcommands delegate to the same shell-context execution +layer while retaining their intentionally narrower public contracts. + Core Services ------------- diff --git a/docs/guides/databases-and-clients.rst b/docs/guides/databases-and-clients.rst index f9ac335e..a5485362 100644 --- a/docs/guides/databases-and-clients.rst +++ b/docs/guides/databases-and-clients.rst @@ -154,9 +154,12 @@ Check the whole stack with:: Inspect a service directly with:: lds logs postgres - lds stack exec postgres + lds shell service:postgres lds restart postgres +The older ``lds stack exec postgres`` form remains available for service-only +compatibility. + Admin UIs --------- diff --git a/docs/guides/domain-setup.rst b/docs/guides/domain-setup.rst index 5faa7750..10811254 100644 --- a/docs/guides/domain-setup.rst +++ b/docs/guides/domain-setup.rst @@ -123,22 +123,31 @@ and Tools can include generated domain/service names. The wildcard covers built-in convenience endpoints such as ``admin.localhost``, ``webmail.localhost``, and ``llm-ollama.localhost``. -Working in a Domain Container ------------------------------ +Working in a Domain or Application Context +------------------------------------------ -Resolve a domain to its application/runtime container and open a shell:: +Use the canonical shell navigator to resolve a discovered domain to its +application/runtime container and working directory:: - lds core project.localhost + lds shell project.localhost -When no domain is supplied, ``lds core`` lists known domains and prompts on an -interactive terminal. +Run a command in that same resolved application context without losing argv boundaries:: -A direct container name can also be supplied to ``lds core``. + lds shell project.localhost -- php artisan about -For a generic container shell/command flow use:: +With no target, ``lds shell`` opens the grouped selector and includes discovered +domains, direct application directories under ``server-tools:/app``, services, +containers, and Tools. - lds cli - lds cli +Use qualified selectors when you want a specific target class or a name is ambiguous:: + + lds shell domain:project.localhost + lds shell app:project + lds shell service:php84 + lds shell container:localdevstack-php84-1 + +``lds core`` and ``lds cli`` remain compatibility commands, but new workflows +should use ``lds shell``. Diagnostics ----------- diff --git a/docs/guides/local-ai.rst b/docs/guides/local-ai.rst index f21a61ce..42f51a1e 100644 --- a/docs/guides/local-ai.rst +++ b/docs/guides/local-ai.rst @@ -391,5 +391,15 @@ Troubleshooting Use ``lds logs llm-fastflow`` or ``lds logs llm-ollama`` only when you explicitly want the provider-specific service identity. +For an interactive provider container through the canonical shell navigator, use the +exact active service identity:: + + lds shell service:llm-fastflow + lds shell service:llm-ollama + +Only the active provider service is expected to exist. The logical ``llm`` alias belongs +to stack/service operations and is not rewritten by the exact ``service:`` shell +qualifier. + If FastFlow was expected but not selected, verify ``/dev/accel/accel0`` and the host ``amdxdna`` driver before forcing ``lds llm runtime npu``. diff --git a/docs/guides/operations-and-support.rst b/docs/guides/operations-and-support.rst index 24146f5d..0ca32ce0 100644 --- a/docs/guides/operations-and-support.rst +++ b/docs/guides/operations-and-support.rst @@ -47,13 +47,17 @@ Status and Logs ``lds status`` delegates the richer status view to Tools. ``lds ps`` uses Compose directly. -Execute a command in a service:: +Execute a command in an exact current-project service through the canonical +execution surface:: - lds stack exec nginx nginx -t + lds shell service:nginx -- nginx -t -Open an interactive shell when no command is supplied:: +Open an interactive shell in that service:: - lds stack exec nginx + lds shell service:nginx + +``lds stack exec nginx ...`` and the top-level ``lds exec nginx ...`` alias remain +service-only compatibility forms. Stack Diff ---------- diff --git a/docs/guides/secrets-sops-age.rst b/docs/guides/secrets-sops-age.rst index 377b9edd..080d8944 100644 --- a/docs/guides/secrets-sops-age.rst +++ b/docs/guides/secrets-sops-age.rst @@ -35,12 +35,12 @@ The LocalDevStack command:: delegates directly to the Tools ``senv`` command inside the running ``server-tools`` container. -Use:: +Use the canonical Tools shell target:: - lds tools sh + lds shell tools when you need to inspect the trusted Tools environment interactively before running a -manual SOPS/Age operation. +manual SOPS/Age operation. ``lds tools sh`` remains a compatibility form. Trust Boundary -------------- 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/quickstart.rst b/docs/quickstart.rst index 60022cde..53fa8143 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -116,6 +116,11 @@ Useful First Checks lds config validate lds status lds ps + lds shell + +``lds shell`` opens the grouped execution selector for domains, application +directories, services, containers, and Tools. Select by number or exact name; use a +qualified target when names collide. ``lds config show`` is redacted by default. Use ``--raw`` only when unredacted output is deliberately required. diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 3ddbf066..11ad0c24 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -35,7 +35,7 @@ Stack lds stack status [status-args...] lds stack ps lds stack logs [service] [--follow] [--since ] [--grep ] - lds stack exec [command...] + lds stack exec [--] [command...] lds stack events [since] lds stack clean --yes [--volumes] [--global] lds stack diff [--config] [--json] @@ -155,23 +155,71 @@ Shortcuts:: lds notify ... lds ui -Tools Control Plane -------------------- +Execution and Shells +-------------------- -:: +``lds shell`` is the canonical execution/navigation surface. - lds tools sh - lds tools exec "" - lds tools file +With no arguments it builds a stable grouped catalog:: + + Applications / Domains + Application Directories + Services + Containers + Utilities + +The groups contain discovered domains, direct ``server-tools:/app`` child +directories, current-project Compose services, running Docker containers, and +the ``tools`` utility target:: + + lds shell + +The selector accepts the displayed global number or an exact name. If the same +name exists in multiple categories, use a qualified selector:: + + domain:project.localhost + app:project + service:php84 + container:localdevstack-php84-1 + utility:tools -Open a generic container shell or run a command:: +Explicit targets use deterministic precedence: exact discovered domain, reserved +``tools`` target, exact current-project service, exact Docker container, then an +exact direct child ``/app/`` inside the current project's server-tools +container. No fuzzy matching or implicit case conversion is performed. Image +names are not implicitly instantiated. - lds cli - lds cli +Canonical forms:: -Resolve a domain/container to its application shell:: + lds shell + lds shell [--] [args...] + lds shell --shell + lds shell --interactive [args...] - lds core [domain|container] +Domain targets retain application-aware working-directory behavior: Node uses +``/app``; other applications use the resolved document root when available, +then ``/app`` and ``/``. Application-directory fallback opens +``server-tools`` at ``/app/``. + +Normal command forms preserve argv exactly. ``--shell`` is the explicit escape +hatch for pipelines, redirections, and compound shell syntax. +``--interactive`` routes argv through the shared real-TTY execution helper. +Interactive shells and TUIs require a real TTY on both stdin and stdout; piped +commands keep stdin without forcing a TTY. + +New documentation and interactive workflows should prefer ``lds shell``. +The older execution surfaces remain compatible during migration:: + + lds core [domain|service|container] [--] [command...] + lds cli [--] [command...] + lds stack exec [--] [command...] + lds tools sh + lds tools exec [--] [args...] + lds tools shell-exec + lds tools file + +``stack exec`` remains service-only. ``tools file`` remains inspection +functionality rather than generic shell navigation. Secrets ------- @@ -278,6 +326,12 @@ Generic service operations also accept ``llm`` and resolve it to the active prov lds exec llm ... lds rebuild llm +The logical ``llm`` name is an operational alias handled by those service commands. +``lds shell service:`` is intentionally exact and does not rewrite logical +service aliases. For the canonical shell navigator, target the active provider service +explicitly as ``service:llm-fastflow`` or ``service:llm-ollama``. The +``lds exec llm`` form remains the provider-neutral compatibility path. + FastFlow/NPU and Ollama both default to ``qwen3.5:9b``. Leaving ``LDS_AI_MODEL`` blank allows the provider default to apply. Rebuild diff --git a/lds b/lds index 9b7ef6bb..f5f44b79 100755 --- a/lds +++ b/lds @@ -433,6 +433,12 @@ on_error() { # shellcheck source=lib/compose.sh source "$DIR/lib/compose.sh" +############################################################################### +# 1a.1 SHARED CONTAINER EXECUTION +############################################################################### +# shellcheck source=lib/container-exec.sh +source "$DIR/lib/container-exec.sh" + ############################################################################### # 1b. PROMPTS + DOTENV HELPERS ############################################################################### @@ -764,12 +770,22 @@ cmd_help() { - `lds images` - `lds urls` -## Tools / shells / secrets -- `lds tools sh` -- `lds tools exec ""` -- `lds tools file ` -- `lds cli [cmd...]` -- `lds core [domain|container]` +## Execution / shells +- `lds shell` — grouped interactive selector for domains, application directories, services, containers, and Tools. +- `lds shell ` — open an interactive shell in the resolved context. +- `lds shell [--] [args...]` — argv-preserving execution. +- `lds shell --shell ` — intentional shell parsing. +- `lds shell --interactive [args...]` — interactive/TUI argv execution. +- Qualified selectors: `domain:`, `app:`, `service:`, `container:`, `utility:tools`. +- `lds core [domain|service|container] [--] [command...]` — compatibility application/domain-aware execution with resolved working directory. +- `lds cli [--] [command...]` — generic current-project service or exact-container execution. +- `lds stack exec [--] [command...]` *(alias: `lds exec`)* — Compose-service-only execution. +- `lds tools sh` — interactive shell in the project `server-tools` container. +- `lds tools exec [--] [args...]` — argv-preserving execution in `server-tools`. +- `lds tools shell-exec ` — intentional shell parsing in `server-tools`. +- `lds tools file ` — inspect a file inside `server-tools`. + +## Secrets - `lds secrets ` ## AI consumer @@ -821,8 +837,6 @@ ${CYAN}Stack:${NC} ${CYAN}Domains:${NC} domain add|rm|ls host add|rm|list Legacy aliases - core [domain|container] - cli [cmd...] ${CYAN}Setup / Profiles:${NC} setup init|permissions|domain|profile|profiles @@ -848,8 +862,17 @@ ${CYAN}Config:${NC} images urls -${CYAN}Tools / Secrets:${NC} - tools sh|exec|file +${CYAN}Execution / Shells:${NC} + shell [target] [--] [command...] Unified selector/execution + shell --shell Intentional shell syntax + shell --interactive [args...] Interactive/TUI command + qualifiers: domain: app: service: container: utility:tools + core [domain|service|container] [--] [command...] Compatibility domain/app entry + cli [--] [command...] Generic service/container exec + stack exec [--] [command...] Compose-service only + tools sh|exec|shell-exec|file server-tools only + +${CYAN}Secrets:${NC} secrets ${CYAN}AI:${NC} @@ -878,6 +901,15 @@ EOF } +_is_public_lds_command() { + case "${1:-}" in + stack|domain|support|bundle|up|start|down|stop|restart|reboot|status|ps|logs|exec|events|clean|config|http|host|setup|profiles|cert|certificate|doctor|diag|sniff|open|notify|ui|images|urls|tools|cli|core|shell|graphify|secrets|rebuild|run) + return 0 + ;; + esac + return 1 +} + ############################################################################### # 7. MAIN ############################################################################### @@ -953,7 +985,7 @@ main() { llm) cmd_llm "$@" ;; vpn-fix) cmd_vpn_fix "$@" ;; *) - if declare -F "cmd_$cmd" >/dev/null 2>&1; then + if _is_public_lds_command "$cmd"; then "cmd_$cmd" "$@" else exec "$DIR/bin/tool-runner" "$cmd" "$@" diff --git a/lib/container-exec.sh b/lib/container-exec.sh new file mode 100644 index 00000000..9c4f2ccf --- /dev/null +++ b/lib/container-exec.sh @@ -0,0 +1,260 @@ +# shellcheck shell=bash +# Shared container target resolution and execution substrate. + +_CONTAINER_TARGET_KIND='' +_CONTAINER_TARGET_REQUESTED='' +_CONTAINER_TARGET_SERVICE='' +_CONTAINER_TARGET_ID='' +_CONTAINER_TARGET_NAME='' +declare -a _CONTAINER_EXEC_FLAGS=() + +_container_is_msys() { + [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]] && return 0 + case "${OSTYPE:-}" in + msys* | cygwin*) return 0 ;; + esac + return 1 +} + +_container_docker() { + if _container_is_msys; then + MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker "$@" + else + docker "$@" + fi +} + +_container_target_reset() { + _CONTAINER_TARGET_KIND='' + _CONTAINER_TARGET_REQUESTED='' + _CONTAINER_TARGET_SERVICE='' + _CONTAINER_TARGET_ID='' + _CONTAINER_TARGET_NAME='' +} + +_container_project_service_exists() { + local want="${1:-}" service + [[ -n "$want" ]] || return 1 + while IFS= read -r service; do + [[ "$service" == "$want" ]] && return 0 + done < <(docker_compose config --services 2>/dev/null || true) + return 1 +} + +_container_name_from_id() { + local id="${1:-}" name + [[ -n "$id" ]] || return 1 + name="$(_container_docker inspect -f '{{.Name}}' "$id" 2>/dev/null || true)" + name="${name#/}" + [[ -n "$name" ]] || return 1 + printf '%s' "$name" +} + +_container_resolve_target() { + local target="${1:-}" id name service + local -a ids=() + + _container_target_reset + [[ -n "$target" ]] || { + err "Container/service target is required" + return 64 + } + _CONTAINER_TARGET_REQUESTED="$target" + + # Current-project Compose service names win over global Docker names. + if _container_project_service_exists "$target"; then + mapfile -t ids < <(docker_compose ps -a -q "$target" 2>/dev/null | awk 'NF') + if (("${#ids[@]}" == 0)); then + err "Service container is not created: $target" + return 66 + fi + if (("${#ids[@]}" > 1)); then + err "Service resolves to multiple containers: $target" + return 65 + fi + + id="${ids[0]}" + name="$(_container_name_from_id "$id" || true)" + [[ -n "$name" ]] || name="$id" + + _CONTAINER_TARGET_KIND=service + _CONTAINER_TARGET_SERVICE="$target" + _CONTAINER_TARGET_ID="$id" + _CONTAINER_TARGET_NAME="$name" + return 0 + fi + + # Otherwise preserve exact Docker container names/IDs as an explicit escape. + id="$(_container_docker inspect -f '{{.Id}}' "$target" 2>/dev/null || true)" + [[ -n "$id" ]] || { + err "Container or current-project service not found: $target" + return 66 + } + + name="$(_container_name_from_id "$id" || true)" + [[ -n "$name" ]] || name="$target" + service="$(_container_docker inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$id" 2>/dev/null || true)" + + _CONTAINER_TARGET_KIND=container + _CONTAINER_TARGET_SERVICE="$service" + _CONTAINER_TARGET_ID="$id" + _CONTAINER_TARGET_NAME="$name" +} + +_container_require_running() { + local target="${1:-}" running + [[ -n "$target" ]] || { + err "Container target is required" + return 64 + } + + running="$(_container_docker inspect -f '{{.State.Running}}' "$target" 2>/dev/null || true)" + [[ "$running" == true ]] || { + err "Container is not running: $target" + return 69 + } +} + +_container_stdin_is_tty() { [[ -t 0 ]]; } +_container_stdout_is_tty() { [[ -t 1 ]]; } +_container_stdin_has_data() { [[ -p /dev/stdin || -f /dev/stdin ]]; } + +_container_exec_flags() { + local mode="${1:-command}" + _CONTAINER_EXEC_FLAGS=() + + case "$mode" in + shell) + if ! _container_stdin_is_tty || ! _container_stdout_is_tty; then + err "Interactive container session requires a TTY" + return 64 + fi + _CONTAINER_EXEC_FLAGS=(-it) + ;; + command) + if _container_stdin_is_tty && _container_stdout_is_tty; then + _CONTAINER_EXEC_FLAGS=(-it) + elif _container_stdin_is_tty || _container_stdin_has_data; then + _CONTAINER_EXEC_FLAGS=(-i) + fi + ;; + *) + err "Unknown container execution mode: $mode" + return 64 + ;; + esac +} + +_container_exec_argv() { + local target="${1:-}" workdir='' + shift || true + + if [[ "${1:-}" == --workdir ]]; then + workdir="${2:-}" + [[ -n "$workdir" ]] || { + err "--workdir requires a path" + return 64 + } + shift 2 + fi + [[ "${1:-}" == -- ]] && shift + (($# > 0)) || { + err "Container command is required" + return 64 + } + + _container_require_running "$target" || return $? + _container_exec_flags command || return $? + + local -a args=(exec "${_CONTAINER_EXEC_FLAGS[@]}") + [[ -n "$workdir" ]] && args+=(--workdir "$workdir") + args+=("$target" "$@") + _container_docker "${args[@]}" +} + +_container_exec_interactive_argv() { + local target="${1:-}" workdir='' + shift || true + + if [[ "${1:-}" == --workdir ]]; then + workdir="${2:-}" + [[ -n "$workdir" ]] || { + err "--workdir requires a path" + return 64 + } + shift 2 + fi + [[ "${1:-}" == -- ]] && shift + (($# > 0)) || { + err "Interactive container command is required" + return 64 + } + + _container_require_running "$target" || return $? + _container_exec_flags shell || return $? + + local -a args=(exec "${_CONTAINER_EXEC_FLAGS[@]}") + [[ -n "$workdir" ]] && args+=(--workdir "$workdir") + args+=("$target" "$@") + _container_docker "${args[@]}" +} + +_container_first_existing_dir() { + local target="${1:-}" + shift || true + _container_require_running "$target" || return $? + + local path + for path in "$@"; do + [[ -n "$path" ]] || continue + if _container_docker exec "$target" test -d "$path" >/dev/null 2>&1; then + printf '%s' "$path" + return 0 + fi + done + + printf '%s' / +} + +_container_shell_name() { + local target="${1:-}" + _container_require_running "$target" || return $? + if _container_docker exec "$target" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then + printf '%s' bash + else + printf '%s' sh + fi +} + +_container_open_shell() { + local target="${1:-}" workdir='' + shift || true + + if [[ "${1:-}" == --workdir ]]; then + workdir="${2:-}" + [[ -n "$workdir" ]] || { + err "--workdir requires a path" + return 64 + } + shift 2 + fi + (($# == 0)) || { + err "Unexpected shell arguments: $*" + return 64 + } + + _container_require_running "$target" || return $? + _container_exec_flags shell || return $? + local shell + shell="$(_container_shell_name "$target")" || return $? + + local -a args=(exec "${_CONTAINER_EXEC_FLAGS[@]}") + [[ -n "$workdir" ]] && args+=(--workdir "$workdir") + args+=("$target") + if [[ "$shell" == bash ]]; then + args+=(bash --login) + else + args+=(sh) + fi + _container_docker "${args[@]}" +} diff --git a/lib/services.sh b/lib/services.sh index c5a4de5c..66869d7d 100644 --- a/lib/services.sh +++ b/lib/services.sh @@ -468,7 +468,10 @@ cmd_profiles() { cmd_secrets() { local ctr ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + [[ -n "$ctr" ]] || { + err "server-tools container is not running for project: $(lds_project)" + return 69 + } docker exec -it "$ctr" senv "$@" } @@ -507,26 +510,37 @@ cmd_host() { } 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 + _shell_context_reset + _shell_resolve_tools || return $? + _shell_context_exec_interactive 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" ]] || { + err "Usage: lds stack exec [--] [command...]" + return 64 + } + [[ "${1:-}" == -- ]] && shift + + local service + service="$(resolve_service "$requested" || true)" + if [[ -z "$service" ]] || ! compose_service_exists "$service"; then + err "Current-project service not found: $requested" + return 66 + fi + + _shell_context_reset + _shell_resolve_service "$service" || return $? + + if (($# > 0)); then + _shell_context_exec_argv "$@" else - docker_compose exec "$s" sh -lc 'command -v bash >/dev/null 2>&1 && exec bash || exec sh' + _shell_context_open fi } @@ -812,157 +826,719 @@ 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 -} 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)" + + _shell_context_reset + _shell_resolve_tools || return $? + case "${sub,,}" in sh | shell | "") - docker_shell "$ctr" + _shell_context_open ;; exec) - [[ $# -gt 0 ]] || die "tools exec " - docker exec -it "$ctr" sh -lc "$*" + [[ "${1:-}" == -- ]] && shift + (($# > 0)) || { + err "Usage: lds tools exec [--] [args...]" + return 64 + } + _shell_context_exec_argv "$@" + ;; + shell-exec) + (($# == 1)) || { + err "Usage: lds tools shell-exec " + return 64 + } + _shell_context_exec_expression "$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" + local path="${1:-}" + [[ -n "$path" ]] || { + err "Usage: lds tools file " + return 64 + } + _shell_context_exec_argv sh -lc ' + ls -la -- "$1" 2>/dev/null || true + printf "\n" + sed -n "1,200p" -- "$1" 2>/dev/null || true + ' sh "$path" ;; *) - die "tools " + die "tools " ;; esac } + cmd_http() { [[ ${1:-} == reload ]] && http_reload; } cmd_cli() { - local ctr="${1:-}" + local target="${1:-}" shift || true - [[ -n "$ctr" ]] || die "Usage: lds cli [cmd...]" + [[ -n "$target" ]] || { + err "Usage: lds cli [--] [command...]" + return 64 + } + [[ "${1:-}" == -- ]] && shift - 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" + _shell_resolve_service_or_container "$target" || return $? - # 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 + if (($# > 0)); then + _shell_context_exec_argv "$@" + else + _shell_context_open fi +} - docker exec -it "$ctr" sh -lc ' - if command -v bash >/dev/null 2>&1; then - exec bash --login - fi - exec sh - ' +_CORE_DOMAIN='' +_CORE_APP='' +_CORE_CONTAINER_ID='' +_CORE_CONTAINER_NAME='' +_CORE_WORKDIR='' + +_core_is_domain() { + local target="${1:-}" domain + [[ -n "$target" ]] || return 1 + + # Domain ownership lives in Tools. Only an exact discovered domain is treated + # as a domain here; hostname-shaped service/container names remain valid + # execution targets instead of being guessed from their spelling. + while IFS= read -r domain; do + [[ "$domain" == "$target" ]] && return 0 + done < <(_core_domain_list 2>/dev/null || true) + + return 1 } -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 +_core_domain_list() { + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || { + err "server-tools container is not running for project: $(lds_project)" + return 69 + } - local target="${1:-}" + docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | + sed '/^[[:space:]]*$/d' | + LC_ALL=C sort -u +} - # 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,})$' +_core_choose_domain() { + local -a domains=() + mapfile -t domains < <(_core_domain_list) || return $? + (("${#domains[@]}" > 0)) || { + err "No domains found" + return 66 + } - # 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 (("${#domains[@]}" == 1)); then + printf '%s' "${domains[0]}" + return 0 + fi - local -a domains=() - mapfile -t domains < <(docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | sed '/^[[:space:]]*$/d' || true) + if [[ ! -t 0 ]]; then + printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 + local i=1 domain + for domain in "${domains[@]}"; do + printf " %2d) %s\n" "$i" "$domain" >&2 + ((i++)) + done + err "No TTY to prompt. Use: lds core " + return 64 + fi + + printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 + local i=1 domain + for domain in "${domains[@]}"; do + printf " %b%2d)%b %s\n" "$CYAN" "$i" "$NC" "$domain" >&2 + ((i++)) + done - ((${#domains[@]} > 0)) || die "No domains found" + local answer='' + while true; do + read -r -p "Enter number (1-${#domains[@]}): " answer + answer="${answer#"${answer%%[![:space:]]*}"}" + answer="${answer%"${answer##*[![:space:]]}"}" + [[ "$answer" =~ ^[0-9]+$ ]] || { + printf "%bInvalid input.%b\n" "$YELLOW" "$NC" >&2 + continue + } + ((answer >= 1 && answer <= ${#domains[@]})) || { + printf "%bOut of range.%b\n" "$YELLOW" "$NC" >&2 + continue + } + printf '%s' "${domains[$((answer - 1))]}" + return 0 + done +} - # stable ordering - IFS=$'\n' domains=($(printf '%s\n' "${domains[@]}" | LC_ALL=C sort -u)) +_core_domain_resolve() { + local domain="${1:-}" tools_ctr app target docroot preferred - if ((${#domains[@]} == 1)); then - target="${domains[0]}" - 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 + _CORE_DOMAIN='' + _CORE_APP='' + _CORE_CONTAINER_ID='' + _CORE_CONTAINER_NAME='' + _CORE_WORKDIR='' - printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 - local i=1 d - for d in "${domains[@]}"; do - printf " %b%2d)%b %s\n" "$CYAN" "$i" "$NC" "$d" >&2 - ((i++)) - done + [[ -n "$domain" ]] || { + err "Core domain is required" + return 64 + } - 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 - } - ((ans >= 1 && ans <= ${#domains[@]})) || { - printf "%bOut of range.%b\n" "$YELLOW" "$NC" >&2 - continue - } - target="${domains[$((ans - 1))]}" - break - done - fi + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || { + err "server-tools container is not running for project: $(lds_project)" + return 69 + } + + app="$(docker exec "$tools_ctr" domain-which --app --quiet "$domain" 2>/dev/null)" || { + err "Unknown domain: $domain" + return 66 + } + target="$(docker exec "$tools_ctr" domain-which --container --quiet "$domain" 2>/dev/null)" || { + err "No container resolved for: $domain" + return 66 + } + docroot="$(docker exec "$tools_ctr" domain-which --docroot --quiet "$domain" 2>/dev/null || true)" + [[ -n "$target" ]] || { + err "No container resolved for: $domain" + return 66 + } + + _container_resolve_target "$target" || return $? + _container_require_running "$_CONTAINER_TARGET_ID" || return $? + + if [[ "${app,,}" == node ]]; then + preferred=/app + else + preferred="${docroot:-/app}" + fi + + _CORE_DOMAIN="$domain" + _CORE_APP="$app" + _CORE_CONTAINER_ID="$_CONTAINER_TARGET_ID" + _CORE_CONTAINER_NAME="$_CONTAINER_TARGET_NAME" + _CORE_WORKDIR="$(_container_first_existing_dir "$_CORE_CONTAINER_ID" "$preferred" /app /)" || return $? +} + +_SHELL_TARGET_KIND='' +_SHELL_TARGET_REQUESTED='' +_SHELL_DOMAIN='' +_SHELL_APP='' +_SHELL_SERVICE='' +_SHELL_CONTAINER_ID='' +_SHELL_CONTAINER_NAME='' +_SHELL_WORKDIR='' + +_shell_context_reset() { + _SHELL_TARGET_KIND='' + _SHELL_TARGET_REQUESTED='' + _SHELL_DOMAIN='' + _SHELL_APP='' + _SHELL_SERVICE='' + _SHELL_CONTAINER_ID='' + _SHELL_CONTAINER_NAME='' + _SHELL_WORKDIR='' +} + +_shell_context_from_resolved_container() { + local kind="${1:-container}" requested="${2:-}" + _SHELL_TARGET_KIND="$kind" + _SHELL_TARGET_REQUESTED="$requested" + _SHELL_SERVICE="$_CONTAINER_TARGET_SERVICE" + _SHELL_CONTAINER_ID="$_CONTAINER_TARGET_ID" + _SHELL_CONTAINER_NAME="$_CONTAINER_TARGET_NAME" +} + +_shell_resolve_domain() { + local domain="${1:-}" + [[ -n "$domain" ]] || { + err "Shell domain target is required" + return 64 + } + _core_is_domain "$domain" || { + err "Domain not found: $domain" + return 66 + } + _core_domain_resolve "$domain" || return $? + + _SHELL_TARGET_KIND=domain + _SHELL_TARGET_REQUESTED="$domain" + _SHELL_DOMAIN="$_CORE_DOMAIN" + _SHELL_APP="$_CORE_APP" + _SHELL_SERVICE="$_CONTAINER_TARGET_SERVICE" + _SHELL_CONTAINER_ID="$_CORE_CONTAINER_ID" + _SHELL_CONTAINER_NAME="$_CORE_CONTAINER_NAME" + _SHELL_WORKDIR="$_CORE_WORKDIR" +} + +_shell_resolve_tools() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || { + err "server-tools container is not running for project: $(lds_project)" + return 69 + } + _container_require_running "$ctr" || return $? + + _SHELL_TARGET_KIND=tools + _SHELL_TARGET_REQUESTED=tools + _SHELL_SERVICE=server-tools + _SHELL_CONTAINER_ID="$ctr" + _SHELL_CONTAINER_NAME="$ctr" +} + +_shell_resolve_service() { + local service="${1:-}" + [[ -n "$service" ]] || { + err "Shell service target is required" + return 64 + } + _container_project_service_exists "$service" || { + err "Current-project service not found: $service" + return 66 + } + _container_resolve_target "$service" || return $? + _shell_context_from_resolved_container service "$service" +} + +_shell_exact_container_exists() { + local target="${1:-}" running + [[ -n "$target" ]] || return 1 + running="$(_container_docker inspect -f '{{.State.Running}}' "$target" 2>/dev/null || true)" + [[ "$running" == true || "$running" == false ]] +} + +_shell_resolve_container() { + local target="${1:-}" id name service + [[ -n "$target" ]] || { + err "Shell container target is required" + return 64 + } + _shell_exact_container_exists "$target" || { + err "Container not found: $target" + return 66 + } + + id="$(_container_docker inspect -f '{{.Id}}' "$target" 2>/dev/null || true)" + [[ -n "$id" ]] || { + err "Container not found: $target" + return 66 + } + name="$(_container_name_from_id "$id" || true)" + [[ -n "$name" ]] || name="$target" + service="$(_container_docker inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$id" 2>/dev/null || true)" + + _SHELL_TARGET_KIND=container + _SHELL_TARGET_REQUESTED="$target" + _SHELL_SERVICE="$service" + _SHELL_CONTAINER_ID="$id" + _SHELL_CONTAINER_NAME="$name" +} + +_shell_app_name_valid() { + local name="${1:-}" + [[ -n "$name" && "$name" != . && "$name" != .. && "$name" != */* ]] +} + +_shell_resolve_app() { + local name="${1:-}" ctr path + _shell_app_name_valid "$name" || { + err "Application directory must be a direct child name under /app: $name" + return 64 + } + + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || { + err "server-tools container is not running for project: $(lds_project)" + return 69 + } + _container_require_running "$ctr" || return $? + + path="/app/$name" + _container_docker exec "$ctr" sh -c '[ -d "$1" ]' sh "$path" >/dev/null 2>&1 || { + err "Application directory not found: $path" + return 66 + } + + _SHELL_TARGET_KIND=app + _SHELL_TARGET_REQUESTED="$name" + _SHELL_APP="$name" + _SHELL_SERVICE=server-tools + _SHELL_CONTAINER_ID="$ctr" + _SHELL_CONTAINER_NAME="$ctr" + _SHELL_WORKDIR="$path" +} + +_shell_resolve_target() { + local requested="${1:-}" target state + _shell_context_reset + + [[ -n "$requested" ]] || { + err "Shell target is required" + return 64 + } + + case "$requested" in + domain:*) + target="${requested#domain:}" + _shell_resolve_domain "$target" + return $? + ;; + service:*) + target="${requested#service:}" + _shell_resolve_service "$target" + return $? + ;; + container:*) + target="${requested#container:}" + _shell_resolve_container "$target" + return $? + ;; + app:*) + target="${requested#app:}" + _shell_resolve_app "$target" + return $? + ;; + utility:tools) + _shell_resolve_tools + return $? + ;; + esac + + if _core_is_domain "$requested"; then + _shell_resolve_domain "$requested" + return $? + fi + + if [[ "$requested" == tools ]]; then + _shell_resolve_tools + return $? + fi + + if _container_project_service_exists "$requested"; then + _shell_resolve_service "$requested" + return $? + fi + + state="$(_container_docker inspect -f '{{.State.Running}}' "$requested" 2>/dev/null || true)" + if [[ "$state" == true || "$state" == false ]]; then + _shell_resolve_container "$requested" + return $? fi - # If target looks like a domain -> resolve via domain-which then shell in - if [[ "$target" =~ $re ]]; then + if _shell_app_name_valid "$requested"; 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 [[ -z "$tools_ctr" ]]; then + err "server-tools container is not running for project: $(lds_project)" + return 69 + fi + if _container_docker exec "$tools_ctr" sh -c '[ -d "$1" ]' sh "/app/$requested" >/dev/null 2>&1; then + _shell_resolve_app "$requested" + return $? + fi + fi + + err "Shell target not found: $requested (checked domain, tools, service, container, and /app directory)" + return 66 +} + +_shell_resolve_service_or_container() { + local target="${1:-}" + _shell_context_reset + _container_resolve_target "$target" || return $? + _shell_context_from_resolved_container "$_CONTAINER_TARGET_KIND" "$target" +} + +_shell_context_open() { + local container="$_SHELL_CONTAINER_ID" workdir="$_SHELL_WORKDIR" + [[ -n "$container" ]] || { + err "Shell context has no container" + return 64 + } + if [[ -n "$workdir" ]]; then + _container_open_shell "$container" --workdir "$workdir" + else + _container_open_shell "$container" + fi +} + +_shell_context_exec_argv() { + local container="$_SHELL_CONTAINER_ID" workdir="$_SHELL_WORKDIR" + (($# > 0)) || { + err "Shell context command is required" + return 64 + } + if [[ -n "$workdir" ]]; then + _container_exec_argv "$container" --workdir "$workdir" -- "$@" + else + _container_exec_argv "$container" -- "$@" + fi +} + +_shell_context_exec_expression() { + (($# == 1)) || { + err "Shell expression is required" + return 64 + } + _shell_context_exec_argv sh -lc "$1" +} + +_shell_context_exec_interactive() { + local container="$_SHELL_CONTAINER_ID" workdir="$_SHELL_WORKDIR" + (($# > 0)) || { + err "Interactive shell context command is required" + return 64 + } + if [[ -n "$workdir" ]]; then + _container_exec_interactive_argv "$container" --workdir "$workdir" -- "$@" + else + _container_exec_interactive_argv "$container" -- "$@" + fi +} + +declare -a _SHELL_MENU_KIND=() +declare -a _SHELL_MENU_NAME=() +declare -a _SHELL_MENU_SELECTOR=() + +_shell_menu_reset() { + _SHELL_MENU_KIND=() + _SHELL_MENU_NAME=() + _SHELL_MENU_SELECTOR=() +} + +_shell_menu_add() { + local kind="${1:-}" name="${2:-}" selector="${3:-}" + [[ -n "$kind" && -n "$name" && -n "$selector" ]] || return 1 + _SHELL_MENU_KIND+=("$kind") + _SHELL_MENU_NAME+=("$name") + _SHELL_MENU_SELECTOR+=("$selector") +} + +_shell_app_list() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || return 0 + + _container_docker exec "$ctr" sh -c ' + for path in /app/*; do + [ -d "$path" ] || continue + basename "$path" + done + ' 2>/dev/null | + sed '/^[[:space:]]*$/d' | + LC_ALL=C sort -u +} + +_shell_service_list() { + docker_compose config --services 2>/dev/null | + sed '/^[[:space:]]*$/d' | + LC_ALL=C sort -u +} + +_shell_container_list() { + _container_docker ps --format '{{.Names}}' 2>/dev/null | + sed '/^[[:space:]]*$/d' | + LC_ALL=C sort -u +} + +_shell_menu_build() { + _shell_menu_reset + local item + + while IFS= read -r item; do + [[ -n "$item" ]] && _shell_menu_add domain "$item" "domain:$item" + done < <(_core_domain_list 2>/dev/null || true) + + while IFS= read -r item; do + [[ -n "$item" ]] && _shell_menu_add app "$item" "app:$item" + done < <(_shell_app_list 2>/dev/null || true) + + while IFS= read -r item; do + [[ -n "$item" ]] && _shell_menu_add service "$item" "service:$item" + done < <(_shell_service_list 2>/dev/null || true) + + while IFS= read -r item; do + [[ -n "$item" ]] && _shell_menu_add container "$item" "container:$item" + done < <(_shell_container_list 2>/dev/null || true) + + if [[ -n "$(_project_tools_container_running || true)" ]]; then + _shell_menu_add utility tools utility:tools + fi +} + +_shell_menu_label() { + case "${1:-}" in + domain) printf '%s' 'Applications / Domains' ;; + app) printf '%s' 'Application Directories' ;; + service) printf '%s' 'Services' ;; + container) printf '%s' 'Containers' ;; + utility) printf '%s' 'Utilities' ;; + *) printf '%s' 'Other' ;; + esac +} - 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" +_shell_menu_print() { + local previous='' kind label i + for ((i = 0; i < ${#_SHELL_MENU_NAME[@]}; i++)); do + kind="${_SHELL_MENU_KIND[$i]}" + if [[ "$kind" != "$previous" ]]; then + label="$(_shell_menu_label "$kind")" + [[ -z "$previous" ]] || printf '\n' >&2 + printf '%b%s%b\n' "$CYAN" "$label" "$NC" >&2 + previous="$kind" + fi + printf ' %2d) %s\n' "$((i + 1))" "${_SHELL_MENU_NAME[$i]}" >&2 + done +} + +_shell_selector_is_tty() { + [[ -t 0 ]] +} - # Node apps should always land at /app. Others follow resolved docroot. - if [[ "${app:-}" == "node" ]]; then - wd="/app" +_shell_menu_match_name() { + local answer="${1:-}" i count=0 match='' + for ((i = 0; i < ${#_SHELL_MENU_NAME[@]}; i++)); do + if [[ "${_SHELL_MENU_NAME[$i]}" == "$answer" ]]; then + ((count += 1)) + match="${_SHELL_MENU_SELECTOR[$i]}" fi - [[ -n "${wd:-}" ]] || wd="/app" + done - docker exec -it "$container" bash -lc "cd \"$wd\" 2>/dev/null || cd /app 2>/dev/null || cd /; exec bash" + if ((count == 1)); then + printf '%s' "$match" return 0 fi + if ((count > 1)); then + printf '%bAmbiguous name:%b %s\n' "$YELLOW" "$NC" "$answer" >&2 + printf 'Use one of:\n' >&2 + for ((i = 0; i < ${#_SHELL_MENU_NAME[@]}; i++)); do + [[ "${_SHELL_MENU_NAME[$i]}" == "$answer" ]] && + printf ' %s\n' "${_SHELL_MENU_SELECTOR[$i]}" >&2 + done + return 65 + fi + return 66 +} + +_shell_choose_target() { + _shell_menu_build + (("${#_SHELL_MENU_NAME[@]}" > 0)) || { + err "No shell targets are available" + return 66 + } + + _shell_menu_print + + if ! _shell_selector_is_tty; then + err "No TTY to prompt. Use: lds shell " + return 64 + fi + + local answer='' i selector rc + while true; do + read -r -p "Enter number or name: " answer + answer="${answer#"${answer%%[![:space:]]*}"}" + answer="${answer%"${answer##*[![:space:]]}"}" + + if [[ "$answer" =~ ^[0-9]+$ ]]; then + if ((answer >= 1 && answer <= ${#_SHELL_MENU_SELECTOR[@]})); then + printf '%s' "${_SHELL_MENU_SELECTOR[$((answer - 1))]}" + return 0 + fi + printf '%bOut of range.%b\n' "$YELLOW" "$NC" >&2 + continue + fi + + for ((i = 0; i < ${#_SHELL_MENU_SELECTOR[@]}; i++)); do + if [[ "${_SHELL_MENU_SELECTOR[$i]}" == "$answer" ]]; then + printf '%s' "$answer" + return 0 + fi + done + + if selector="$(_shell_menu_match_name "$answer")"; then + printf '%s' "$selector" + return 0 + else + rc=$? + fi + ((rc == 65)) && continue + + printf '%bUnknown target.%b %s\n' "$YELLOW" "$NC" "$answer" >&2 + done +} + +cmd_shell() { + local target="${1:-}" + if [[ -z "$target" ]]; then + target="$(_shell_choose_target)" || return $? + else + shift || true + fi + + _shell_resolve_target "$target" || return $? + + if (($# == 0)); then + _shell_context_open + return $? + fi + + case "${1:-}" in + --) + shift + (($# > 0)) || { + err "Usage: lds shell -- [args...]" + return 64 + } + _shell_context_exec_argv "$@" + ;; + --shell) + shift + (($# == 1)) || { + err "Usage: lds shell --shell " + return 64 + } + _shell_context_exec_expression "$1" + ;; + --interactive | -i) + shift + (($# > 0)) || { + err "Usage: lds shell --interactive [args...]" + return 64 + } + _shell_context_exec_interactive "$@" + ;; + *) + _shell_context_exec_argv "$@" + ;; + esac +} + +cmd_core() { + local target="${1:-}" + [[ -n "$target" ]] && shift || true + + if [[ -z "$target" ]]; then + target="$(_core_choose_domain)" || return $? + fi + + [[ "${1:-}" == -- ]] && shift + + if _core_is_domain "$target"; then + _shell_context_reset + _shell_resolve_domain "$target" || return $? + else + _shell_resolve_service_or_container "$target" || return $? + 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 (($# > 0)); then + _shell_context_exec_argv "$@" + else + _shell_context_open + fi } cmd_setup() { diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index 140b287d..fc47583c 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -10,11 +10,18 @@ assert_contains "$help_output" "LocalDevStack" assert_contains "$help_output" "Stack" assert_contains "$help_output" "Domain" assert_contains "$help_output" "Setup" +assert_contains "$help_output" "Execution / Shells" +assert_contains "$help_output" "Generic service/container exec" +assert_contains "$help_output" "Compose-service only" +assert_contains "$help_output" "server-tools only" pass "lds help" markdown_output="$("$ROOT/lds" help --markdown)" assert_contains "$markdown_output" "# LocalDevStack" assert_contains "$markdown_output" "lds stack up" +assert_contains "$markdown_output" "lds core [domain|service|container]" +assert_contains "$markdown_output" "lds cli " +assert_contains "$markdown_output" "argv-preserving execution" pass "lds markdown help" global_help="$("$ROOT/lds" --help)" @@ -42,6 +49,24 @@ assert_contains "$help_output" "graphify [path]" assert_contains "$markdown_output" "lds graphify" pass "Graphify workflow help" + +assert_file_contains "$ROOT/lds" '_is_public_lds_command()' +if grep -Fq 'declare -F "cmd_$cmd"' "$ROOT/lds"; then + fail "top-level dispatch still exposes arbitrary cmd_* functions dynamically" +fi +assert_file_contains "$ROOT/lds" 'stack|domain|support|bundle|up|start' +assert_file_contains "$ROOT/lds" 'tools|cli|core|shell|graphify|secrets|rebuild|run)' +pass "top-level LDS command routing is explicit and collision-safe" + +if PATH="$tmpbin:$PATH" "$ROOT/lds" graphify --help 2>&1 | grep -Fq 'SERVER_TOOLS is not running'; then + fail "top-level graphify incorrectly fell through to tool-runner" +fi +pass "top-level Graphify remains a host-side command" + +assert_contains "$help_output" "shell [target]" +assert_contains "$markdown_output" "lds shell " +pass "unified shell is exposed in embedded help" + graphify_log="$(mktemp)" cat >"$tmpbin/graphify" <<'SH' #!/usr/bin/env sh diff --git a/tests/container-exec-contract.sh b/tests/container-exec-contract.sh new file mode 100755 index 00000000..94e749e5 --- /dev/null +++ b/tests/container-exec-contract.sh @@ -0,0 +1,280 @@ +#!/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" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +log="$tmp/container-exec.log" + +err() { + printf 'err:%s\n' "$*" >>"$log" +} + +docker_compose() { + case "${1:-} ${2:-}" in + "config --services") + printf '%s\n' php84 nginx multi + return 0 + ;; + esac + + if [[ "${1:-}" == ps && "${2:-}" == -a && "${3:-}" == -q ]]; then + case "${4:-}" in + php84) printf '%s\n' cid-php84 ;; + nginx) printf '%s\n' cid-nginx ;; + multi) printf '%s\n' cid-one cid-two ;; + esac + return 0 + fi + + return 1 +} + +docker() { + if [[ "${1:-}" == inspect ]]; then + if [[ "${2:-}" == -f ]]; then + case "${2:-}|${3:-}|${4:-}" in + "-f|{{.Name}}|cid-php84") printf '%s\n' /PHP84 ;; + "-f|{{.Name}}|cid-nginx") printf '%s\n' /NGINX ;; + "-f|{{.Name}}|cid-one") printf '%s\n' /ONE ;; + "-f|{{.Name}}|cid-two") printf '%s\n' /TWO ;; + "-f|{{.Name}}|cid-custom") printf '%s\n' /MixedCaseContainer ;; + "-f|{{.Id}}|custom") printf '%s\n' cid-custom ;; + "-f|{{.Id}}|stopped") printf '%s\n' cid-stopped ;; + "-f|{{.Name}}|cid-stopped") printf '%s\n' /StoppedContainer ;; + "-f|{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-custom") printf '%s\n' external-service ;; + "-f|{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-stopped") printf '\n' ;; + "-f|{{.State.Running}}|cid-php84") printf '%s\n' true ;; + "-f|{{.State.Running}}|cid-nginx") printf '%s\n' true ;; + "-f|{{.State.Running}}|cid-custom") printf '%s\n' true ;; + "-f|{{.State.Running}}|cid-stopped") printf '%s\n' false ;; + *) return 1 ;; + esac + return 0 + fi + fi + + if [[ "${1:-}" == exec ]]; then + printf 'exec:' >>"$log" + printf ' <%s>' "$@" >>"$log" + printf '\n' >>"$log" + + case " $* " in + *" cid-php84 sh -lc command -v bash >/dev/null 2>&1 "*) return 0 ;; + *" cid-nginx sh -lc command -v bash >/dev/null 2>&1 "*) return 1 ;; + *" cid-custom sh -lc command -v bash >/dev/null 2>&1 "*) return 0 ;; + esac + return 0 + fi + + return 1 +} + +# shellcheck source=lib/container-exec.sh +source "$ROOT/lib/container-exec.sh" + +_container_resolve_target php84 +[[ "$_CONTAINER_TARGET_KIND" == service ]] || fail "service target kind drifted" +[[ "$_CONTAINER_TARGET_SERVICE" == php84 ]] || fail "service name drifted" +[[ "$_CONTAINER_TARGET_ID" == cid-php84 ]] || fail "service id drifted" +[[ "$_CONTAINER_TARGET_NAME" == PHP84 ]] || fail "service container name drifted" +pass "shared resolver prefers current-project Compose services" + +_container_resolve_target custom +[[ "$_CONTAINER_TARGET_KIND" == container ]] || fail "explicit container target kind drifted" +[[ "$_CONTAINER_TARGET_ID" == cid-custom ]] || fail "explicit container id drifted" +[[ "$_CONTAINER_TARGET_NAME" == MixedCaseContainer ]] || fail "explicit container case was not preserved" +[[ "$_CONTAINER_TARGET_SERVICE" == external-service ]] || fail "explicit container service label drifted" +pass "shared resolver preserves exact explicit container targets" + +set +e +_container_resolve_target multi >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 65 ]] || fail "ambiguous service returned $rc instead of 65" +assert_file_contains "$log" 'err:Service resolves to multiple containers: multi' +pass "shared resolver rejects ambiguous Compose services" + +set +e +_container_resolve_target missing >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "missing target returned $rc instead of 66" +assert_file_contains "$log" 'err:Container or current-project service not found: missing' +pass "shared resolver reports missing targets" + +_container_resolve_target stopped +set +e +_container_require_running "$_CONTAINER_TARGET_ID" >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 69 ]] || fail "stopped target returned $rc instead of 69" +assert_file_contains "$log" 'err:Container is not running: cid-stopped' +pass "shared execution validates running state" + +_container_stdin_is_tty() { return 0; } +_container_stdout_is_tty() { return 0; } +_container_stdin_has_data() { return 0; } +_container_exec_flags command +[[ "${_CONTAINER_EXEC_FLAGS[*]}" == "-it" ]] || fail "interactive command flags drifted: ${_CONTAINER_EXEC_FLAGS[*]}" +pass "interactive command gets stdin and TTY" + +_container_stdin_is_tty() { return 1; } +_container_stdout_is_tty() { return 1; } +_container_stdin_has_data() { return 0; } +_container_exec_flags command +[[ "${_CONTAINER_EXEC_FLAGS[*]}" == "-i" ]] || fail "piped command flags drifted: ${_CONTAINER_EXEC_FLAGS[*]}" +pass "piped command keeps stdin without forcing TTY" + +_container_stdin_is_tty() { return 1; } +_container_stdout_is_tty() { return 1; } +_container_stdin_has_data() { return 1; } +_container_exec_flags command +[[ "${#_CONTAINER_EXEC_FLAGS[@]}" -eq 0 ]] || fail "non-interactive no-input command received unnecessary flags" +pass "non-interactive command without stdin gets no TTY flags" + +_container_stdin_is_tty() { return 1; } +_container_stdout_is_tty() { return 1; } +set +e +_container_exec_flags shell >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "non-TTY interactive shell returned $rc instead of 64" +pass "interactive shell rejects non-TTY invocation" + +_container_stdin_is_tty() { return 0; } +_container_stdout_is_tty() { return 0; } +_container_exec_flags shell +[[ "${_CONTAINER_EXEC_FLAGS[*]}" == "-it" ]] || fail "interactive shell flags drifted" +pass "interactive shell receives stdin and TTY when available" + +# Explicit argv must remain separate Docker arguments, including spaces and shell metacharacters. +: >"$log" +_container_stdin_is_tty() { return 1; } +_container_stdout_is_tty() { return 1; } +_container_stdin_has_data() { return 1; } +_container_exec_argv cid-custom --workdir '/app path' -- printf '%s|%s' 'hello world' '$(danger)' +assert_file_contains "$log" 'exec: <--workdir> <%s|%s> <$(danger)>' +pass "shared executor preserves argv and working directory without host interpolation" + +: >"$log" +_container_stdin_is_tty() { return 0; } +_container_stdout_is_tty() { return 0; } +_container_exec_interactive_argv cid-custom --workdir '/app path' -- lazydocker --debug +assert_file_contains "$log" 'exec: <-it> <--workdir> <--debug>' +pass "shared interactive argv helper requires and preserves TTY execution" + +: >"$log" +_container_open_shell cid-php84 --workdir /app +assert_file_contains "$log" 'exec: <-it> <--workdir> <--login>' +pass "shared shell helper prefers Bash and supports Docker workdir" + + +: >"$log" +_container_open_shell cid-nginx +assert_file_contains "$log" 'exec: <-it> ' +pass "shared shell helper falls back to sh" + +: >"$log" +docker() { + if [[ "${1:-}" == inspect && "${2:-}" == -f && "${3:-}" == '{{.State.Running}}' ]]; then + printf '%s\n' true + return 0 + fi + if [[ "${1:-}" == exec && "${2:-}" == cid-php84 && "${3:-}" == test && "${4:-}" == -d ]]; then + case "${5:-}" in + /missing) return 1 ;; + /app) return 0 ;; + esac + fi + return 0 +} +resolved_dir="$(_container_first_existing_dir cid-php84 /missing /app /)" +[[ "$resolved_dir" == /app ]] || fail "workdir fallback resolved '$resolved_dir' instead of /app" +pass "shared workdir resolver selects the first existing container directory" + + +# Cross-platform wrapper behavior is isolated in subshells so environment +# overrides do not affect the Linux contracts above. +msys_log="$tmp/msys.log" +( + set -euo pipefail + err() { :; } + docker() { + printf 'env:%s:%s args:' "${MSYS_NO_PATHCONV:-}" "${MSYS2_ARG_CONV_EXCL:-}" >>"$msys_log" + printf ' <%s>' "$@" >>"$msys_log" + printf '\n' >>"$msys_log" + if [[ "${1:-}" == inspect && "${2:-}" == -f && "${3:-}" == '{{.State.Running}}' ]]; then + printf '%s\n' true + fi + } + # shellcheck source=lib/container-exec.sh + source "$ROOT/lib/container-exec.sh" + export MSYSTEM=MINGW64 + export OSTYPE=msys + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + _container_exec_argv cid-win --workdir '/app/path with spaces' -- printf '%s' '$(danger)' +) +assert_file_contains "$msys_log" 'env:1:* args: <--workdir> <%s> <$(danger)>' +pass "Git Bash/MSYS disables Docker path conversion and preserves argv" + +linux_log="$tmp/linux.log" +( + set -euo pipefail + err() { :; } + docker() { + printf 'env:%s:%s args:' "${MSYS_NO_PATHCONV:-}" "${MSYS2_ARG_CONV_EXCL:-}" >>"$linux_log" + printf ' <%s>' "$@" >>"$linux_log" + printf '\n' >>"$linux_log" + if [[ "${1:-}" == inspect && "${2:-}" == -f && "${3:-}" == '{{.State.Running}}' ]]; then + printf '%s\n' true + fi + } + # shellcheck source=lib/container-exec.sh + source "$ROOT/lib/container-exec.sh" + unset MSYSTEM CYGWIN + export OSTYPE=linux-gnu + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + _container_exec_argv cid-linux -- echo ok +) +assert_file_contains "$linux_log" 'env:: args: ' +pass "Linux/WSL path keeps native Docker argument behavior" + +signal_log="$tmp/signal.log" +( + set -euo pipefail + err() { :; } + docker() { + if [[ "${1:-}" == inspect && "${2:-}" == -f && "${3:-}" == '{{.State.Running}}' ]]; then + printf '%s\n' true + return 0 + fi + if [[ "${1:-}" == exec ]]; then + return 130 + fi + return 0 + } + # shellcheck source=lib/container-exec.sh + source "$ROOT/lib/container-exec.sh" + unset MSYSTEM CYGWIN + export OSTYPE=linux-gnu + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + set +e + _container_exec_argv cid-signal -- signal-test + rc=$? + set -e + printf '%s\n' "$rc" >"$signal_log" +) +[[ "$(cat "$signal_log")" == 130 ]] || fail "container exec did not propagate exit 130" +pass "shared executor propagates SIGINT-style container exit status" + +printf 'Container execution substrate contract complete.\n' diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 1dbb172a..f3411e88 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 @@ -44,6 +43,14 @@ assert_file_contains "$ops" 'lds support bundle' assert_file_contains "$ops" 'lds clean --global --yes' assert_file_contains "$runner" 'lds run --sock' assert_file_contains "$notify" 'Windows/Git Bash' +assert_file_contains "$arch" 'lds shell' +assert_file_contains "$arch" 'utility:tools' +assert_file_contains "$quick" 'lds shell' +assert_file_contains "$domain" 'lds shell project.localhost' +assert_file_contains "$domain" 'lds shell service:php84' +assert_file_contains "$ops" 'lds shell service:nginx -- nginx -t' +assert_file_contains "$secrets" 'lds shell tools' +assert_file_contains "$databases" 'lds shell service:postgres' pass "docs describe current architecture, operations, and complete user surfaces" assert_file_contains "$profiles" 'docker/release.env' @@ -81,9 +88,14 @@ assert_file_contains "$ai" 'docker/compose/companion.yaml' assert_file_contains "$ai" 'docker/.runtime/' assert_file_contains "$ai" 'lds llm runtime npu' assert_file_contains "$ai" 'lds logs llm' +assert_file_contains "$ai" 'lds shell service:llm-fastflow' +assert_file_contains "$ai" 'lds shell service:llm-ollama' assert_file_contains "$cli" 'lds restart llm' assert_file_contains "$cli" 'lds exec llm' assert_file_contains "$cli" 'lds rebuild llm' +assert_file_contains "$cli" 'does not rewrite logical' +assert_file_contains "$cli" 'service:llm-fastflow' +assert_file_contains "$cli" 'service:llm-ollama' assert_file_contains "$ai" 'backend=lds-fastflow' assert_file_contains "$ai" 'backend=lds-ollama' assert_file_contains "$ai" 'extra_body={"think": false}' @@ -145,11 +157,39 @@ 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 shell [--] [args...]' 'lds shell --shell ' 'lds cli [--] [command...]' 'lds core [domain|service|container] [--] [command...]' 'lds stack exec [--] [command...]' 'lds tools exec [--] [args...]' '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" 'Execution and Shells' +assert_file_contains "$cli" 'lds shell [--] [args...]' +assert_file_contains "$cli" 'Applications' +assert_file_contains "$cli" 'domain:project.localhost' +assert_file_contains "$cli" 'utility:tools' +assert_file_contains "$cli" 'Image' +assert_file_contains "$cli" 'lds core [domain|service|container] [--] [command...]' +assert_file_contains "$cli" 'lds cli [--] [command...]' +assert_file_contains "$cli" 'lds stack exec [--] [command...]' +assert_file_contains "$cli" 'lds tools exec [--] [args...]' +assert_file_contains "$cli" 'lds tools shell-exec ' +assert_file_contains "$cli" 'preserve argv exactly' +assert_file_contains "$cli" 'require a real TTY' +assert_file_contains "$readme" '## Execution and shells' +assert_file_contains "$readme" 'lds shell' +assert_file_contains "$readme" 'lds shell project.localhost -- php artisan about' +assert_file_contains "$readme" 'lds shell php84 -- php -v' +assert_file_contains "$readme" 'lds shell tools --interactive lazydocker' +assert_file_contains "$readme" 'utility:tools' +assert_contains "$help_md" 'utility:tools' +if grep -Fq 'lds core project.localhost' "$domain"; then + fail "domain guide still teaches lds core as the primary domain shell" +fi +if grep -Fq 'lds cli ' "$domain"; then + fail "domain guide still teaches lds cli as the primary container shell" +fi +pass "execution-surface docs match the shared Core/CLI contract" + 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 +199,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 100755 index 00000000..0aa45635 --- /dev/null +++ b/tests/execution-contract.sh @@ -0,0 +1,747 @@ +#!/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" + +# This contract evolves batch-by-batch. Assertions for a surface are updated +# only when that surface deliberately migrates onto the shared executor. + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +log="$tmp/execution.log" + +run_case() { + : >"$log" + ( + set -euo pipefail + export EXECUTION_TEST_LOG="$log" + export CYAN='' YELLOW='' RED='' GREEN='' NC='' QUIET=0 VERBOSE=0 + + die() { printf 'die:%s\n' "$*" >>"$EXECUTION_TEST_LOG"; return 64; } + err() { printf 'err:%s\n' "$*" >>"$EXECUTION_TEST_LOG"; } + lds_project() { printf '%s' testproject; } + effective_ai_runtime() { printf '%s' cpu; } + ai_service_for_runtime() { printf '%s' llm-ollama; } + _project_tools_container_running() { printf '%s' SERVER_TOOLS; } + + docker_compose() { + printf 'compose:' >>"$EXECUTION_TEST_LOG" + printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG" + printf '\n' >>"$EXECUTION_TEST_LOG" + + if [[ "${1:-}" == config && "${2:-}" == --services ]]; then + printf '%s\n' php84 node multi + return 0 + fi + if [[ "${1:-}" == ps && "${2:-}" == -a && "${3:-}" == -q ]]; then + case "${4:-}" in + php84) printf '%s\n' cid-php84 ;; + node) printf '%s\n' cid-node ;; + multi) printf '%s\n' cid-one cid-two ;; + esac + return 0 + fi + return 0 + } + + docker() { + printf 'docker:' >>"$EXECUTION_TEST_LOG" + printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG" + printf '\n' >>"$EXECUTION_TEST_LOG" + + if [[ "${1:-}" == ps && "${2:-}" == --format ]]; then + printf '%s\n' demo-container external-worker worker.local + return 0 + fi + + if [[ "${1:-}" == inspect && "${2:-}" == -f ]]; then + case "${3:-}|${4:-}" in + "{{.Id}}|demo-container") printf '%s\n' cid-demo ;; + "{{.Id}}|mixedCase-container") printf '%s\n' cid-mixed ;; + "{{.Id}}|worker.local") printf '%s\n' cid-domainlike ;; + "{{.Id}}|NODE") printf '%s\n' cid-node ;; + "{{.Id}}|node") printf '%s\n' cid-node-container ;; + "{{.Id}}|stopped-container") printf '%s\n' cid-stopped ;; + "{{.Id}}|"*) return 1 ;; + "{{.Name}}|cid-demo") printf '%s\n' /demo-container ;; + "{{.Name}}|cid-mixed") printf '%s\n' /mixedCase-container ;; + "{{.Name}}|cid-domainlike") printf '%s\n' /worker.local ;; + "{{.Name}}|cid-node") printf '%s\n' /NODE ;; + "{{.Name}}|cid-node-container") printf '%s\n' /node ;; + "{{.Name}}|cid-php84") printf '%s\n' /PHP84 ;; + "{{.Name}}|cid-one") printf '%s\n' /ONE ;; + "{{.Name}}|cid-two") printf '%s\n' /TWO ;; + "{{.Name}}|cid-stopped") printf '%s\n' /stopped-container ;; + "{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-demo") printf '\n' ;; + "{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-mixed") printf '\n' ;; + "{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-domainlike") printf '\n' ;; + "{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-node") printf '%s\n' node ;; + "{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-node-container") printf '%s\n' external-node ;; + "{{ index .Config.Labels \"com.docker.compose.service\" }}|cid-stopped") printf '\n' ;; + "{{.State.Running}}|cid-demo") printf '%s\n' true ;; + "{{.State.Running}}|cid-mixed") printf '%s\n' true ;; + "{{.State.Running}}|cid-domainlike") printf '%s\n' true ;; + "{{.State.Running}}|cid-node") printf '%s\n' true ;; + "{{.State.Running}}|cid-php84") printf '%s\n' true ;; + "{{.State.Running}}|cid-stopped") printf '%s\n' false ;; + "{{.State.Running}}|demo-container") printf '%s\n' true ;; + "{{.State.Running}}|mixedCase-container") printf '%s\n' true ;; + "{{.State.Running}}|worker.local") printf '%s\n' true ;; + "{{.State.Running}}|stopped-container") printf '%s\n' false ;; + "{{.State.Running}}|node") printf '%s\n' true ;; + "{{.State.Running}}|cid-node-container") printf '%s\n' true ;; + "{{.State.Running}}|SERVER_TOOLS") printf '%s\n' true ;; + "{{.State.Running}}|billing"|"{{.State.Running}}|missing-shell"|"{{.State.Running}}|php:8.4-alpine") return 1 ;; + "{{.State.Running}}|"*) printf '%s\n' true ;; + esac + return 0 + fi + + if [[ "${1:-}" == inspect && "${2:-}" != -f ]]; then + return 0 + fi + + if [[ "${1:-}" == exec ]]; then + if [[ "${2:-}" == SERVER_TOOLS && "${3:-}" == sh && "${4:-}" == -c ]]; then + case "${7:-}" in + /app/billing) return 0 ;; + /app/missing-shell|/app/php:8.4-alpine) return 1 ;; + esac + if [[ "${5:-}" == *'for path in /app/'* ]]; then + printf '%s\n' billing node + return 0 + fi + fi + if [[ " $* " == *" signal-test "* ]]; then + return 130 + fi + case " $* " in + *" domain-which --list-domains "*) + printf '%s\n' app.local php.local fallback.local + ;; + *" domain-which --app --quiet app.local "*) + printf '%s\n' node + ;; + *" domain-which --container --quiet app.local "*) + printf '%s\n' NODE + ;; + *" domain-which --docroot --quiet app.local "*) + printf '%s\n' /srv/app/public + ;; + *" domain-which --app --quiet php.local "*) + printf '%s\n' php + ;; + *" domain-which --container --quiet php.local "*) + printf '%s\n' php84 + ;; + *" domain-which --docroot --quiet php.local "*) + printf '%s\n' /srv/php/public + ;; + *" domain-which --app --quiet fallback.local "*) + printf '%s\n' php + ;; + *" domain-which --container --quiet fallback.local "*) + printf '%s\n' php84 + ;; + *" domain-which --docroot --quiet fallback.local "*) + printf '%s\n' /missing/docroot + ;; + *" cid-node test -d /app "*) + return 0 + ;; + *" cid-php84 test -d /srv/php/public "*) + return 0 + ;; + *" cid-php84 test -d /missing/docroot "*) + return 1 + ;; + *" cid-php84 test -d /app "*) + return 0 + ;; + *" cid-demo sh -lc command -v bash >/dev/null 2>&1 "*|*" cid-mixed sh -lc command -v bash >/dev/null 2>&1 "*|*" cid-node sh -lc command -v bash >/dev/null 2>&1 "*|*" cid-php84 sh -lc command -v bash >/dev/null 2>&1 "*) + return 0 + ;; + esac + return 0 + fi + } + + # shellcheck source=lib/container-exec.sh + source "$ROOT/lib/container-exec.sh" + # shellcheck source=lib/services.sh + source "$ROOT/lib/services.sh" + "$@" + ) +} + +force_interactive_tty() { + _container_stdin_is_tty() { return 0; } + _container_stdout_is_tty() { return 0; } +} + +case_shell_resolve_domain() { + _shell_resolve_target app.local + printf 'shell-context:%s|%s|%s|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_DOMAIN" "$_SHELL_APP" "$_SHELL_CONTAINER_ID" "$_SHELL_WORKDIR" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_resolve_domain +assert_file_contains "$log" 'shell-context:domain|app.local|node|cid-node|/app' +pass "shell batch 1: exact discovered domain resolves application container and cwd" + +case_shell_resolve_tools() { + _shell_resolve_target tools + printf 'shell-context:%s|%s|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_SERVICE" "$_SHELL_CONTAINER_ID" "$_SHELL_WORKDIR" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_resolve_tools +assert_file_contains "$log" 'shell-context:tools|server-tools|SERVER_TOOLS|' +pass "shell batch 1: reserved tools target resolves server-tools" + +case_shell_resolve_service() { + _shell_resolve_target php84 + printf 'shell-context:%s|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_SERVICE" "$_SHELL_CONTAINER_ID" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_resolve_service +assert_file_contains "$log" 'shell-context:service|php84|cid-php84' +pass "shell batch 1: exact current-project service resolves before container fallback" + +case_shell_resolve_container() { + _shell_resolve_target demo-container + printf 'shell-context:%s|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_CONTAINER_ID" "$_SHELL_CONTAINER_NAME" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_resolve_container +assert_file_contains "$log" 'shell-context:container|cid-demo|demo-container' +pass "shell batch 1: exact container resolves without case rewriting" + +case_shell_resolve_app() { + _shell_resolve_target billing + printf 'shell-context:%s|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_CONTAINER_ID" "$_SHELL_WORKDIR" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_resolve_app +assert_file_contains "$log" 'shell-context:app|SERVER_TOOLS|/app/billing' +pass "shell batch 1: unresolved target falls back to direct server-tools /app child" + +case_shell_qualified_targets() { + _shell_resolve_target domain:php.local + printf 'qualified:%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_DOMAIN" >>"$EXECUTION_TEST_LOG" + _shell_resolve_target service:node + printf 'qualified:%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_SERVICE" >>"$EXECUTION_TEST_LOG" + _shell_resolve_target container:mixedCase-container + printf 'qualified:%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_CONTAINER_NAME" >>"$EXECUTION_TEST_LOG" + _shell_resolve_target app:billing + printf 'qualified:%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_WORKDIR" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_qualified_targets +assert_file_contains "$log" 'qualified:domain|php.local' +assert_file_contains "$log" 'qualified:service|node' +assert_file_contains "$log" 'qualified:container|mixedCase-container' +assert_file_contains "$log" 'qualified:app|/app/billing' +pass "shell batch 1: qualified selectors bypass normal precedence" + +set +e +run_case _shell_resolve_target app:../escape >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "shell app traversal returned $rc instead of 64" +pass "shell batch 1: /app fallback rejects path traversal" + +set +e +run_case _shell_resolve_target missing-shell >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "missing shell target returned $rc instead of 66" +assert_file_contains "$log" 'err:Shell target not found: missing-shell' +pass "shell batch 1: unresolved targets return one final actionable not-found error" + +set +e +run_case _shell_resolve_target php:8.4-alpine >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "image-like shell target returned $rc instead of 66" +if grep -Fq '' "$log"; then + fail "lds shell resolver attempted to instantiate an image" +fi +pass "shell batch 1: image-like targets are not implicitly instantiated" + +case_shell_menu_build() { + _shell_menu_build + local i + for ((i = 0; i < ${#_SHELL_MENU_NAME[@]}; i++)); do + printf 'menu:%s|%s|%s\n' "${_SHELL_MENU_KIND[$i]}" "${_SHELL_MENU_NAME[$i]}" "${_SHELL_MENU_SELECTOR[$i]}" >>"$EXECUTION_TEST_LOG" + done +} +run_case case_shell_menu_build +assert_file_contains "$log" 'menu:domain|app.local|domain:app.local' +assert_file_contains "$log" 'menu:app|billing|app:billing' +assert_file_contains "$log" 'menu:service|php84|service:php84' +assert_file_contains "$log" 'menu:container|demo-container|container:demo-container' +assert_file_contains "$log" 'menu:utility|tools|utility:tools' +pass "shell batch 2: grouped catalog includes domains apps services containers and tools" + +case_shell_choose_number() { + _shell_selector_is_tty() { return 0; } + local choice + choice="$(printf '4\n' | _shell_choose_target)" + printf 'choice:%s\n' "$choice" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_choose_number +assert_file_contains "$log" 'choice:app:billing' +pass "shell batch 2: global numeric selector resolves across grouped categories" + +case_shell_choose_name() { + _shell_selector_is_tty() { return 0; } + local choice + choice="$(printf 'billing\n' | _shell_choose_target)" + printf 'choice:%s\n' "$choice" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_choose_name +assert_file_contains "$log" 'choice:app:billing' +pass "shell batch 2: exact unique name selector resolves category target" + +case_shell_choose_ambiguous_name() { + _shell_selector_is_tty() { return 0; } + local choice + choice="$(printf 'node\nservice:node\n' | _shell_choose_target)" + printf 'choice:%s\n' "$choice" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_choose_ambiguous_name +assert_file_contains "$log" 'choice:service:node' +pass "shell batch 2: ambiguous names require a qualified selector" + +case_shell_menu_nontty() { + _shell_selector_is_tty() { return 1; } + _shell_choose_target +} +set +e +run_case case_shell_menu_nontty >"$tmp/shell-menu.out" 2>"$tmp/shell-menu.err" +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "bare shell non-TTY selector returned $rc instead of 64" +grep -Fq 'Applications / Domains' "$tmp/shell-menu.err" || fail "bare shell catalog omitted domain heading" +grep -Fq 'Application Directories' "$tmp/shell-menu.err" || fail "bare shell catalog omitted app heading" +grep -Fq 'Services' "$tmp/shell-menu.err" || fail "bare shell catalog omitted services heading" +grep -Fq 'Containers' "$tmp/shell-menu.err" || fail "bare shell catalog omitted containers heading" +grep -Fq 'Utilities' "$tmp/shell-menu.err" || fail "bare shell catalog omitted utilities heading" +assert_file_contains "$log" 'err:No TTY to prompt. Use: lds shell ' +pass "shell batch 2: bare shell prints grouped catalog and fails actionably without TTY" + +case_shell_domain_shell() { + force_interactive_tty + cmd_shell app.local +} +run_case case_shell_domain_shell +assert_file_contains "$log" 'docker: <-it> <--workdir> <--login>' +pass "shell batch 3: domain target opens application-aware interactive shell" + +case_shell_app_shell() { + force_interactive_tty + cmd_shell billing +} +run_case case_shell_app_shell +assert_file_contains "$log" 'docker: <-it> <--workdir> <--login>' +pass "shell batch 3: app-directory fallback opens server-tools at matching /app child" + +case_shell_service_command() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_shell php84 -- php -v +} +run_case case_shell_service_command +assert_file_contains "$log" 'docker: <-v>' +pass "shell batch 3: explicit command preserves argv for service target" + +case_shell_expression() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_shell billing --shell 'printf "%s\n" "hello world" | cat' +} +run_case case_shell_expression +assert_file_contains "$log" 'docker: <--workdir> <-lc> ' +pass "shell batch 3: --shell makes intentional shell parsing explicit" + +case_shell_interactive_command() { + force_interactive_tty + cmd_shell tools --interactive lazydocker +} +run_case case_shell_interactive_command +assert_file_contains "$log" 'docker: <-it> ' +pass "shell batch 3: --interactive routes TUI argv through shared interactive helper" + +case_shell_collision_precedence() { + _shell_resolve_target node + printf 'collision:unqualified|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_CONTAINER_ID" >>"$EXECUTION_TEST_LOG" + _shell_resolve_target container:node + printf 'collision:qualified|%s|%s\n' "$_SHELL_TARGET_KIND" "$_SHELL_CONTAINER_ID" >>"$EXECUTION_TEST_LOG" +} +run_case case_shell_collision_precedence +assert_file_contains "$log" 'collision:unqualified|service|cid-node' +assert_file_contains "$log" 'collision:qualified|container|cid-node-container' +pass "shell batch 6: service precedence and qualified container escape are deterministic" + +case_shell_domainlike_container_command() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_shell worker.local -- echo ok +} +run_case case_shell_domainlike_container_command +assert_file_contains "$log" 'docker: ' +if grep -Fq 'domain-which --app --quiet worker.local' "$log"; then + fail "lds shell guessed hostname-shaped container was a domain" +fi +pass "shell batch 6: domain-like container names stay containers unless discovered" + +case_shell_mixed_case_container() { + force_interactive_tty + cmd_shell mixedCase-container +} +run_case case_shell_mixed_case_container +assert_file_contains "$log" 'docker: <-it> <--login>' +if grep -Fq 'MIXEDCASE-CONTAINER' "$log"; then + fail "lds shell rewrote mixed-case container target" +fi +pass "shell batch 6: exact mixed-case containers are preserved" + +set +e +run_case cmd_shell multi >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 65 ]] || fail "lds shell ambiguous service returned $rc instead of 65" +assert_file_contains "$log" 'err:Service resolves to multiple containers: multi' +pass "shell batch 6: ambiguous service targets preserve exit 65" + +set +e +run_case cmd_shell stopped-container >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 69 ]] || fail "lds shell stopped container returned $rc instead of 69" +assert_file_contains "$log" 'err:Container is not running: cid-stopped' +pass "shell batch 6: stopped target error wins before TTY validation" + +set +e +run_case cmd_shell missing-shell >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "lds shell missing target returned $rc instead of 66" +pass "shell batch 6: missing targets preserve exit 66" + +case_shell_nontty_existing_target() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + cmd_shell demo-container +} +set +e +run_case case_shell_nontty_existing_target >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "lds shell non-TTY interactive target returned $rc instead of 64" +assert_file_contains "$log" 'err:Interactive container session requires a TTY' +pass "shell batch 6: interactive shell requires a real TTY" + +case_shell_signal_exit() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_shell demo-container -- signal-test +} +set +e +run_case case_shell_signal_exit >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 130 ]] || fail "lds shell child exit 130 became $rc" +pass "shell batch 6: child Ctrl-C style exit status propagates unchanged" + +case_cli_command() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_cli demo-container -- printf '%s %s' 'hello world' '$(danger)' +} +run_case case_cli_command +assert_file_contains "$log" 'docker: <-f> <{{.Id}}> ' +assert_file_contains "$log" 'docker: <%s %s> <$(danger)>' +if grep -Fq ' <-lc>' "$log"; then + fail "lds cli explicit command still reparses argv through a shell" +fi +pass "batch 2: lds cli preserves explicit command argv without forcing TTY" + +case_cli_piped_command() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 0; } + cmd_cli demo-container cat +} +run_case case_cli_piped_command +assert_file_contains "$log" 'docker: <-i> ' +pass "batch 2: lds cli keeps piped stdin without allocating TTY" + +case_cli_service() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_cli php84 php -v +} +run_case case_cli_service +assert_file_contains "$log" 'compose: <--services>' +assert_file_contains "$log" 'compose: <-a> <-q> ' +assert_file_contains "$log" 'docker: <-v>' +pass "batch 2: lds cli resolves current-project Compose service names" + +case_cli_shell() { + force_interactive_tty + cmd_cli demo-container +} +run_case case_cli_shell +assert_file_contains "$log" 'docker: <-it> <--login>' +pass "batch 2: lds cli without command opens the shared interactive shell" + +case_cli_shell_nontty() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + cmd_cli demo-container +} +set +e +run_case case_cli_shell_nontty >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "lds cli non-TTY shell returned $rc instead of 64" +assert_file_contains "$log" 'err:Interactive container session requires a TTY' +pass "batch 6: lds cli rejects interactive shell without a TTY" + +set +e +run_case cmd_cli stopped-container >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 69 ]] || fail "lds cli stopped target returned $rc instead of 69" +assert_file_contains "$log" 'err:Container is not running: cid-stopped' +pass "batch 2: lds cli reports stopped targets" + +set +e +run_case cmd_cli missing-container >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "lds cli missing target returned $rc instead of 66" +assert_file_contains "$log" 'err:Container or current-project service not found: missing-container' +pass "batch 2: lds cli reports missing targets" + +set +e +run_case cmd_cli multi >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 65 ]] || fail "lds cli ambiguous service returned $rc instead of 65" +assert_file_contains "$log" 'err:Service resolves to multiple containers: multi' +pass "batch 2: lds cli rejects ambiguous service targets" + +set +e +run_case cmd_cli >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "lds cli missing target returned $rc instead of 64" +assert_file_contains "$log" 'err:Usage: lds cli [--] [command...]' +pass "batch 5: lds cli uses standardized usage exit" + +case_core_node_domain() { + force_interactive_tty + cmd_core app.local +} +run_case case_core_node_domain +assert_file_contains "$log" 'docker: <--app> <--quiet> ' +assert_file_contains "$log" 'docker: <-it> <--workdir> <--login>' +pass "batch 3: lds core resolves Node domains and opens /app through the shared shell helper" + +case_core_php_domain() { + force_interactive_tty + cmd_core php.local +} +run_case case_core_php_domain +assert_file_contains "$log" 'docker: <-it> <--workdir> <--login>' +pass "batch 3: lds core preserves resolved PHP document root" + +case_core_docroot_fallback() { + force_interactive_tty + cmd_core fallback.local +} +run_case case_core_docroot_fallback +assert_file_contains "$log" 'docker: <-d> ' +assert_file_contains "$log" 'docker: <-it> <--workdir> <--login>' +pass "batch 3: lds core falls back from missing docroot to /app" + +case_core_domain_command() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_core app.local -- node -e 'console.log("hello world")' +} +run_case case_core_domain_command +assert_file_contains "$log" 'docker: <--workdir> <-e> ' +pass "batch 3: lds core executes domain commands as exact argv in the application workdir" + +case_core_service() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_core php84 php -v +} +run_case case_core_service +assert_file_contains "$log" 'compose: <-a> <-q> ' +assert_file_contains "$log" 'docker: <-v>' +pass "batch 3: lds core delegates service targets to the shared resolver" + +case_core_container() { + force_interactive_tty + cmd_core mixedCase-container +} +run_case case_core_container +assert_file_contains "$log" 'docker: <-f> <{{.Id}}> ' +assert_file_contains "$log" 'docker: <-it> <--login>' +if grep -Fq 'MIXEDCASE-CONTAINER' "$log"; then + fail "lds core still uppercases explicit container targets" +fi +pass "batch 3: lds core preserves explicit mixed-case container targets" + +case_core_domainlike_container() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_core worker.local -- echo ok +} +run_case case_core_domainlike_container +assert_file_contains "$log" 'docker: <-f> <{{.Id}}> ' +assert_file_contains "$log" 'docker: ' +if grep -Fq 'domain-which --app --quiet worker.local' "$log"; then + fail "lds core guessed a hostname-shaped container was a domain" +fi +pass "batch 3: lds core distinguishes discovered domains from hostname-shaped containers" + +case_core_single_domain() { + force_interactive_tty + _core_domain_list() { printf '%s\n' app.local; } + cmd_core +} +run_case case_core_single_domain +assert_file_contains "$log" 'docker: <-it> <--workdir> <--login>' +pass "batch 3: lds core auto-selects the only discovered domain" + +set +e +run_case cmd_core >"$tmp/core-nontty.out" 2>"$tmp/core-nontty.err" +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "lds core non-TTY multi-domain selection returned $rc instead of 64" +grep -Fq 'app.local' "$tmp/core-nontty.err" || fail "non-TTY domain list omitted app.local" +grep -Fq 'php.local' "$tmp/core-nontty.err" || fail "non-TTY domain list omitted php.local" +assert_file_contains "$log" 'err:No TTY to prompt. Use: lds core ' +pass "batch 3: lds core lists domains and fails actionably without a TTY" + +case_stack_exec_command() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_exec PHP84 -- php -r 'echo "ok";' +} +run_case case_stack_exec_command +assert_file_contains "$log" 'docker: <-r> ' +if grep -Fq 'compose: ' "$log"; then + fail "lds stack exec still bypasses the shared container executor" +fi +pass "batch 4: lds stack exec remains service-only and preserves command argv" + +case_stack_exec_shell() { + force_interactive_tty + cmd_exec PHP84 +} +run_case case_stack_exec_shell +assert_file_contains "$log" 'docker: <-it> <--login>' +pass "batch 4: lds stack exec uses the shared interactive shell helper" + +set +e +run_case cmd_exec >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "lds stack exec missing service returned $rc instead of 64" +assert_file_contains "$log" 'err:Usage: lds stack exec [--] [command...]' + +set +e +run_case cmd_exec missing-service >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "lds stack exec unknown service returned $rc instead of 66" +assert_file_contains "$log" 'err:Current-project service not found: missing-service' +pass "batch 5: lds stack exec uses standardized usage/not-found exits" + +case_tools_exec() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_tools exec -- printf '%s %s' 'hello world' '$(danger)' +} +run_case case_tools_exec +assert_file_contains "$log" 'docker: <%s %s> <$(danger)>' +if grep -Fq ' <-lc> <-lc> ' +pass "batch 4: lds tools shell-exec makes intentional shell parsing explicit" + +case_tools_shell() { + force_interactive_tty + cmd_tools sh +} +run_case case_tools_shell +assert_file_contains "$log" 'docker: <-it> <--login>' +pass "batch 4: lds tools sh uses the shared interactive shell helper" + +set +e +run_case cmd_tools exec >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 64 ]] || fail "lds tools exec missing command returned $rc instead of 64" +assert_file_contains "$log" 'err:Usage: lds tools exec [--] [args...]' + +case_tools_unavailable() { + _project_tools_container_running() { return 1; } + cmd_tools sh +} +set +e +run_case case_tools_unavailable >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -eq 69 ]] || fail "lds tools unavailable container returned $rc instead of 69" +assert_file_contains "$log" 'err:server-tools container is not running for project: testproject' +pass "batch 5: lds tools uses standardized usage/unavailable exits" + +case_tools_file() { + _container_stdin_is_tty() { return 1; } + _container_stdout_is_tty() { return 1; } + _container_stdin_has_data() { return 1; } + cmd_tools file '/app/path with spaces.txt' +} +run_case case_tools_file +assert_file_contains "$log" ' <-lc>' +assert_file_contains "$log" ' ' +pass "batch 4: lds tools file passes paths as shell positional argv rather than interpolating them" + +case_ui_interactive() { + force_interactive_tty + cmd_ui +} +run_case case_ui_interactive +assert_file_contains "$log" 'docker: <-it> ' +pass "batch 4: support ui uses the shared interactive execution helper" + +assert_file_contains "$ROOT/lds" 'exec) cmd_exec "$@" ;;' +assert_file_contains "$ROOT/lds" 'if _is_public_lds_command "$cmd"; then' +assert_file_contains "$ROOT/lds" '"cmd_$cmd" "$@"' +pass "baseline: grouped stack exec and allowlisted top-level dispatch remain wired" + +printf 'Execution contract complete.\n' diff --git a/tests/release-gate.sh b/tests/release-gate.sh index f339012a..1df17821 100755 --- a/tests/release-gate.sh +++ b/tests/release-gate.sh @@ -5,6 +5,8 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" "$ROOT/tests/static.sh" "$ROOT/tests/cli-contract.sh" +"$ROOT/tests/execution-contract.sh" +"$ROOT/tests/container-exec-contract.sh" "$ROOT/tests/env-contract.sh" "$ROOT/tests/catalog-contract.sh" "$ROOT/tests/compose-contract.sh" diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1 index 6ead17b6..6f8dd0ec 100644 --- a/tests/windows-bridge.ps1 +++ b/tests/windows-bridge.ps1 @@ -51,3 +51,30 @@ if (-not $copied.Contains('set "DEVHOME=%~dp0"')) { } Write-Host "PASS: Windows bridge quoting/discovery contract" + +$ldsPath = Join-Path $root "lds" +$execPath = Join-Path $root "lib/container-exec.sh" +if (-not (Test-Path $execPath)) { + throw "shared container execution helper not found" +} +$ldsContent = Get-Content -Raw -Path $ldsPath +$execContent = Get-Content -Raw -Path $execPath +foreach ($needle in @( + 'source "$DIR/lib/container-exec.sh"', + '_is_public_lds_command()' +)) { + if (-not $ldsContent.Contains($needle)) { + throw "lds is missing Core/CLI execution contract: $needle" + } +} +foreach ($needle in @( + 'MSYS_NO_PATHCONV=1', + "MSYS2_ARG_CONV_EXCL='*'", + '_container_exec_argv', + '_container_open_shell' +)) { + if (-not $execContent.Contains($needle)) { + throw "shared executor is missing Windows/Git Bash contract: $needle" + } +} +Write-Host "PASS: Windows bridge uses MSYS-safe shared container execution"