diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1e919c90..02e12285 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -49,7 +49,11 @@ jobs: - name: Container execution substrate contract run: tests/container-exec-contract.sh - + - name: Conversion contracts + run: | + bash tests/document-convert-contract.sh + bash tests/image-convert-contract.sh + bash tests/media-convert-contract.sh - name: Environment contract run: tests/env-contract.sh @@ -117,6 +121,9 @@ jobs: - name: Smoke direct provider on minimum Graphify run: bash tests/graphify-direct-provider-contract.sh + - name: Validate document merge round trip on minimum Graphify + run: bash tests/graphify-roundtrip-contract.sh + - name: Validate latest Graphify contract run: | python -m pip install --upgrade "graphifyy[openai]" @@ -139,6 +146,9 @@ jobs: - name: Smoke direct provider on latest Graphify run: bash tests/graphify-direct-provider-contract.sh + - name: Validate document merge round trip on latest Graphify + run: bash tests/graphify-roundtrip-contract.sh + compose: name: Compose contract @@ -178,6 +188,18 @@ jobs: - name: Validate published compatibility images run: tests/published-images.sh + - name: Smoke host document conversion + run: | + set -euo pipefail + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + mkdir -p "$tmp/input" "$tmp/output" + printf '# LocalDevStack conversion\n\nPandoc-backed host document.\n' >"$tmp/input/source.md" + ./lds convert docs "$tmp/input/source.md" "$tmp/output/source.html" --standalone + grep -Fq '` starts a temporary Tools container with the current host directory at +`/workspace`, shares the running server-tools network/volumes, preserves stdin/TTY, and +inherits the active LDS AI/Git runtime settings. Because `server-tools` owns the Docker +socket and trusted control-plane/secret mounts, this runner is a privileged workstation +context—not a sandbox—and should be used only with trusted commands from the Tools image. +Use `lds tools run ...` when a tool name collides with an LDS `tools` subcommand. +The older `tools sh/exec/file` forms continue to target the long-running control-plane +container. + +## File conversion + +Pandoc is available from the Tools image without installing it on the host or starting +the LocalDevStack services: + +```bash +lds convert docs README.md README.html +lds convert docs docs/guide.rst guide.docx --toc +lds convert docs report.docx report.md --wrap=none +lds convert docs book.md book.epub --toc +lds convert docs --list-input-formats +lds convert docs --list-output-formats +lds convert image photo.jpg photo.webp -- -quality 82 -strip +lds convert image animation.gif animation.webp +lds convert image --formats +lds convert audio recording.wav recording.mp3 +lds convert audio recording.wav recording.ogg -- -c:a libopus -b:a 128k +lds convert video recording.mov recording.mp4 +lds convert video recording.mkv recording.webm -- -c:v libvpx-vp9 -crf 32 -b:v 0 +``` + +Documents use Pandoc, images use ImageMagick, and audio/video use FFmpeg. The input mount is read-only and only the output mount is writable. The +short-lived converter receives no Docker socket or LocalDevStack networks. Existing +outputs require `--force`. PDF generation additionally depends on a PDF engine; the base +Tools image ships Pandoc itself, not a TeX/PDF rendering stack. ## Optional local AI Enable the `ai` profile through `lds setup profile`. @@ -286,7 +341,7 @@ lds graphify ./your-project --mode deep Nginx owns the loopback-only native route `127.0.0.1:11434 -> nginx:11434 -> llm:11434`. Provider containers do not publish host ports. -`lds graphify` keeps the host Graphify CLI on `http://llm.localhost:11434/v1`, with no Graphify proxy service or Python adapter. When the Tools image supports `docstruct`, `.md/.rst/.yaml/.yml/.json/.toml/.ini/.cfg` files are extracted mechanically, optionally reviewed in bounded AI chunks, validated as a Graphify fragment, and merged into a reserved document layer; Graphify continues to own code ASTs and unsupported semantic formats. `LDS_GRAPHIFY_DOCSTRUCT=auto|on|off` and `LDS_GRAPHIFY_DOC_REVIEW=auto|on|off` control the handoff. Explicit `--code-only` remains code-only. +`lds graphify` keeps the host Graphify CLI on `http://llm.localhost:11434/v1`, with no Graphify proxy service or Python adapter. When the Tools image supports `docstruct`, `.md/.rst/.yaml/.yml/.json/.toml/.ini/.cfg` files are extracted mechanically, optionally reviewed in bounded AI chunks, validated as a Graphify fragment, and merged into a staged reserved document layer. Before publication, LocalDevStack runs that staged graph through an isolated LLM-free `graphify cluster-only --no-label --no-viz` round trip and publishes only Graphify's canonical output; Graphify continues to own code ASTs and unsupported semantic formats. Document review retries one malformed structured response once before deterministic fallback. `LDS_GRAPHIFY_DOCSTRUCT=auto|on|off` and `LDS_GRAPHIFY_DOC_REVIEW=auto|on|off` control the handoff. Explicit `--code-only` remains code-only. The built-in Compose layout keeps both provider definitions in `docker/compose/companion.yaml`, but runtime-generated profile selectors enable exactly one. NVIDIA/ROCm hardware augmentation is generated ephemerally under `docker/.runtime/`; FastFlow's `/dev/accel/accel0` + memlock contract lives in its tracked service definition. diff --git a/bin/tool-runner b/bin/tool-runner index fe32d71a..bdb8a8cf 100755 --- a/bin/tool-runner +++ b/bin/tool-runner @@ -63,7 +63,38 @@ server_tools_image() { server_tools_env_value() { local key="${1:?}" "$(bin_path docker)" inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null \ - | awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/, ""); print; exit }' + | awk -F= -v k="$key" ' + $1 == k && !found { + sub(/^[^=]*=/, "") + print + found=1 + } + END { if (!found) exit 1 } + ' +} + +stdin_has_data() { + [[ -p /dev/stdin || -f /dev/stdin ]] +} + +append_server_tools_env() { + local key value + local -n target="${1:?}" + + for key in \ + TZ USERNAME GIT_USER_NAME GIT_USER_EMAIL GIT_CREDENTIAL_MODE \ + LDS_AI_ENABLED LDS_AI_RUNTIME LDS_AI_MODEL LDS_AI_THINK \ + LDS_AI_CONNECT_TIMEOUT LDS_AI_PREFLIGHT_TIMEOUT LDS_AI_TIMEOUT \ + LDS_AI_AVAILABILITY_TTL LDS_AI_MAX_CONTEXT_BYTES \ + LDS_AI_MAX_REQUEST_BYTES LDS_AI_MAX_RESPONSE_BYTES + do + value="$(server_tools_env_value "$key" || true)" + if [[ -n "$value" ]]; then + target+=(-e "$key=$value") + fi + done + + return 0 } resolve_workspace() { @@ -100,15 +131,13 @@ main() { server_tools_running || die "$SERVER_TOOLS_CONTAINER is not running" server_tools_has "$cmd" || die "command '$cmd' not found in $SERVER_TOOLS_CONTAINER" - local image workspace tz username home_dir + local image workspace home_dir image="$(server_tools_image)" || die "unable to inspect $SERVER_TOOLS_CONTAINER image" [[ -n "$image" ]] || die "unable to resolve $SERVER_TOOLS_CONTAINER image" workspace="$(resolve_workspace)" [[ -n "$workspace" ]] || die "unable to resolve workspace" - tz="$(server_tools_env_value TZ || true)" - username="$(server_tools_env_value USERNAME || true)" home_dir="/home/root" local -a flags envs @@ -120,14 +149,15 @@ main() { -e TERM="${TERM:-xterm-256color}" ) - [[ -n "$tz" ]] && envs+=(-e TZ="$tz") - [[ -n "$username" ]] && envs+=(-e USERNAME="$username") + append_server_tools_env envs [[ -n "${COLORTERM:-}" ]] && envs+=(-e COLORTERM="$COLORTERM") [[ -n "${NO_COLOR:-}" ]] && envs+=(-e NO_COLOR="$NO_COLOR") [[ -n "${CLICOLOR_FORCE:-}" ]] && envs+=(-e CLICOLOR_FORCE="$CLICOLOR_FORCE") [[ -n "${FORCE_COLOR:-}" ]] && envs+=(-e FORCE_COLOR="$FORCE_COLOR") - [[ -t 0 ]] && flags+=(-i) + if [[ -t 0 ]] || stdin_has_data; then + flags+=(-i) + fi [[ -t 1 ]] && flags+=(-t) if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then diff --git a/docs/guides/conversion.rst b/docs/guides/conversion.rst new file mode 100644 index 00000000..cefd4bb6 --- /dev/null +++ b/docs/guides/conversion.rst @@ -0,0 +1,131 @@ +Conversion +========== + +LocalDevStack exposes Pandoc from the Tools image as a host-file conversion command. +Pandoc does not need to be installed on the workstation and the main LocalDevStack +services do not need to be running. + +Basic Usage +----------- + +Convert one host file to another:: + + lds convert docs README.md README.html + lds convert docs docs/guide.rst guide.docx + lds convert docs report.docx report.md + lds convert docs book.md book.epub --toc + +The first path is mounted read-only. Only the output directory is mounted writable. +The short-lived conversion container receives no Docker socket, project volumes, or +LocalDevStack networks. + +Pandoc Options +-------------- + +Arguments after the output path are passed to Pandoc without shell flattening:: + + lds convert docs README.md README.html --toc --standalone + lds convert docs report.docx report.md --wrap=none + lds convert docs book.md book.epub --metadata title="Developer Guide" + +An optional ``--`` separator is accepted:: + + lds convert docs README.md README.html -- --toc --standalone + +``-o`` / ``--output`` is intentionally rejected because LocalDevStack owns the output +path through the second positional argument. + +Relative Assets +--------------- + +Pandoc runs with the input directory as its working directory and with +``--resource-path=/lds-input``. Relative images and other resources next to the input +document therefore remain available during conversion. + +Auxiliary file options such as a reference document should use files below the input +directory and refer to them with relative paths. + +Overwrite Safety +---------------- + +Existing output files are not replaced unless ``--force`` is supplied:: + + lds convert docs --force README.md README.html + +The input and output may not resolve to the same file. + +Format Discovery +---------------- + +The supported readers and writers come from the Pandoc version currently shipped by the +Tools image:: + + lds convert docs --list-input-formats + lds convert docs --list-output-formats + lds convert docs --version + +Pandoc supports many text/document formats, but not every format can be converted to +every other format. PDF output additionally requires a compatible PDF engine; the base +Tools image currently provides Pandoc itself, not a TeX/PDF rendering stack. + +Windows and Git Bash +-------------------- + +The converter normalizes Windows/Git Bash host paths before creating Docker mounts and +disables MSYS argument rewriting for the Docker invocation. Paths containing spaces are +preserved as individual argv values. + +Image Conversion +---------------- + +Raster/image conversion uses ImageMagick from the same Tools image:: + + lds convert image photo.jpg photo.png + lds convert image photo.png photo.webp -- -quality 82 -strip + lds convert image animation.gif animation.webp + lds convert image animation.gif preview.jpg + +Use ``lds convert image --formats`` to inspect the delegates/formats available in the current image. Static outputs such as JPEG and PNG use the first frame of animated inputs by default; animation-capable GIF/WebP outputs preserve frames when supported. + +Audio and Video Conversion +-------------------------- + +Audio and video conversion use FFmpeg from the Tools image:: + + lds convert audio recording.wav recording.mp3 + lds convert audio recording.wav recording.ogg -- -c:a libopus -b:a 128k + lds convert video recording.mov recording.mp4 + lds convert video recording.mkv recording.webm -- -c:v libvpx-vp9 -crf 32 -b:v 0 + +The first-class media converter owns one input, overwrite policy, and one output path. +FFmpeg output options are passed after the input as exact argv. LDS reserves ``-i``, +``-y``, and ``-n`` because it owns input/output and ``--force`` behavior. + +Inspect FFmpeg capabilities with:: + + lds convert audio --formats + lds convert audio --codecs + lds convert audio --encoders + lds convert video --version + +For multi-input, concat, capture, or complex filtergraph workflows use the raw Tools +surface instead:: + + lds tools ffmpeg ... + lds tools ffprobe media.mkv + +Specialist Media Tools +---------------------- + +The Tools image also exposes media utilities directly:: + + lds tools sox ... + lds tools soxi recording.wav + lds tools mkvmerge ... + lds tools mkvinfo media.mkv + lds tools mkvextract ... + lds tools mkvpropedit ... + lds tools mediainfo media.mkv + +``xvidcore`` is installed as an explicit FFmpeg codec runtime dependency; it is a +library rather than a standalone command. diff --git a/docs/guides/local-ai.rst b/docs/guides/local-ai.rst index 42f51a1e..c79b3919 100644 --- a/docs/guides/local-ai.rst +++ b/docs/guides/local-ai.rst @@ -295,8 +295,12 @@ When the active docker-tools image exposes the docstruct Graphify handoff, 5. optional semantic review runs in bounded chunks against the active local model; 6. docker-tools emits a Graphify-compatible fragment; 7. Graphify's public ``merge-chunks`` validates that fragment; -8. docker-tools atomically replaces only the reserved ``docstruct_`` semantic layer; -9. ``graphify label`` reclusters and relabels the final combined graph. +8. docker-tools replaces only the reserved ``docstruct_`` semantic layer in a staged graph; +9. LocalDevStack copies that staged graph into an isolated temporary workspace and runs + ``graphify cluster-only --no-label --no-viz``; only Graphify's canonical round-trip + output is eligible for publication; +10. the canonical graph is atomically published to ``graphify-out/graph.json``, then + ``graphify label`` reclusters and relabels the final combined graph. This removes Markdown/RST/config parsing and recognized Python pip requirement manifests from the fragile raw LLM extraction path while preserving Graphify's existing support for @@ -321,7 +325,8 @@ Controls ``LDS_GRAPHIFY_DOC_REVIEW``: - ``auto`` (default): review bounded document chunks on the built-in local provider; - if review fails, keep the deterministic structure and continue; + docker-tools retries one malformed structured response once, then LocalDevStack keeps + the deterministic structure and continues if review still fails; - ``on``: require semantic review to succeed; - ``off``: use deterministic document structure only. diff --git a/docs/index.rst b/docs/index.rst index f496b401..378aec29 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,6 +28,7 @@ LocalDevStack is designed for trusted developer workstations, not production dep guides/domain-setup guides/databases-and-clients + guides/conversion guides/tls-and-certificates guides/local-ai guides/operations-and-support diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 53fa8143..0f243525 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -19,9 +19,10 @@ Windows macOS Docker Desktop. -Docker is always a host-side requirement. Some developer utilities such as ``jq``, -``yq``, ``rg``, ``fd``, ``tree``, and ``shellcheck`` can be proxied through a running -``server-tools`` container when they are not installed on the host. +Docker is always a host-side requirement. Bundled developer utilities are available through +``lds tools `` without installing them on the host. The temporary runner mounts the +current workspace and shares the running ``server-tools`` context. Use ``lds tools list`` +to see the curated Toolset/data/search/file utilities. Recommended Layout ------------------ @@ -125,6 +126,23 @@ qualified target when names collide. ``lds config show`` is redacted by default. Use ``--raw`` only when unredacted output is deliberately required. +File conversion is also available without starting the stack:: + + lds convert docs README.md README.html + +Pandoc runs from the Tools image; use ``lds convert docs --list-output-formats`` to inspect the +writers available in the current image. + +Image conversion uses ImageMagick:: + + lds convert image photo.jpg photo.webp -- -quality 82 + +Audio/video conversion uses FFmpeg:: + + lds convert audio recording.wav recording.mp3 + lds convert video recording.mov recording.mp4 + + Updating an Existing Installation --------------------------------- diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 11ad0c24..b0310e73 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -108,6 +108,40 @@ Configuration ``config show`` is redacted by default. +Conversion +---------- + +Pandoc-backed host conversion runs in a short-lived Tools container; the stack does not +need to be running:: + + lds convert docs [--force] [--] [pandoc-options...] + lds convert docs --list-input-formats + lds convert docs --list-output-formats + lds convert docs --version + +The input directory is read-only and the output directory is the only writable host +mount. Existing output requires ``--force``. ``-o`` / ``--output`` is reserved by LDS. + +Image conversion uses ImageMagick:: + + lds convert image [--force] [--] [imagemagick-options...] + lds convert image --formats + lds convert image --version + +Static JPEG/PNG-style outputs use the first frame of animated inputs; GIF/WebP outputs preserve animation when supported. + +Audio/video conversion uses FFmpeg:: + + lds convert audio [--force] [--] [ffmpeg-output-options...] + lds convert video [--force] [--] [ffmpeg-output-options...] + lds convert audio|video --formats + lds convert audio|video --codecs + lds convert audio|video --encoders + lds convert audio|video --version + +The first-class media converter owns one input and one output. Use ``lds tools ffmpeg`` +for multi-input/concat/capture/complex filtergraph workflows. ``ffprobe``, ``sox``, +``soxi``, MKVToolNix commands, and ``mediainfo`` are available through ``lds tools``. Certificates ------------ @@ -213,13 +247,29 @@ The older execution surfaces remain compatible during migration:: lds core [domain|service|container] [--] [command...] lds cli [--] [command...] lds stack exec [--] [command...] + lds tools list + lds tools [args...] + lds tools run [args...] + lds tools ui [args...] 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. +``tools `` and ``tools run`` start a short-lived Tools container with the +current host workspace mounted at ``/workspace``, inherit the active server-tools +network/volumes and selected LDS AI/Git environment, preserve stdin/TTY, then remove the +container. The inherited volumes include the Docker socket and trusted control-plane / +secret material, so this is a privileged workstation context rather than a sandbox and +must be used only with trusted Tools-image commands. This is the preferred surface for +Toolset utilities such as ``gitx``, +``sqlitex``, ``chromacat``, and ``netx``, plus bundled utilities such as +``jq``, ``yq``, ``rg``, ``fd``, ``tree``, ``shellcheck``, +``ncdu``, ``zip``, and ``unzip``. + +``tools sh/exec/shell-exec/file`` intentionally remain operations against the +long-running ``server-tools`` control-plane container. ``stack exec`` remains +service-only. Secrets ------- @@ -262,11 +312,14 @@ Graphify's raw semantic LLM extractor. Other semantic formats remain Graphify-ow Use ``LDS_GRAPHIFY_DOCSTRUCT=off`` to force the legacy path, ``LDS_GRAPHIFY_DOCSTRUCT=on`` to require the deterministic path, and ``LDS_GRAPHIFY_DOC_REVIEW=off|auto|on`` to control bounded semantic review. -For a brand-new graph it performs a code-only ``extract --no-cluster`` first, clusters -that structural graph, then performs a normal incremental ``extract --no-cluster`` to -enrich docs/papers/images and reclusters and force-relabels the combined graph again. Existing graphs use a -single incremental extract followed by one ``cluster-only`` pass. Explicit ``--code-only`` -remains a single structural build. +For a brand-new docstruct-enabled graph it performs a code-only +``extract --no-cluster``, then a second ``extract --no-cluster`` for semantic +formats not owned by docstruct. The deterministic document layer is merged into a staged +graph, and LocalDevStack runs an isolated LLM-free +``graphify cluster-only --no-label --no-viz`` round trip before publishing it. The +normal ``graphify label`` pass then relabels the canonical combined graph. Existing +docstruct-enabled graphs use the same staged merge + canonicalization before labeling. +Explicit ``--code-only`` remains a single structural build. For the built-in local route, LocalDevStack creates a temporary Graphify provider configuration that points directly to ``http://llm.localhost:11434/v1``. No Graphify diff --git a/lds b/lds index f5f44b79..a53d9f69 100755 --- a/lds +++ b/lds @@ -484,6 +484,9 @@ source "$DIR/lib/certificates.sh" # shellcheck source=lib/services.sh source "$DIR/lib/services.sh" +# shellcheck source=lib/conversion.sh +source "$DIR/lib/conversion.sh" + # ───────────────────────────────────────────────────────────────────────────── # 6d. DIAG / SNIFF # ───────────────────────────────────────────────────────────────────────────── @@ -780,6 +783,10 @@ cmd_help() { - `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 list` — show the curated Tools/Toolset catalog. +- `lds tools [args...]` — run a Tools command in a temporary workspace-aware container. +- `lds tools run [args...]` — explicit collision-safe form of the same runner. +- `lds tools ui [args...]` — run lazydocker through the temporary Tools runner. - `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`. @@ -791,6 +798,15 @@ cmd_help() { ## AI consumer - `lds ai status|ask|explain|troubleshoot|review|repo-review|graphify ...` +## Conversion +- `lds convert docs [--force] [--] [pandoc-options...]` +- `lds convert docs --list-input-formats|--list-output-formats|--version` +- `lds convert image [--force] [--] [imagemagick-options...]` +- `lds convert image --formats|--version` +- `lds convert audio [--force] [--] [ffmpeg-output-options...]` +- `lds convert video [--force] [--] [ffmpeg-output-options...]` +- `lds convert audio|video --formats|--codecs|--encoders|--version` + ## Host Graphify workflow - `lds graphify [path] [graphify-extract-options...]` @@ -870,11 +886,21 @@ ${CYAN}Execution / Shells:${NC} 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 + tools list|run|ui Curated/temporary Tools runner + tools [args...] Run a Tools command in workspace + tools sh|exec|shell-exec|file Long-running server-tools context ${CYAN}Secrets:${NC} secrets +${CYAN}Conversion:${NC} + convert docs [--force] [--] [pandoc-options...] + convert docs --list-input-formats|--list-output-formats|--version + convert image [--force] [--] [imagemagick-options...] + convert image --formats|--version + convert audio|video [--force] [--] [ffmpeg-output-options...] + convert audio|video --formats|--codecs|--encoders|--version + ${CYAN}AI:${NC} ai status|ask|explain|troubleshoot|review|repo-review|graphify graphify [path] [graphify-extract-options...] @@ -903,7 +929,7 @@ 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) + 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|convert|graphify|secrets|rebuild|run) return 0 ;; esac diff --git a/lib/ai.sh b/lib/ai.sh index 8c0b1acd..eb2bbef9 100644 --- a/lib/ai.sh +++ b/lib/ai.sh @@ -320,6 +320,46 @@ _graphify_tools_put() { docker cp "$host_file" "$_GRAPHIFY_TOOLS_CTR:$container_file" >/dev/null } +_graphify_canonicalize_staged_graph() { + local graphify_bin="$1" staged_graph="$2" + local roundtrip_dir roundtrip_input roundtrip_output rc=0 + + roundtrip_dir="$(mktemp -d "${TMPDIR:-/tmp}/lds-graphify-roundtrip.XXXXXX")" || + die "Unable to create temporary Graphify round-trip directory" + chmod 700 "$roundtrip_dir" 2>/dev/null || true + roundtrip_input="$roundtrip_dir/staged.json" + roundtrip_output="$roundtrip_dir/graphify-out/graph.json" + + if ! cp "$staged_graph" "$roundtrip_input"; then + rm -rf "$roundtrip_dir" + die "Unable to stage merged graph for Graphify round-trip validation" + fi + + printf '%s\n' "[lds graphify] documents: canonicalizing merged graph through Graphify before publication" >&2 + if ( + cd "$roundtrip_dir" + "$graphify_bin" cluster-only --graph "$roundtrip_input" --no-label --no-viz >/dev/null + ); then + : + else + rc=$? + rm -rf "$roundtrip_dir" + return "$rc" + fi + + if [[ ! -s "$roundtrip_output" ]]; then + rm -rf "$roundtrip_dir" + die "Graphify round-trip validation did not produce graph.json" + fi + + if ! cp "$roundtrip_output" "$staged_graph"; then + rm -rf "$roundtrip_dir" + die "Unable to stage Graphify-canonical document merge" + fi + + rm -rf "$roundtrip_dir" +} + _graphify_docstruct_enrich() { local graphify_bin="$1" target_abs="$2" review_mode="$3" shift 3 @@ -407,6 +447,14 @@ _graphify_docstruct_enrich() { return "$rc" fi + if ! _graphify_canonicalize_staged_graph "$graphify_bin" "$publish_tmp"; then + rc=$? + rm -f "$publish_tmp" + _graphify_tools_session_cleanup + rm -rf "$workdir" + die "Merged document graph is not round-trip compatible with the installed Graphify" + fi + chmod 0644 "$publish_tmp" 2>/dev/null || true if ! mv -f "$publish_tmp" "$graph_path"; then rm -f "$publish_tmp" diff --git a/lib/conversion.sh b/lib/conversion.sh new file mode 100644 index 00000000..b6ccd167 --- /dev/null +++ b/lib/conversion.sh @@ -0,0 +1,428 @@ +# shellcheck shell=bash + +_CONVERT_TOOLS_IMAGE="infocyph/tools:latest" +_CONVERT_INPUT_ABS='' +_CONVERT_OUTPUT_ABS='' +_CONVERT_INPUT_DIR='' +_CONVERT_OUTPUT_DIR='' +_CONVERT_INPUT_NAME='' +_CONVERT_OUTPUT_NAME='' +_CONVERT_INPUT_MOUNT='' +_CONVERT_OUTPUT_MOUNT='' + +_convert_usage() { + cat <<'EOF' +Usage: + lds convert docs [--force] [--] [pandoc-options...] + lds convert docs --list-input-formats|--list-output-formats|--version + lds convert image [--force] [--] [imagemagick-options...] + lds convert image --formats|--version + lds convert audio [--force] [--] [ffmpeg-output-options...] + lds convert audio --formats|--codecs|--encoders|--version + lds convert video [--force] [--] [ffmpeg-output-options...] + lds convert video --formats|--codecs|--encoders|--version + +Examples: + lds convert docs README.md README.html + lds convert docs docs/guide.rst guide.docx --toc + lds convert image photo.jpg photo.png + lds convert image photo.png photo.webp -- -quality 82 + lds convert image animation.gif animation.webp + lds convert audio recording.wav recording.mp3 + lds convert video recording.mov recording.mp4 -- -c:v libx264 -crf 23 -c:a aac +EOF +} + +_convert_docs_usage() { + cat <<'EOF' +Usage: + lds convert docs [--force] [--] [pandoc-options...] + lds convert docs --list-input-formats + lds convert docs --list-output-formats + lds convert docs --version +EOF +} + +_convert_image_usage() { + cat <<'EOF' +Usage: + lds convert image [--force] [--] [imagemagick-options...] + lds convert image --formats + lds convert image --version + +Examples: + lds convert image photo.jpg photo.png + lds convert image photo.png photo.webp -- -quality 82 + lds convert image animation.gif animation.webp + lds convert image animation.gif preview.jpg + +GIF/WebP output preserves animation when supported by ImageMagick. Static output +formats such as JPEG/PNG use the first frame of a multi-frame input by default. +EOF +} + +_convert_media_usage() { + local kind="${1:-media}" + cat < [--] [ffmpeg-output-options...] + lds convert $kind --formats + lds convert $kind --codecs + lds convert $kind --encoders + lds convert $kind --version + +Examples: + lds convert audio recording.wav recording.mp3 + lds convert audio recording.wav recording.ogg -- -c:a libopus -b:a 128k + lds convert video recording.mov recording.mp4 + lds convert video recording.mkv recording.webm -- -c:v libvpx-vp9 -crf 32 -b:v 0 + +The first-class media converter owns the single input, overwrite policy and one +output path. For multi-input, concat, capture, or advanced filtergraph workflows +use "lds tools ffmpeg ..." directly. +EOF +} + +_convert_ffmpeg_capability() { + local option="${1:-}" + case "$option" in + --version) + _convert_engine ffmpeg -version + ;; + --formats) + _convert_engine ffmpeg -hide_banner -formats + ;; + --codecs) + _convert_engine ffmpeg -hide_banner -codecs + ;; + --encoders) + _convert_engine ffmpeg -hide_banner -encoders + ;; + *) + return 64 + ;; + esac +} + +_convert_reject_ffmpeg_owned_options() { + local kind="${1:-media}" + shift || true + local arg + for arg in "$@"; do + case "$arg" in + -i|-y|-n) + err "lds convert $kind owns FFmpeg input/output and overwrite selection; option $arg is not allowed" + return 64 + ;; + esac + done +} + +_convert_media() { + local kind="${1:-media}" + shift || true + local force=0 input='' output='' overwrite_flag=-n + local -a args=() run_args=() + + case "${1:-}" in + ''|-h|--help|help) + _convert_media_usage "$kind" + return 0 + ;; + --version|--formats|--codecs|--encoders) + _convert_ffmpeg_capability "$1" + return $? + ;; + --force) + force=1 + overwrite_flag=-y + shift + ;; + esac + + input="${1:-}" + output="${2:-}" + [[ -n "$input" && -n "$output" ]] || { + _convert_media_usage "$kind" >&2 + return 64 + } + shift 2 + [[ "${1:-}" != '--' ]] || shift + args=("$@") + _convert_reject_ffmpeg_owned_options "$kind" "${args[@]}" || return $? + _convert_prepare_paths "$input" "$output" "$force" || return $? + + run_args=( + run --rm --pull=missing --network none + --user "$(id -u):$(id -g)" + -e HOME=/tmp + -v "$_CONVERT_INPUT_MOUNT:/lds-input:ro" + -v "$_CONVERT_OUTPUT_MOUNT:/lds-output" + --entrypoint ffmpeg + "$_CONVERT_TOOLS_IMAGE" + -hide_banner + -nostdin + "$overwrite_flag" + -i "/lds-input/$_CONVERT_INPUT_NAME" + ) + run_args+=("${args[@]}") + run_args+=("/lds-output/$_CONVERT_OUTPUT_NAME") + _convert_docker "${run_args[@]}" +} + +_convert_host_fs_path() { + local path="${1:-}" + [[ -n "$path" ]] || return 1 + + if [[ "$path" =~ ^[A-Za-z]:[\\/].* ]] && has_bin cygpath; then + cygpath -u "$path" + return $? + fi + + printf '%s' "$path" +} + +_convert_abs_existing_file() { + local raw="${1:-}" path + path="$(_convert_host_fs_path "$raw")" || return 1 + [[ -f "$path" ]] || return 1 + _realpath "$path" +} + +_convert_abs_output() { + local raw="${1:-}" path dir base abs_dir + path="$(_convert_host_fs_path "$raw")" || return 1 + dir="$(dirname -- "$path")" + base="$(basename -- "$path")" + [[ -d "$dir" ]] || return 1 + abs_dir="$(cd -P -- "$dir" 2>/dev/null && pwd -P)" || return 1 + printf '%s/%s' "$abs_dir" "$base" +} + +_convert_docker_mount_path() { + local path="${1:-}" + [[ -n "$path" ]] || return 1 + + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]] && has_bin cygpath; then + cygpath -w "$path" + return $? + fi + + printf '%s' "$path" +} + +_convert_prepare_paths() { + local input="${1:-}" output="${2:-}" force="${3:-0}" existing_output_abs + + _CONVERT_INPUT_ABS="$(_convert_abs_existing_file "$input")" || { + err "Input file not found or is not a regular file: $input" + return 66 + } + _CONVERT_OUTPUT_ABS="$(_convert_abs_output "$output")" || { + err "Output directory does not exist: $(dirname -- "$output")" + return 66 + } + + [[ "$_CONVERT_INPUT_ABS" != "$_CONVERT_OUTPUT_ABS" ]] || { + err "Input and output must be different files" + return 64 + } + if [[ -e "$_CONVERT_OUTPUT_ABS" ]]; then + existing_output_abs="$(_realpath "$_CONVERT_OUTPUT_ABS" 2>/dev/null || true)" + [[ -z "$existing_output_abs" || "$existing_output_abs" != "$_CONVERT_INPUT_ABS" ]] || { + err "Input and output must be different files" + return 64 + } + fi + if [[ -e "$_CONVERT_OUTPUT_ABS" && "$force" -ne 1 ]]; then + err "Output already exists: $_CONVERT_OUTPUT_ABS (use --force to replace it)" + return 73 + fi + + _CONVERT_INPUT_DIR="$(dirname -- "$_CONVERT_INPUT_ABS")" + _CONVERT_OUTPUT_DIR="$(dirname -- "$_CONVERT_OUTPUT_ABS")" + _CONVERT_INPUT_NAME="$(basename -- "$_CONVERT_INPUT_ABS")" + _CONVERT_OUTPUT_NAME="$(basename -- "$_CONVERT_OUTPUT_ABS")" + _CONVERT_INPUT_MOUNT="$(_convert_docker_mount_path "$_CONVERT_INPUT_DIR")" || return 66 + _CONVERT_OUTPUT_MOUNT="$(_convert_docker_mount_path "$_CONVERT_OUTPUT_DIR")" || return 66 +} + +_convert_docker() { + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then + ( + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + "$(bin_path docker)" "$@" + ) + else + "$(bin_path docker)" "$@" + fi +} + +_convert_engine() { + local entrypoint="${1:-}" + shift || true + [[ -n "$entrypoint" ]] || return 64 + _convert_docker run --rm --pull=missing --network none --entrypoint "$entrypoint" "$_CONVERT_TOOLS_IMAGE" "$@" +} + +_convert_reject_pandoc_output_option() { + local arg + for arg in "$@"; do + case "$arg" in + -o|-o?*|--output|--output=*) + err "lds convert docs owns Pandoc output selection; use the LDS output path instead of $arg" + return 64 + ;; + esac + done +} + +_convert_reject_imagemagick_output_option() { + local arg + for arg in "$@"; do + case "$arg" in + -write|+write) + err "lds convert image owns ImageMagick output selection; additional -write outputs are not allowed" + return 64 + ;; + esac + done +} + +_convert_image_static_output() { + local ext="${1##*.}" + ext="${ext,,}" + case "$ext" in + jpg|jpeg|jpe|png|bmp|ico) return 0 ;; + esac + return 1 +} + +_convert_docs() { + local force=0 input='' output='' + local -a args=() run_args=() + + case "${1:-}" in + ''|-h|--help|help) + _convert_docs_usage + return 0 + ;; + --version|--list-input-formats|--list-output-formats) + _convert_engine pandoc "$1" + return $? + ;; + --force) + force=1 + shift + ;; + esac + + input="${1:-}" + output="${2:-}" + [[ -n "$input" && -n "$output" ]] || { + _convert_docs_usage >&2 + return 64 + } + shift 2 + [[ "${1:-}" != '--' ]] || shift + args=("$@") + _convert_reject_pandoc_output_option "${args[@]}" || return $? + _convert_prepare_paths "$input" "$output" "$force" || return $? + + run_args=( + run --rm --pull=missing --network none + --user "$(id -u):$(id -g)" + -e HOME=/tmp + -v "$_CONVERT_INPUT_MOUNT:/lds-input:ro" + -v "$_CONVERT_OUTPUT_MOUNT:/lds-output" + -w /lds-input + --entrypoint pandoc + "$_CONVERT_TOOLS_IMAGE" + --resource-path=/lds-input + "./$_CONVERT_INPUT_NAME" + -o "/lds-output/$_CONVERT_OUTPUT_NAME" + ) + run_args+=("${args[@]}") + _convert_docker "${run_args[@]}" +} + +_convert_image() { + local force=0 input='' output='' image_input + local -a args=() run_args=() + + case "${1:-}" in + ''|-h|--help|help) + _convert_image_usage + return 0 + ;; + --version) + _convert_engine magick -version + return $? + ;; + --formats|--list-formats) + _convert_engine magick -list format + return $? + ;; + --force) + force=1 + shift + ;; + esac + + input="${1:-}" + output="${2:-}" + [[ -n "$input" && -n "$output" ]] || { + _convert_image_usage >&2 + return 64 + } + shift 2 + [[ "${1:-}" != '--' ]] || shift + args=("$@") + _convert_reject_imagemagick_output_option "${args[@]}" || return $? + _convert_prepare_paths "$input" "$output" "$force" || return $? + + image_input="/lds-input/$_CONVERT_INPUT_NAME" + if _convert_image_static_output "$_CONVERT_OUTPUT_NAME"; then + image_input+='[0]' + fi + + run_args=( + run --rm --pull=missing --network none + --user "$(id -u):$(id -g)" + -e HOME=/tmp + -v "$_CONVERT_INPUT_MOUNT:/lds-input:ro" + -v "$_CONVERT_OUTPUT_MOUNT:/lds-output" + --entrypoint magick + "$_CONVERT_TOOLS_IMAGE" + "$image_input" + ) + run_args+=("${args[@]}") + run_args+=("/lds-output/$_CONVERT_OUTPUT_NAME") + _convert_docker "${run_args[@]}" +} + +cmd_convert() { + local kind="${1:-}" + shift || true + + case "${kind,,}" in + docs) + _convert_docs "$@" + ;; + image) + _convert_image "$@" + ;; + audio | video) + _convert_media "${kind,,}" "$@" + ;; + ''|-h|--help|help) + _convert_usage + ;; + *) + err "Unknown conversion type: $kind (expected docs, image, audio, or video)" + _convert_usage >&2 + return 64 + ;; + esac +} diff --git a/lib/services.sh b/lib/services.sh index 66869d7d..af936d46 100644 --- a/lib/services.sh +++ b/lib/services.sh @@ -826,12 +826,98 @@ cmd_rebuild() { } +_tools_catalog_print() { + cat <<'EOF' +Tools / Toolset catalog + +Toolset: + gitx Git workflow, summaries, worklogs and commit helpers + sqlitex SQLite administration, migrations, backup/export and tuning + chromacat Pipeline-safe colour/log/banner presentation + netx Network/DNS/TLS/HTTP diagnostics from the Tools network context + +Media: + ffmpeg audio/video transcode, remux and filter + ffprobe stream/container probing + sox audio processing/effects + soxi audio metadata inspection + mkvmerge Matroska muxing/remuxing + mkvinfo Matroska structure inspection + mkvextract Matroska stream/attachment extraction + mkvpropedit Matroska property editing + mediainfo media/container metadata inspection + xvidcore codec runtime used by FFmpeg (library; no standalone CLI) + +Data / search: + jq JSON processor + yq YAML processor + rg ripgrep + fd file finder + +Files / quality: + tree directory tree + shellcheck shell static analysis + ncdu terminal disk-usage browser + zip ZIP archive creator + unzip ZIP archive extractor + +TUI: + lazydocker Docker terminal UI (also: lds support ui) + +Usage: + lds tools [args...] + lds tools run [args...] + lds tools list + +Examples: + lds tools gitx status + lds tools sqlitex --db app.db tables + cat app.log | lds tools chromacat --log + lds tools netx route show + lds tools ffprobe media.mkv + lds tools mediainfo media.mkv + lds tools soxi recording.wav + lds tools jq --version + +Any non-reserved tool name is delegated to the current Tools image. Use +"lds tools run " when a tool name collides with an LDS tools subcommand. +EOF +} + +_tools_runner_exec() { + (($# > 0)) || { + err "Usage: lds tools run [args...]" + return 64 + } + "$DIR/bin/tool-runner" "$@" +} + cmd_tools() { local sub="${1:-sh}" shift || true - _shell_context_reset - _shell_resolve_tools || return $? + case "${sub,,}" in + list | catalog | help | -h | --help) + _tools_catalog_print + return 0 + ;; + run) + _tools_runner_exec "$@" + return $? + ;; + ui) + _tools_runner_exec lazydocker "$@" + return $? + ;; + sh | shell | "" | exec | shell-exec | file) + _shell_context_reset + _shell_resolve_tools || return $? + ;; + *) + _tools_runner_exec "$sub" "$@" + return $? + ;; + esac case "${sub,,}" in sh | shell | "") @@ -865,7 +951,8 @@ cmd_tools() { ' sh "$path" ;; *) - die "tools " + err "Usage: lds tools " + return 64 ;; esac } diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index fc47583c..b8cbb4c0 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -13,7 +13,7 @@ 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" +assert_contains "$help_output" "Long-running server-tools context" pass "lds help" markdown_output="$("$ROOT/lds" help --markdown)" @@ -55,7 +55,7 @@ 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)' +assert_file_contains "$ROOT/lds" 'tools|cli|core|shell|convert|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 @@ -67,6 +67,24 @@ assert_contains "$help_output" "shell [target]" assert_contains "$markdown_output" "lds shell " pass "unified shell is exposed in embedded help" +assert_contains "$help_output" "convert docs [--force] " +assert_contains "$markdown_output" "lds convert docs [--force] " +assert_contains "$markdown_output" "lds convert docs --list-input-formats" +assert_contains "$markdown_output" "lds convert image [--force] " +assert_contains "$markdown_output" "lds convert image --formats" +assert_contains "$help_output" "convert audio|video [--force] " +assert_contains "$markdown_output" "lds convert audio [--force] " +assert_contains "$markdown_output" "lds convert video [--force] " +assert_contains "$markdown_output" "lds convert audio|video --formats|--codecs|--encoders|--version" +pass "docs image audio and video conversion are exposed in embedded help" + +assert_contains "$help_output" "tools list|run|ui" +assert_contains "$help_output" "tools [args...]" +assert_contains "$markdown_output" "lds tools list" +assert_contains "$markdown_output" "lds tools [args...]" +assert_contains "$markdown_output" "lds tools run [args...]" +pass "Tools catalog and temporary runner are exposed in embedded help" + graphify_log="$(mktemp)" cat >"$tmpbin/graphify" <<'SH' #!/usr/bin/env sh @@ -282,6 +300,18 @@ if [[ "${1:-}" == merge-chunks ]]; then esac done cp "$input" "$out" +elif [[ "${1:-}" == cluster-only ]]; then + shift + graph='' + while (($#)); do + case "$1" in + --graph) graph="$2"; shift 2 ;; + *) shift ;; + esac + done + [[ -n "$graph" ]] || exit 96 + mkdir -p graphify-out + cp "$graph" graphify-out/graph.json fi SH chmod +x "$graphify_hybrid" @@ -323,6 +353,10 @@ SH fail "external graph publication failed" grep -Fq 'merge-chunks ' "$graphify_hybrid_log" || fail "Graphify public fragment validation was not invoked" + grep -Fq 'cluster-only --graph ' "$graphify_hybrid_log" || + fail "Graphify merged graph round-trip canonicalization was not invoked" + grep -Fq -- '--no-label --no-viz' "$graphify_hybrid_log" || + fail "Graphify merged graph round-trip must stay deterministic and LLM-free" [[ "$(_graphify_docstruct_mode)" == auto ]] || fail "Graphify docstruct default mode drifted" @@ -351,6 +385,9 @@ assert_file_contains "$ROOT/lib/ai.sh" 'server-tools tail -f /dev/null' assert_file_contains "$ROOT/lib/ai.sh" 'docker exec -i "$_GRAPHIFY_TOOLS_CTR"' assert_file_contains "$ROOT/lib/ai.sh" 'docker rm -f "$ctr"' assert_file_contains "$ROOT/lib/ai.sh" 'mktemp "$target_abs/graphify-out/.graph.json.docstruct.XXXXXX"' +assert_file_contains "$ROOT/lib/ai.sh" '_graphify_canonicalize_staged_graph' +assert_file_contains "$ROOT/lib/ai.sh" 'cluster-only --graph "$roundtrip_input" --no-label --no-viz' +assert_file_contains "$ROOT/lib/ai.sh" 'documents: canonicalizing merged graph through Graphify before publication' assert_file_contains "$ROOT/lib/ai.sh" "--exclude 'requirements*.txt'" assert_file_contains "$ROOT/lib/ai.sh" "--exclude 'constraints*.txt'" assert_file_contains "$ROOT/lib/ai.sh" "--exclude 'requirements/*.txt'" diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index f3411e88..afb961e3 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -15,18 +15,51 @@ domain="$ROOT/docs/guides/domain-setup.rst" tls="$ROOT/docs/guides/tls-and-certificates.rst" ai="$ROOT/docs/guides/local-ai.rst" databases="$ROOT/docs/guides/databases-and-clients.rst" +conversion="$ROOT/docs/guides/conversion.rst" ops="$ROOT/docs/guides/operations-and-support.rst" 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" -for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$ops" "$runner" "$notify" "$secrets" "$cli"; do +for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$conversion" "$ops" "$runner" "$notify" "$secrets" "$cli"; do assert_file "$file" done assert_file_contains "$index" 'guides/local-ai' assert_file_contains "$index" 'guides/databases-and-clients' +assert_file_contains "$index" 'guides/conversion' +assert_file_contains "$conversion" 'lds convert docs README.md README.html' +assert_file_contains "$conversion" 'lds convert docs --list-output-formats' +assert_file_contains "$conversion" 'lds convert image photo.jpg photo.png' +assert_file_contains "$conversion" 'lds convert image --formats' +assert_file_contains "$conversion" 'lds convert audio recording.wav recording.mp3' +assert_file_contains "$conversion" 'lds convert video recording.mov recording.mp4' +assert_file_contains "$conversion" 'lds tools ffprobe media.mkv' +assert_file_contains "$conversion" 'lds tools mkvinfo media.mkv' +assert_file_contains "$conversion" 'lds tools mediainfo media.mkv' +assert_file_contains "$cli" 'lds convert audio [--force] ' +assert_file_contains "$cli" 'lds convert video [--force] ' +assert_file_contains "$quick" 'lds convert audio recording.wav recording.mp3' +assert_file_contains "$quick" 'lds convert video recording.mov recording.mp4' +assert_file_contains "$readme" 'lds tools ffprobe media.mkv' +assert_file_contains "$readme" 'lds tools mediainfo media.mkv' +assert_file_contains "$conversion" 'no Docker socket' +assert_file_contains "$cli" 'lds convert docs [--force] ' +assert_file_contains "$cli" 'lds convert image [--force] ' +assert_file_contains "$quick" 'lds convert docs README.md README.html' +assert_file_contains "$quick" 'lds convert image photo.jpg photo.webp' +assert_file_contains "$readme" 'lds convert docs docs/guide.rst guide.docx --toc' +assert_file_contains "$readme" 'lds convert image animation.gif animation.webp' +assert_file_contains "$readme" 'lds tools gitx status' +assert_file_contains "$readme" 'lds tools sqlitex --db app.db tables' +assert_file_contains "$readme" 'cat app.log | lds tools chromacat --log' +assert_file_contains "$quick" 'lds tools list' +assert_file_contains "$cli" 'lds tools [args...]' +assert_file_contains "$cli" 'lds tools run [args...]' +assert_file_contains "$cli" 'Docker socket' +assert_file_contains "$cli" 'privileged workstation context' +assert_file_contains "$readme" 'not a sandbox' assert_file_contains "$index" 'guides/operations-and-support' assert_file_contains "$index" 'guides/ad-hoc-runner' assert_file_contains "$index" 'reference/cli' @@ -107,6 +140,9 @@ assert_file_contains "$ai" '.md .markdown .rst .yaml .yml .json .toml .ini .cfg' assert_file_contains "$ai" 'LDS_GRAPHIFY_DOCSTRUCT' assert_file_contains "$ai" 'LDS_GRAPHIFY_DOC_REVIEW' assert_file_contains "$ai" '--token-budget 3000' +assert_file_contains "$ai" 'cluster-only --no-label --no-viz' +assert_file_contains "$ai" 'retries one malformed structured response once' +assert_file_contains "$cli" 'cluster-only --no-label --no-viz' assert_file_contains "$ai" 'There is no LocalDevStack Graphify HTTP proxy or Python compatibility adapter.' assert_file_contains "$ai" 'Graphify' assert_file_contains "$ai" 'docstruct' @@ -157,7 +193,7 @@ 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 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 +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 list' 'lds tools [args...]' 'lds tools run [args...]' '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" @@ -171,6 +207,9 @@ 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 list' +assert_file_contains "$cli" 'lds tools [args...]' +assert_file_contains "$cli" 'lds tools run [args...]' assert_file_contains "$cli" 'lds tools exec [--] [args...]' assert_file_contains "$cli" 'lds tools shell-exec ' assert_file_contains "$cli" 'preserve argv exactly' diff --git a/tests/document-convert-contract.sh b/tests/document-convert-contract.sh new file mode 100644 index 00000000..30850aeb --- /dev/null +++ b/tests/document-convert-contract.sh @@ -0,0 +1,151 @@ +#!/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 + +bin="$tmp/bin" +mkdir -p "$bin" "$tmp/Input Docs" "$tmp/Output Docs" +log="$tmp/docker.log" +: >"$log" + +cat >"$bin/docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +: "${DOCUMENT_CONVERT_TEST_LOG:?}" +printf '%s\n' '---' >>"$DOCUMENT_CONVERT_TEST_LOG" +for arg in "$@"; do + printf '<%s>\n' "$arg" >>"$DOCUMENT_CONVERT_TEST_LOG" +done + +out_host='' +out_name='' +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + if [[ "${args[$i]}" == "-v" && $((i + 1)) -lt ${#args[@]} ]]; then + mount="${args[$((i + 1))]}" + if [[ "$mount" == *":/lds-output" ]]; then + out_host="${mount%:/lds-output}" + fi + fi + if [[ "${args[$i]}" == "-o" && $((i + 1)) -lt ${#args[@]} ]]; then + target="${args[$((i + 1))]}" + out_name="${target#/lds-output/}" + fi +done + +case " $* " in + *" --list-input-formats "*) + printf '%s\n' markdown rst html docx epub + exit 0 + ;; + *" --list-output-formats "*) + printf '%s\n' html5 markdown docx epub + exit 0 + ;; + *" --version "*) + printf '%s\n' 'pandoc 3.test' + exit 0 + ;; +esac + +if [[ -n "$out_host" && -n "$out_name" ]]; then + mkdir -p -- "$(dirname -- "$out_host/$out_name")" + printf '%s\n' 'converted' >"$out_host/$out_name" +fi +SH +chmod +x "$bin/docker" + +input="$tmp/Input Docs/Guide File.md" +output="$tmp/Output Docs/Guide File.html" +printf '# Guide\n' >"$input" + +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$output" --toc --standalone + +[[ -f "$output" ]] || fail "lds convert docs did not publish the host output file" +grep -Fq "<$tmp/Input Docs:/lds-input:ro>" "$log" || + fail "lds convert docs did not mount the input directory read-only" +grep -Fq "<$tmp/Output Docs:/lds-output>" "$log" || + fail "lds convert docs did not mount the output directory writable" +grep -Fq '<--entrypoint>' "$log" || fail "lds convert docs did not use an explicit Pandoc entrypoint" +grep -Fq '' "$log" || fail "lds convert docs did not invoke Pandoc" +grep -Fq '<--network>' "$log" || fail "lds convert docs did not disable container networking" +grep -Fq '' "$log" || fail "lds convert docs did not use the none network" +grep -Fq '<--user>' "$log" || fail "lds convert docs did not preserve host output ownership" +grep -Fq '<--resource-path=/lds-input>' "$log" || + fail "lds convert docs did not preserve relative input resources" +grep -Fq '<./Guide File.md>' "$log" || fail "input filename with spaces was not preserved" +grep -Fq '' "$log" || fail "output filename with spaces was not preserved" +grep -Fq '<--toc>' "$log" || fail "Pandoc option passthrough lost --toc" +grep -Fq '<--standalone>' "$log" || fail "Pandoc option passthrough lost --standalone" +if grep -Fq '/var/run/docker.sock' "$log"; then + fail "lds convert docs must not expose the Docker socket" +fi +if grep -Fq '' "$log"; then + fail "lds convert docs must not require a running Compose stack" +fi +pass "containerized host document conversion" + +set +e +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$output" >/dev/null 2>"$tmp/existing.err" +rc=$? +set -e +[[ "$rc" -eq 73 ]] || fail "existing output returned $rc instead of 73" +grep -Fq 'use --force to replace it' "$tmp/existing.err" || + fail "existing output refusal did not explain --force" + +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --force "$input" "$output" --wrap=none +grep -Fq '<--wrap=none>' "$log" || fail "--force conversion lost Pandoc options" +pass "document conversion overwrite protection" + +before="$(wc -l <"$log" | tr -d '[:space:]')" +set +e +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --force "$input" "$output" -- -oelsewhere.html >/dev/null 2>"$tmp/output-option.err" +rc=$? +set -e +after="$(wc -l <"$log" | tr -d '[:space:]')" +[[ "$rc" -eq 64 ]] || fail "conflicting Pandoc output option returned $rc instead of 64" +[[ "$before" == "$after" ]] || fail "conflicting output option reached Docker" +grep -Fq 'owns Pandoc output selection' "$tmp/output-option.err" || + fail "conflicting output option diagnostic missing" +ln -s "$input" "$tmp/Output Docs/input-link.md" +before="$(wc -l <"$log" | tr -d '[:space:]')" +set +e +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert docs --force "$input" "$tmp/Output Docs/input-link.md" >/dev/null 2>"$tmp/same.err" +rc=$? +set -e +after="$(wc -l <"$log" | tr -d '[:space:]')" +[[ "$rc" -eq 64 ]] || fail "input/output symlink collision returned $rc instead of 64" +[[ "$before" == "$after" ]] || fail "input/output symlink collision reached Docker" +grep -Fq 'Input and output must be different files' "$tmp/same.err" || + fail "input/output symlink collision diagnostic missing" +pass "document conversion blocks in-place and symlink overwrite" + +formats="$( + DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --list-input-formats +)" +grep -qx 'markdown' <<<"$formats" || fail "input format discovery did not reach Pandoc" +version="$( + DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --version +)" +grep -q '^pandoc ' <<<"$version" || fail "Pandoc version discovery failed" +pass "document conversion capability discovery" + +set +e +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$tmp/missing.md" "$tmp/Output Docs/missing.html" >/dev/null 2>"$tmp/missing.err" +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "missing input returned $rc instead of 66" + +set +e +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$tmp/no-such-dir/output.html" >/dev/null 2>"$tmp/outdir.err" +rc=$? +set -e +[[ "$rc" -eq 66 ]] || fail "missing output directory returned $rc instead of 66" +pass "document conversion validates host paths" diff --git a/tests/execution-contract.sh b/tests/execution-contract.sh index 0aa45635..ad6ef236 100755 --- a/tests/execution-contract.sh +++ b/tests/execution-contract.sh @@ -731,6 +731,64 @@ 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_tools_catalog_offline() { + _project_tools_container_running() { return 1; } + cmd_tools list >"$tmp/tools-catalog.out" +} +run_case case_tools_catalog_offline +assert_file_contains "$tmp/tools-catalog.out" 'gitx' +assert_file_contains "$tmp/tools-catalog.out" 'sqlitex' +assert_file_contains "$tmp/tools-catalog.out" 'chromacat' +assert_file_contains "$tmp/tools-catalog.out" 'netx' +assert_file_contains "$tmp/tools-catalog.out" 'lazydocker' +assert_file_contains "$tmp/tools-catalog.out" 'ffmpeg' +assert_file_contains "$tmp/tools-catalog.out" 'ffprobe' +assert_file_contains "$tmp/tools-catalog.out" 'sox' +assert_file_contains "$tmp/tools-catalog.out" 'soxi' +assert_file_contains "$tmp/tools-catalog.out" 'mkvmerge' +assert_file_contains "$tmp/tools-catalog.out" 'mkvinfo' +assert_file_contains "$tmp/tools-catalog.out" 'mkvextract' +assert_file_contains "$tmp/tools-catalog.out" 'mkvpropedit' +assert_file_contains "$tmp/tools-catalog.out" 'mediainfo' +assert_file_contains "$tmp/tools-catalog.out" 'xvidcore' +pass "tools catalog is discoverable without a running Tools container" + +case_tools_direct_runner() { + _tools_runner_exec() { + printf 'tool-runner:' >>"$EXECUTION_TEST_LOG" + printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG" + printf '\n' >>"$EXECUTION_TEST_LOG" + } + cmd_tools gitx status --short +} +run_case case_tools_direct_runner +assert_file_contains "$log" 'tool-runner: <--short>' +pass "curated/non-reserved tools delegate argv to the temporary tool runner" + +case_tools_explicit_runner() { + _tools_runner_exec() { + printf 'tool-runner:' >>"$EXECUTION_TEST_LOG" + printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG" + printf '\n' >>"$EXECUTION_TEST_LOG" + } + cmd_tools run sqlitex --db 'db path/app.db' tables +} +run_case case_tools_explicit_runner +assert_file_contains "$log" 'tool-runner: <--db> ' +pass "tools run provides an explicit collision-safe extension path" + +case_tools_ui_runner() { + _tools_runner_exec() { + printf 'tool-runner:' >>"$EXECUTION_TEST_LOG" + printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG" + printf '\n' >>"$EXECUTION_TEST_LOG" + } + cmd_tools ui --debug +} +run_case case_tools_ui_runner +assert_file_contains "$log" 'tool-runner: <--debug>' +pass "tools ui reuses the temporary runner for the Docker TUI" + case_ui_interactive() { force_interactive_tty cmd_ui diff --git a/tests/graphify-roundtrip-contract.sh b/tests/graphify-roundtrip-contract.sh new file mode 100644 index 00000000..a46ab896 --- /dev/null +++ b/tests/graphify-roundtrip-contract.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +command -v graphify >/dev/null 2>&1 || { + printf 'graphify-roundtrip-contract: graphify is required\n' >&2 + exit 69 +} +command -v jq >/dev/null 2>&1 || { + printf 'graphify-roundtrip-contract: jq is required\n' >&2 + exit 69 +} + +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT INT TERM + +cat >"$tmp/staged.json" <<'JSON' +{ + "directed": false, + "multigraph": false, + "graph": {}, + "nodes": [ + { + "id": "src_runtime_php_runtime", + "label": "Runtime", + "file_type": "code", + "source_file": "src/Runtime.php", + "source_location": "L1", + "_origin": "ast" + }, + { + "id": "docstruct_docs_guide_repeat_a", + "label": "Repeated heading", + "file_type": "document", + "source_file": "/workspace/docs/guide.md", + "source_location": "L2", + "docstruct_origin": "docker-tools.docstruct/v1" + }, + { + "id": "docstruct_docs_guide_repeat_b", + "label": "Repeated heading", + "file_type": "document", + "source_file": "/workspace/docs/guide.md", + "source_location": "L8", + "docstruct_origin": "docker-tools.docstruct/v1" + } + ], + "links": [ + { + "source": "src_runtime_php_runtime", + "target": "docstruct_docs_guide_repeat_a", + "relation": "references", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": "src/Runtime.php" + }, + { + "source": "src_runtime_php_runtime", + "target": "docstruct_docs_guide_repeat_b", + "relation": "references", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": "src/Runtime.php" + } + ], + "hyperedges": [] +} +JSON + +run_roundtrip() { + local input="$1" dir="$2" + mkdir -p "$dir" + cp "$input" "$dir/staged.json" + ( + cd "$dir" + graphify cluster-only --graph "$dir/staged.json" --no-label --no-viz >/dev/null + ) + [[ -s "$dir/graphify-out/graph.json" ]] || { + printf 'graphify-roundtrip-contract: no canonical graph produced\n' >&2 + exit 1 + } +} + +run_roundtrip "$tmp/staged.json" "$tmp/first" +run_roundtrip "$tmp/first/graphify-out/graph.json" "$tmp/second" + +raw_nodes="$(jq '.nodes | length' "$tmp/staged.json")" +first_nodes="$(jq '.nodes | length' "$tmp/first/graphify-out/graph.json")" +second_nodes="$(jq '.nodes | length' "$tmp/second/graphify-out/graph.json")" + +((first_nodes <= raw_nodes)) || { + printf 'graphify-roundtrip-contract: canonicalization unexpectedly grew nodes: %s -> %s\n' "$raw_nodes" "$first_nodes" >&2 + exit 1 +} +[[ "$first_nodes" == "$second_nodes" ]] || { + printf 'graphify-roundtrip-contract: canonical output is not idempotent: %s -> %s\n' "$first_nodes" "$second_nodes" >&2 + exit 1 +} + +for graph in "$tmp/first/graphify-out/graph.json" "$tmp/second/graphify-out/graph.json"; do + jq -e '.nodes | any(.id == "src_runtime_php_runtime" and .file_type == "code")' "$graph" >/dev/null || + { + printf 'graphify-roundtrip-contract: code node was lost during canonicalization\n' >&2 + exit 1 + } +done + +printf 'graphify-roundtrip-contract: ok (%s -> %s -> %s nodes)\n' "$raw_nodes" "$first_nodes" "$second_nodes" diff --git a/tests/image-convert-contract.sh b/tests/image-convert-contract.sh new file mode 100644 index 00000000..1abb5aa2 --- /dev/null +++ b/tests/image-convert-contract.sh @@ -0,0 +1,120 @@ +#!/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 + +bin="$tmp/bin" +mkdir -p "$bin" "$tmp/Input Images" "$tmp/Output Images" +log="$tmp/docker.log" +: >"$log" + +cat >"$bin/docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +: "${IMAGE_CONVERT_TEST_LOG:?}" +printf '%s\n' '---' >>"$IMAGE_CONVERT_TEST_LOG" +for arg in "$@"; do + printf '<%s>\n' "$arg" >>"$IMAGE_CONVERT_TEST_LOG" +done + +case " $* " in + *" -list format "*) + printf '%s\n' ' JPEG* rw- Joint Photographic Experts Group JFIF format' ' PNG* rw- Portable Network Graphics' ' GIF* rw+ CompuServe graphics interchange format' ' WEBP* rw+ WebP Image Format' + exit 0 + ;; + *" -version "*) + printf '%s\n' 'Version: ImageMagick 7.test' + exit 0 + ;; +esac + +out_host='' +out_name='' +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + if [[ "${args[$i]}" == "-v" && $((i + 1)) -lt ${#args[@]} ]]; then + mount="${args[$((i + 1))]}" + if [[ "$mount" == *":/lds-output" ]]; then + out_host="${mount%:/lds-output}" + fi + fi +done +if ((${#args[@]} > 0)); then + target="${args[$((${#args[@]} - 1))]}" + [[ "$target" == /lds-output/* ]] && out_name="${target#/lds-output/}" +fi +if [[ -n "$out_host" && -n "$out_name" ]]; then + mkdir -p -- "$(dirname -- "$out_host/$out_name")" + printf '%s\n' 'converted-image' >"$out_host/$out_name" +fi +SH +chmod +x "$bin/docker" + +input="$tmp/Input Images/Photo File.png" +output="$tmp/Output Images/Photo File.webp" +printf 'png' >"$input" + +IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert image "$input" "$output" -- -quality 82 -strip +[[ -f "$output" ]] || fail "lds convert image did not publish the host output file" +grep -Fq "<$tmp/Input Images:/lds-input:ro>" "$log" || + fail "image conversion did not mount input read-only" +grep -Fq "<$tmp/Output Images:/lds-output>" "$log" || + fail "image conversion did not mount output writable" +grep -Fq '<--entrypoint>' "$log" || fail "image conversion did not set entrypoint" +grep -Fq '' "$log" || fail "image conversion did not invoke ImageMagick" +grep -Fq '<--network>' "$log" || fail "image conversion did not disable networking" +grep -Fq '' "$log" || fail "image conversion did not use network none" +grep -Fq '<--user>' "$log" || fail "image conversion did not preserve host output ownership" +grep -Fq '' "$log" || fail "image input with spaces was not preserved" +grep -Fq '<-quality>' "$log" || fail "ImageMagick quality option was lost" +grep -Fq '<82>' "$log" || fail "ImageMagick quality value was lost" +grep -Fq '<-strip>' "$log" || fail "ImageMagick strip option was lost" +grep -Fq '' "$log" || fail "image output with spaces was not preserved" +if grep -Fq '/var/run/docker.sock' "$log"; then fail "image conversion exposed Docker socket"; fi +pass "containerized host image conversion" + +gif="$tmp/Input Images/Animation.gif" +jpg="$tmp/Output Images/Preview.jpg" +webp="$tmp/Output Images/Animation.webp" +printf 'gif' >"$gif" +: >"$log" +IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert image "$gif" "$jpg" +grep -Fq '' "$log" || + fail "static image output did not select first animation frame" +: >"$log" +IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert image "$gif" "$webp" +if grep -Fq '' "$log"; then + fail "animation-capable WebP output incorrectly discarded animation frames" +fi +grep -Fq '' "$log" || fail "animated WebP input path missing" +pass "static and animated image output semantics" + +formats="$(IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert image --formats)" +grep -q 'JPEG' <<<"$formats" || fail "JPEG capability discovery missing" +grep -q 'PNG' <<<"$formats" || fail "PNG capability discovery missing" +grep -q 'GIF' <<<"$formats" || fail "GIF capability discovery missing" +grep -q 'WEBP' <<<"$formats" || fail "WebP capability discovery missing" +version="$(IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert image --version)" +grep -q 'ImageMagick' <<<"$version" || fail "ImageMagick version discovery failed" +pass "image conversion capability discovery" + +before="$(wc -l <"$log" | tr -d '[:space:]')" +set +e +IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert image --force "$input" "$output" -- -write extra.png >/dev/null 2>"$tmp/write.err" +rc=$? +set -e +after="$(wc -l <"$log" | tr -d '[:space:]')" +[[ "$rc" -eq 64 ]] || fail "ImageMagick -write returned $rc instead of 64" +[[ "$before" == "$after" ]] || fail "ImageMagick -write reached Docker" +grep -Fq 'owns ImageMagick output selection' "$tmp/write.err" || fail "ImageMagick -write diagnostic missing" +pass "image conversion owns output path" diff --git a/tests/media-convert-contract.sh b/tests/media-convert-contract.sh new file mode 100644 index 00000000..9cbf17b4 --- /dev/null +++ b/tests/media-convert-contract.sh @@ -0,0 +1,115 @@ +#!/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 + +bin="$tmp/bin" +mkdir -p "$bin" "$tmp/Input Media" "$tmp/Output Media" +log="$tmp/docker.log" +: >"$log" + +cat >"$bin/docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +: "${MEDIA_CONVERT_TEST_LOG:?}" +printf '%s\n' '---' >>"$MEDIA_CONVERT_TEST_LOG" +for arg in "$@"; do + printf '<%s>\n' "$arg" >>"$MEDIA_CONVERT_TEST_LOG" +done + +case " $* " in + *" -formats "*) printf '%s\n' ' D matroska,webm' ' DE mp3' ; exit 0 ;; + *" -codecs "*) printf '%s\n' ' DEV.L. h264' ' DEA.L. mp3' ; exit 0 ;; + *" -encoders "*) printf '%s\n' ' V..... libx264' ' A..... libmp3lame' ; exit 0 ;; + *" -version "*) printf '%s\n' 'ffmpeg version test' ; exit 0 ;; +esac + +out_host='' +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + if [[ "${args[$i]}" == "-v" && $((i + 1)) -lt ${#args[@]} ]]; then + mount="${args[$((i + 1))]}" + [[ "$mount" == *":/lds-output" ]] && out_host="${mount%:/lds-output}" + fi +done +if ((${#args[@]} > 0)); then + target="${args[$((${#args[@]} - 1))]}" + if [[ "$target" == /lds-output/* && -n "$out_host" ]]; then + out_name="${target#/lds-output/}" + printf '%s\n' converted >"$out_host/$out_name" + fi +fi +SH +chmod +x "$bin/docker" + +audio_in="$tmp/Input Media/Source Audio.wav" +audio_out="$tmp/Output Media/Output Audio.mp3" +video_in="$tmp/Input Media/Source Video.mkv" +video_out="$tmp/Output Media/Output Video.mp4" +printf 'audio' >"$audio_in" +printf 'video' >"$video_in" + +MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert audio "$audio_in" "$audio_out" -- -c:a libmp3lame -b:a 160k +[[ -f "$audio_out" ]] || fail "audio conversion did not publish output" +grep -Fq '<--network>' "$log" || fail "audio conversion did not disable networking" +grep -Fq '' "$log" || fail "audio conversion did not use network none" +grep -Fq '<--user>' "$log" || fail "audio conversion did not preserve host ownership" +grep -Fq "<$tmp/Input Media:/lds-input:ro>" "$log" || fail "audio input mount was not read-only" +grep -Fq '<--entrypoint>' "$log" || fail "audio conversion did not set FFmpeg entrypoint" +grep -Fq '' "$log" || fail "audio conversion did not invoke FFmpeg" +grep -Fq '<-nostdin>' "$log" || fail "audio conversion did not disable FFmpeg interactive stdin" +grep -Fq '<-n>' "$log" || fail "audio conversion did not use no-overwrite mode" +grep -Fq '<-i>' "$log" || fail "audio conversion input marker missing" +grep -Fq '' "$log" || fail "audio input path with spaces was lost" +grep -Fq '<-c:a>' "$log" || fail "audio codec option was lost" +grep -Fq '' "$log" || fail "audio codec value was lost" +grep -Fq '' "$log" || fail "audio output path with spaces was lost" +pass "FFmpeg audio conversion contract" + +: >"$log" +MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert video "$video_in" "$video_out" -- -c:v libx264 -crf 23 -c:a aac +[[ -f "$video_out" ]] || fail "video conversion did not publish output" +grep -Fq '' "$log" || fail "video input path missing" +grep -Fq '<-c:v>' "$log" || fail "video codec option missing" +grep -Fq '' "$log" || fail "video codec value missing" +grep -Fq '<-crf>' "$log" || fail "video CRF option missing" +grep -Fq '' "$log" || fail "video output path missing" +pass "FFmpeg video conversion contract" + +: >"$log" +MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert audio --force "$audio_in" "$audio_out" -- -c:a copy +grep -Fq '<-y>' "$log" || fail "--force did not map to FFmpeg overwrite mode" +pass "media conversion overwrite policy" + +for forbidden in -i -y -n; do + before="$(wc -l <"$log" | tr -d '[:space:]')" + set +e + MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \ + "$ROOT/lds" convert video --force "$video_in" "$video_out" -- "$forbidden" extra >/dev/null 2>"$tmp/forbidden.err" + rc=$? + set -e + after="$(wc -l <"$log" | tr -d '[:space:]')" + [[ "$rc" -eq 64 ]] || fail "reserved FFmpeg option $forbidden returned $rc instead of 64" + [[ "$before" == "$after" ]] || fail "reserved FFmpeg option $forbidden reached Docker" +done +grep -Fq 'owns FFmpeg input/output and overwrite selection' "$tmp/forbidden.err" || + fail "reserved FFmpeg option diagnostic missing" +pass "media converter owns FFmpeg input/output controls" + +formats="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert video --formats)" +grep -q matroska <<<"$formats" || fail "FFmpeg format discovery failed" +codecs="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert audio --codecs)" +grep -q mp3 <<<"$codecs" || fail "FFmpeg codec discovery failed" +encoders="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert video --encoders)" +grep -q libx264 <<<"$encoders" || fail "FFmpeg encoder discovery failed" +version="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert audio --version)" +grep -q '^ffmpeg version' <<<"$version" || fail "FFmpeg version discovery failed" +pass "media conversion capability discovery" diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1 index 6f8dd0ec..82c9a67d 100644 --- a/tests/windows-bridge.ps1 +++ b/tests/windows-bridge.ps1 @@ -54,11 +54,16 @@ Write-Host "PASS: Windows bridge quoting/discovery contract" $ldsPath = Join-Path $root "lds" $execPath = Join-Path $root "lib/container-exec.sh" +$conversionPath = Join-Path $root "lib/conversion.sh" if (-not (Test-Path $execPath)) { throw "shared container execution helper not found" } +if (-not (Test-Path $conversionPath)) { + throw "conversion helper not found" +} $ldsContent = Get-Content -Raw -Path $ldsPath $execContent = Get-Content -Raw -Path $execPath +$conversionContent = Get-Content -Raw -Path $conversionPath foreach ($needle in @( 'source "$DIR/lib/container-exec.sh"', '_is_public_lds_command()' @@ -78,3 +83,14 @@ foreach ($needle in @( } } Write-Host "PASS: Windows bridge uses MSYS-safe shared container execution" + +foreach ($needle in @( + '_convert_docker_mount_path', + 'MSYS_NO_PATHCONV=1', + "MSYS2_ARG_CONV_EXCL='*'" +)) { + if (-not $conversionContent.Contains($needle)) { + throw "conversion is missing Windows/Git Bash contract: $needle" + } +} +Write-Host "PASS: conversion uses MSYS-safe host mounts" diff --git a/tests/wrappers-contract.sh b/tests/wrappers-contract.sh index 771364e9..2f7f1816 100755 --- a/tests/wrappers-contract.sh +++ b/tests/wrappers-contract.sh @@ -20,15 +20,101 @@ done pass "wrapper syntax and Docker-DNS independence" runner="$ROOT/bin/tool-runner" -assert_file_contains "$runner" '[[ -t 0 ]] && flags+=(-i)' +assert_file_contains "$runner" 'stdin_has_data()' +assert_file_contains "$runner" 'if [[ -t 0 ]] || stdin_has_data; then' assert_file_contains "$runner" '[[ -t 1 ]] && flags+=(-t)' assert_file_contains "$runner" 'MSYS_NO_PATHCONV=1' assert_file_contains "$runner" 'MSYS2_ARG_CONV_EXCL=' assert_file_contains "$runner" '--network "container:$SERVER_TOOLS_CONTAINER"' assert_file_contains "$runner" '--volumes-from "$SERVER_TOOLS_CONTAINER"' +assert_file_contains "$runner" 'append_server_tools_env envs' +assert_file_contains "$runner" 'LDS_AI_RUNTIME' +assert_file_contains "$runner" 'LDS_AI_MODEL' +assert_file_contains "$runner" 'GIT_CREDENTIAL_MODE' assert_file_contains "$runner" 'exec "$(bin_path docker)" run' pass "tool-runner preserves TTY, path, namespace and exit-code contracts" +runner_tmp="$(mktemp -d)" +runner_bin="$runner_tmp/bin" +runner_log="$runner_tmp/docker.log" +runner_stdin="$runner_tmp/stdin.log" +runner_workspace="$runner_tmp/work space" +mkdir -p "$runner_bin" "$runner_workspace" + +cat >"$runner_bin/docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +: "${TOOL_RUNNER_TEST_LOG:?}" +: "${TOOL_RUNNER_TEST_STDIN:?}" + +if [[ "${1:-}" == inspect && "${2:-}" == -f ]]; then + case "${3:-}" in + *State.Running*) printf '%s\n' true ;; + *Config.Image*) printf '%s\n' infocyph/tools:test ;; + *Config.Env*) + cat <<'ENV' +TZ=Asia/Dhaka +USERNAME=tester +GIT_USER_NAME=Test User +GIT_USER_EMAIL=test@example.com +GIT_CREDENTIAL_MODE=store +LDS_AI_ENABLED=1 +LDS_AI_RUNTIME=npu +LDS_AI_MODEL=qwen-test +LDS_AI_THINK=medium +LDS_AI_TIMEOUT=321 +ENV + ;; + esac + exit 0 +fi + +if [[ "${1:-}" == exec ]]; then + exit 0 +fi + +if [[ "${1:-}" == run ]]; then + printf 'docker-run:' >>"$TOOL_RUNNER_TEST_LOG" + printf ' <%s>' "$@" >>"$TOOL_RUNNER_TEST_LOG" + printf '\n' >>"$TOOL_RUNNER_TEST_LOG" + cat >"$TOOL_RUNNER_TEST_STDIN" || true + exit 0 +fi + +exit 0 +SH +chmod +x "$runner_bin/docker" + +runner_err="$runner_tmp/runner.err" +set +e +printf '%s\n' 'stream payload' | ( + cd "$runner_workspace" + PATH="$runner_bin:$PATH" \ + WORKDIR="$runner_workspace" \ + TOOL_RUNNER_TEST_LOG="$runner_log" \ + TOOL_RUNNER_TEST_STDIN="$runner_stdin" \ + "$runner" chromacat --log +) 2>"$runner_err" +runner_rc=$? +set -e +if ((runner_rc != 0)); then + cat "$runner_err" >&2 || true + fail "tool-runner mock exited $runner_rc" +fi + +assert_file_contains "$runner_log" '<-i>' +assert_file_contains "$runner_log" '<--network> ' +assert_file_contains "$runner_log" '<--volumes-from> ' +assert_file_contains "$runner_log" '<-v>' +assert_file_contains "$runner_log" '<-e> ' +assert_file_contains "$runner_log" '<-e> ' +assert_file_contains "$runner_log" '<-e> ' +assert_file_contains "$runner_log" '<--entrypoint> ' +assert_file_contains "$runner_log" '<--log>' +assert_file_contains "$runner_stdin" 'stream payload' +rm -rf "$runner_tmp" +pass "tool-runner preserves piped stdin and active Tools runtime environment" + php="$ROOT/bin/php" assert_file_contains "$php" '-V|--v|--php)' assert_file_contains "$php" 'pick_highest_php_container()'