From 8b5b4b247baf6c6181a01450b3dc9e1439118420 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:05:22 +0600 Subject: [PATCH 01/84] fix(graphify): canonicalize docstruct merge before publish --- lib/ai.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/lib/ai.sh b/lib/ai.sh index 8c0b1acd..738a2f47 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" From 84ec675af602173354a53d024c8df3669c2ad788 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:05:50 +0600 Subject: [PATCH 02/84] test(graphify): require prepublish round-trip canonicalization --- tests/cli-contract.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index fc47583c..5c863d80 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -282,6 +282,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 +335,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 +367,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'" From df89d68b6db27d231026841582b6a5fbb6702406 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:10:49 +0600 Subject: [PATCH 03/84] docs(graphify): explain canonical docstruct publication --- docs/guides/local-ai.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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. From 13c8298b3d0e81e2d8543b42bb0b84847b65c651 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:10:53 +0600 Subject: [PATCH 04/84] docs(graphify): describe round-trip-safe hybrid flow --- docs/reference/cli.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 11ad0c24..0f3b337e 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -262,11 +262,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 From 4b80412cb5bc299e22a214855077f45613ae36ed Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:10:57 +0600 Subject: [PATCH 05/84] docs(graphify): document canonical staged handoff --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 607f039e..304b320f 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,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. From cbb2b0f0dcefc8dc95f064a75860f14dd025b9e6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:11:06 +0600 Subject: [PATCH 06/84] test(docs): lock round-trip-safe Graphify workflow --- tests/docs-contract.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index f3411e88..66c53173 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -107,6 +107,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' From 803df2816cece1eef37befe2956e127143ac9692 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:13:25 +0600 Subject: [PATCH 07/84] test(graphify): prove canonical round-trip idempotence --- tests/graphify-roundtrip-contract.sh | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/graphify-roundtrip-contract.sh 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" From 4930f94a06474aacd596aeb7d8c780361736afe6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:13:37 +0600 Subject: [PATCH 08/84] ci(graphify): validate canonical round trips on min and latest --- .github/workflows/check.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1e919c90..131c1c12 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -117,6 +117,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 +142,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 From e2a40cc8ce368684e9bdf673cf026003a866cb7a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:13:51 +0600 Subject: [PATCH 09/84] fix(graphify): keep round-trip copy portable --- lib/ai.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ai.sh b/lib/ai.sh index 738a2f47..eb2bbef9 100644 --- a/lib/ai.sh +++ b/lib/ai.sh @@ -330,7 +330,7 @@ _graphify_canonicalize_staged_graph() { roundtrip_input="$roundtrip_dir/staged.json" roundtrip_output="$roundtrip_dir/graphify-out/graph.json" - if ! cp -- "$staged_graph" "$roundtrip_input"; then + if ! cp "$staged_graph" "$roundtrip_input"; then rm -rf "$roundtrip_dir" die "Unable to stage merged graph for Graphify round-trip validation" fi @@ -352,7 +352,7 @@ _graphify_canonicalize_staged_graph() { die "Graphify round-trip validation did not produce graph.json" fi - if ! cp -- "$roundtrip_output" "$staged_graph"; then + if ! cp "$roundtrip_output" "$staged_graph"; then rm -rf "$roundtrip_dir" die "Unable to stage Graphify-canonical document merge" fi From 76974e6cea5d5b07116f65e73b8c039e39935f08 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:27:25 +0600 Subject: [PATCH 10/84] feat(documents): add containerized Pandoc conversion --- lib/documents.sh | 167 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 lib/documents.sh diff --git a/lib/documents.sh b/lib/documents.sh new file mode 100644 index 00000000..afc4309c --- /dev/null +++ b/lib/documents.sh @@ -0,0 +1,167 @@ +# shellcheck shell=bash + +_DOCUMENT_CONVERT_IMAGE="infocyph/tools:latest" + +_convert_usage() { + cat <<'EOF' +Usage: + lds convert [--force] [--] [pandoc-options...] + lds convert --list-input-formats + lds convert --list-output-formats + lds convert --version + +Examples: + lds convert README.md README.html + lds convert docs/guide.rst guide.docx --toc + lds convert report.docx report.md --wrap=none + lds convert book.md book.epub --toc + +The conversion runs in a short-lived Tools container; Pandoc is not required on +the host. Relative auxiliary files referenced by Pandoc should live under the +input file's directory. Existing output files require --force. +EOF +} + +_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_reject_output_option() { + local arg + for arg in "$@"; do + case "$arg" in + -o|--output|--output=*) + err "lds convert owns Pandoc output selection; use the second LDS path argument instead of $arg" + return 64 + ;; + esac + done +} + +_convert_run_pandoc() { + local -a args=("$@") + + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then + ( + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + "$(bin_path docker)" run --rm --pull=missing --entrypoint pandoc "$_DOCUMENT_CONVERT_IMAGE" "${args[@]}" + ) + else + "$(bin_path docker)" run --rm --pull=missing --entrypoint pandoc "$_DOCUMENT_CONVERT_IMAGE" "${args[@]}" + fi +} + +cmd_convert() { + local force=0 input='' output='' input_abs output_abs + local input_dir output_dir input_name output_name input_mount output_mount + local -a pandoc_args=() + + case "${1:-}" in + ""|-h|--help|help) + _convert_usage + return 0 + ;; + --version|--list-input-formats|--list-output-formats) + _convert_run_pandoc "$1" + return $? + ;; + --force) + force=1 + shift + ;; + esac + + input="${1:-}" + output="${2:-}" + [[ -n "$input" && -n "$output" ]] || { + _convert_usage >&2 + return 64 + } + shift 2 + + [[ "${1:-}" != "--" ]] || shift + pandoc_args=("$@") + _convert_reject_output_option "${pandoc_args[@]}" || return $? + + input_abs="$(_convert_abs_existing_file "$input")" || { + err "Input document not found or is not a regular file: $input" + return 66 + } + output_abs="$(_convert_abs_output "$output")" || { + err "Output directory does not exist: $(dirname -- "$output")" + return 66 + } + + [[ "$input_abs" != "$output_abs" ]] || { + err "Input and output must be different files" + return 64 + } + if [[ -e "$output_abs" && "$force" -ne 1 ]]; then + err "Output already exists: $output_abs (use --force to replace it)" + return 73 + fi + + input_dir="$(dirname -- "$input_abs")" + output_dir="$(dirname -- "$output_abs")" + input_name="$(basename -- "$input_abs")" + output_name="$(basename -- "$output_abs")" + + input_mount="$(_convert_docker_mount_path "$input_dir")" || { + err "Unable to resolve input directory for Docker: $input_dir" + return 66 + } + output_mount="$(_convert_docker_mount_path "$output_dir")" || { + err "Unable to resolve output directory for Docker: $output_dir" + return 66 + } + + local -a docker_args=( + -v "$input_mount:/lds-input:ro" + -v "$output_mount:/lds-output" + -w /lds-input + --resource-path=/lds-input + "./$input_name" + -o "/lds-output/$output_name" + ) + docker_args+=("${pandoc_args[@]}") + + _convert_run_pandoc "${docker_args[@]}" +} From 45c6f9b6a35a1cabed2e15bb534d58a2f85930af Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:27:49 +0600 Subject: [PATCH 11/84] fix(documents): order Docker mounts before Tools image --- lib/documents.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/documents.sh b/lib/documents.sh index afc4309c..049a1697 100644 --- a/lib/documents.sh +++ b/lib/documents.sh @@ -153,15 +153,26 @@ cmd_convert() { return 66 } - local -a docker_args=( + local -a run_args=( + run --rm --pull=missing -v "$input_mount:/lds-input:ro" -v "$output_mount:/lds-output" -w /lds-input + --entrypoint pandoc + "$_DOCUMENT_CONVERT_IMAGE" --resource-path=/lds-input "./$input_name" -o "/lds-output/$output_name" ) - docker_args+=("${pandoc_args[@]}") + run_args+=("${pandoc_args[@]}") - _convert_run_pandoc "${docker_args[@]}" + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then + ( + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + "$(bin_path docker)" "${run_args[@]}" + ) + else + "$(bin_path docker)" "${run_args[@]}" + fi } From 751616bb8de8dc06879223437041331e1f3d3e25 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:28:31 +0600 Subject: [PATCH 12/84] feat(cli): expose host document conversion --- lds | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lds b/lds index f5f44b79..20f5a782 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/documents.sh +source "$DIR/lib/documents.sh" + # ───────────────────────────────────────────────────────────────────────────── # 6d. DIAG / SNIFF # ───────────────────────────────────────────────────────────────────────────── @@ -791,6 +794,10 @@ cmd_help() { ## AI consumer - `lds ai status|ask|explain|troubleshoot|review|repo-review|graphify ...` +## Document conversion +- `lds convert [--force] [--] [pandoc-options...]` +- `lds convert --list-input-formats|--list-output-formats|--version` + ## Host Graphify workflow - `lds graphify [path] [graphify-extract-options...]` @@ -875,6 +882,10 @@ ${CYAN}Execution / Shells:${NC} ${CYAN}Secrets:${NC} secrets +${CYAN}Documents:${NC} + convert [--force] [--] [pandoc-options...] + convert --list-input-formats|--list-output-formats|--version + ${CYAN}AI:${NC} ai status|ask|explain|troubleshoot|review|repo-review|graphify graphify [path] [graphify-extract-options...] @@ -903,7 +914,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 From d4eed24a2168a1b680c225fc547835fb1243d07c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:29:11 +0600 Subject: [PATCH 13/84] test(documents): cover host Pandoc conversion --- tests/document-convert-contract.sh | 136 +++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/document-convert-contract.sh diff --git a/tests/document-convert-contract.sh b/tests/document-convert-contract.sh new file mode 100644 index 00000000..6083863d --- /dev/null +++ b/tests/document-convert-contract.sh @@ -0,0 +1,136 @@ +#!/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 "$input" "$output" --toc --standalone + +[[ -f "$output" ]] || fail "lds convert did not publish the host output file" +grep -Fq "<$tmp/Input Docs:/lds-input:ro>" "$log" || + fail "lds convert did not mount the input directory read-only" +grep -Fq "<$tmp/Output Docs:/lds-output>" "$log" || + fail "lds convert did not mount the output directory writable" +grep -Fq '<--entrypoint>' "$log" || fail "lds convert did not use an explicit Pandoc entrypoint" +grep -Fq '' "$log" || fail "lds convert did not invoke Pandoc" +grep -Fq '<--resource-path=/lds-input>' "$log" || + fail "lds convert 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 must not expose the Docker socket" +fi +if grep -Fq '' "$log"; then + fail "lds convert 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 "$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 --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 --force "$input" "$output" -- -o elsewhere.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" +pass "document conversion owns output path" + +formats="$( + DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert --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 --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 "$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 "$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" From 4e762918e56479bae6090e9224785cbc93b6fa49 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:29:49 +0600 Subject: [PATCH 14/84] ci(documents): run Pandoc conversion contract --- .github/workflows/check.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 131c1c12..077cfb19 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -49,7 +49,8 @@ jobs: - name: Container execution substrate contract run: tests/container-exec-contract.sh - + - name: Document conversion contract + run: bash tests/document-convert-contract.sh - name: Environment contract run: tests/env-contract.sh From bc08579886939eed0de5de36da946bff2d9eb179 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:29:54 +0600 Subject: [PATCH 15/84] test(cli): expose document conversion command --- tests/cli-contract.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index 5c863d80..d131afe1 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -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,11 @@ 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 [--force] " +assert_contains "$markdown_output" "lds convert [--force] " +assert_contains "$markdown_output" "lds convert --list-input-formats" +pass "document conversion is exposed in embedded help" + graphify_log="$(mktemp)" cat >"$tmpbin/graphify" <<'SH' #!/usr/bin/env sh From b856a15b69ef098ec55f00a1ac5cbee7a93f8989 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:30:34 +0600 Subject: [PATCH 16/84] docs(documents): add Pandoc conversion guide --- docs/guides/document-conversion.rst | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/guides/document-conversion.rst diff --git a/docs/guides/document-conversion.rst b/docs/guides/document-conversion.rst new file mode 100644 index 00000000..ae235489 --- /dev/null +++ b/docs/guides/document-conversion.rst @@ -0,0 +1,76 @@ +Document 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 README.md README.html + lds convert docs/guide.rst guide.docx + lds convert report.docx report.md + lds convert 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 README.md README.html --toc --standalone + lds convert report.docx report.md --wrap=none + lds convert book.md book.epub --metadata title="Developer Guide" + +An optional ``--`` separator is accepted:: + + lds convert 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 --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 --list-input-formats + lds convert --list-output-formats + lds convert --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. From c4d22781f0124da6075f6911ab90e6c646e11063 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:30:57 +0600 Subject: [PATCH 17/84] docs(documents): link conversion guide --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index f496b401..6bc58b61 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/document-conversion guides/tls-and-certificates guides/local-ai guides/operations-and-support From 3c36ae80c9aaf1b6fba6f2199dd01b3b38456378 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:31:01 +0600 Subject: [PATCH 18/84] docs(cli): document host conversion command --- docs/reference/cli.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 0f3b337e..1bb24f10 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -108,6 +108,19 @@ Configuration ``config show`` is redacted by default. +Document Conversion +------------------- + +Pandoc-backed host conversion runs in a short-lived Tools container; the stack does not +need to be running:: + + lds convert [--force] [--] [pandoc-options...] + lds convert --list-input-formats + lds convert --list-output-formats + lds convert --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. Certificates ------------ From fe8917325ad0e3705ca08403caedd03798fa5792 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:31:04 +0600 Subject: [PATCH 19/84] docs(quickstart): mention document conversion --- docs/quickstart.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 53fa8143..ffe8f8c8 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -125,6 +125,13 @@ qualified target when names collide. ``lds config show`` is redacted by default. Use ``--raw`` only when unredacted output is deliberately required. +Document conversion is also available without starting the stack:: + + lds convert README.md README.html + +Pandoc runs from the Tools image; use ``lds convert --list-output-formats`` to inspect the +writers available in the current image. + Updating an Existing Installation --------------------------------- From 16f45df2ca1a7003cacef19392efb5d8919eb17d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:31:08 +0600 Subject: [PATCH 20/84] docs(readme): add containerized document conversion --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 304b320f..5e7bce41 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,24 @@ lds es ... See `docs/guides/databases-and-clients.rst` for the profile/client map. +## Document conversion + +Pandoc is available from the Tools image without installing it on the host or starting +the LocalDevStack services: + +```bash +lds convert README.md README.html +lds convert docs/guide.rst guide.docx --toc +lds convert report.docx report.md --wrap=none +lds convert book.md book.epub --toc +lds convert --list-input-formats +lds convert --list-output-formats +``` + +The input directory is mounted read-only; only the output directory 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`. From 2a70ad7664044c505e460142778058f0512ac839 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:31:28 +0600 Subject: [PATCH 21/84] fix(documents): protect against output symlink overwrite --- lib/documents.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/documents.sh b/lib/documents.sh index 049a1697..821e4461 100644 --- a/lib/documents.sh +++ b/lib/documents.sh @@ -90,7 +90,7 @@ _convert_run_pandoc() { } cmd_convert() { - local force=0 input='' output='' input_abs output_abs + local force=0 input='' output='' input_abs output_abs existing_output_abs local input_dir output_dir input_name output_name input_mount output_mount local -a pandoc_args=() @@ -134,6 +134,13 @@ cmd_convert() { err "Input and output must be different files" return 64 } + if [[ -e "$output_abs" ]]; then + existing_output_abs="$(_realpath "$output_abs" 2>/dev/null || true)" + [[ -z "$existing_output_abs" || "$existing_output_abs" != "$input_abs" ]] || { + err "Input and output must be different files" + return 64 + } + fi if [[ -e "$output_abs" && "$force" -ne 1 ]]; then err "Output already exists: $output_abs (use --force to replace it)" return 73 From 35402aae821998bbcc2339ae5f70902bf4b47537 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:31:32 +0600 Subject: [PATCH 22/84] test(docs): lock document conversion surface --- tests/docs-contract.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 66c53173..c8c728a7 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -15,18 +15,26 @@ 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" +documents="$ROOT/docs/guides/document-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" "$documents" "$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/document-conversion' +assert_file_contains "$documents" 'lds convert README.md README.html' +assert_file_contains "$documents" '--list-output-formats' +assert_file_contains "$documents" 'no Docker socket' +assert_file_contains "$cli" 'lds convert [--force] ' +assert_file_contains "$quick" 'lds convert README.md README.html' +assert_file_contains "$readme" 'lds convert docs/guide.rst guide.docx --toc' assert_file_contains "$index" 'guides/operations-and-support' assert_file_contains "$index" 'guides/ad-hoc-runner' assert_file_contains "$index" 'reference/cli' From 4761a5fd3008f7fd0a21dffb2413e804a35b4cd4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:33:31 +0600 Subject: [PATCH 23/84] fix(documents): reserve compact Pandoc output options --- lib/documents.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/documents.sh b/lib/documents.sh index 821e4461..79333054 100644 --- a/lib/documents.sh +++ b/lib/documents.sh @@ -67,7 +67,7 @@ _convert_reject_output_option() { local arg for arg in "$@"; do case "$arg" in - -o|--output|--output=*) + -o|-o?*|--output|--output=*) err "lds convert owns Pandoc output selection; use the second LDS path argument instead of $arg" return 64 ;; From cdcd807c4e8f397a6da0b04af39943d3819cc482 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:33:34 +0600 Subject: [PATCH 24/84] test(documents): cover symlink and compact output safety --- tests/document-convert-contract.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/document-convert-contract.sh b/tests/document-convert-contract.sh index 6083863d..b9973dd0 100644 --- a/tests/document-convert-contract.sh +++ b/tests/document-convert-contract.sh @@ -102,7 +102,7 @@ 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 --force "$input" "$output" -- -o elsewhere.html >/dev/null 2>"$tmp/output-option.err" +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert --force "$input" "$output" -- -oelsewhere.html >/dev/null 2>"$tmp/output-option.err" rc=$? set -e after="$(wc -l <"$log" | tr -d '[:space:]')" @@ -110,7 +110,19 @@ after="$(wc -l <"$log" | tr -d '[:space:]')" [[ "$before" == "$after" ]] || fail "conflicting output option reached Docker" grep -Fq 'owns Pandoc output selection' "$tmp/output-option.err" || fail "conflicting output option diagnostic missing" -pass "document conversion owns output path" +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 --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 --list-input-formats From 662de3332f8e3abe6786a1ecfc48f65568627c62 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:33:48 +0600 Subject: [PATCH 25/84] test(windows): lock document conversion path safety --- tests/windows-bridge.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1 index 6f8dd0ec..7d7f1a2c 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" +$documentsPath = Join-Path $root "lib/documents.sh" if (-not (Test-Path $execPath)) { throw "shared container execution helper not found" } +if (-not (Test-Path $documentsPath)) { + throw "document conversion helper not found" +} $ldsContent = Get-Content -Raw -Path $ldsPath $execContent = Get-Content -Raw -Path $execPath +$documentsContent = Get-Content -Raw -Path $documentsPath 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 $documentsContent.Contains($needle)) { + throw "document conversion is missing Windows/Git Bash contract: $needle" + } +} +Write-Host "PASS: document conversion uses MSYS-safe host mounts" From 40ddfd6322d97a557acff8dead898d5a88899658 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 11:34:55 +0600 Subject: [PATCH 26/84] ci(documents): smoke real Tools Pandoc conversion --- .github/workflows/check.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 077cfb19..bd9fa792 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -185,6 +185,17 @@ 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 "$tmp/input/source.md" "$tmp/output/source.html" --standalone + grep -Fq ' Date: Thu, 24 Sep 2026 12:18:00 +0600 Subject: [PATCH 27/84] refactor(convert): namespace docs and image conversion --- lib/conversion.sh | 306 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 lib/conversion.sh diff --git a/lib/conversion.sh b/lib/conversion.sh new file mode 100644 index 00000000..01eafd0b --- /dev/null +++ b/lib/conversion.sh @@ -0,0 +1,306 @@ +# 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 + +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 +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_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 --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|tif|tiff|ico|avif|heic|heif) 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 + -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 + -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 "$@" + ;; + ''|-h|--help|help) + _convert_usage + ;; + *) + err "Unknown conversion type: $kind (expected docs or image)" + _convert_usage >&2 + return 64 + ;; + esac +} From e2f49c68e375330d40b353f4a1f9e438d4184526 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:19:03 +0600 Subject: [PATCH 28/84] refactor(convert): expose docs and image subcommands --- lds | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lds b/lds index 20f5a782..d9dd8bb0 100755 --- a/lds +++ b/lds @@ -484,8 +484,8 @@ source "$DIR/lib/certificates.sh" # shellcheck source=lib/services.sh source "$DIR/lib/services.sh" -# shellcheck source=lib/documents.sh -source "$DIR/lib/documents.sh" +# shellcheck source=lib/conversion.sh +source "$DIR/lib/conversion.sh" # ───────────────────────────────────────────────────────────────────────────── # 6d. DIAG / SNIFF @@ -794,9 +794,11 @@ cmd_help() { ## AI consumer - `lds ai status|ask|explain|troubleshoot|review|repo-review|graphify ...` -## Document conversion -- `lds convert [--force] [--] [pandoc-options...]` -- `lds convert --list-input-formats|--list-output-formats|--version` +## 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` ## Host Graphify workflow - `lds graphify [path] [graphify-extract-options...]` @@ -882,9 +884,11 @@ ${CYAN}Execution / Shells:${NC} ${CYAN}Secrets:${NC} secrets -${CYAN}Documents:${NC} - convert [--force] [--] [pandoc-options...] - convert --list-input-formats|--list-output-formats|--version +${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 ${CYAN}AI:${NC} ai status|ask|explain|troubleshoot|review|repo-review|graphify From ce2bbca34b668e5dff16f1b635d7f5e1527a3188 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:19:17 +0600 Subject: [PATCH 29/84] refactor(convert): remove superseded documents helper --- lib/documents.sh | 185 ----------------------------------------------- 1 file changed, 185 deletions(-) delete mode 100644 lib/documents.sh diff --git a/lib/documents.sh b/lib/documents.sh deleted file mode 100644 index 79333054..00000000 --- a/lib/documents.sh +++ /dev/null @@ -1,185 +0,0 @@ -# shellcheck shell=bash - -_DOCUMENT_CONVERT_IMAGE="infocyph/tools:latest" - -_convert_usage() { - cat <<'EOF' -Usage: - lds convert [--force] [--] [pandoc-options...] - lds convert --list-input-formats - lds convert --list-output-formats - lds convert --version - -Examples: - lds convert README.md README.html - lds convert docs/guide.rst guide.docx --toc - lds convert report.docx report.md --wrap=none - lds convert book.md book.epub --toc - -The conversion runs in a short-lived Tools container; Pandoc is not required on -the host. Relative auxiliary files referenced by Pandoc should live under the -input file's directory. Existing output files require --force. -EOF -} - -_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_reject_output_option() { - local arg - for arg in "$@"; do - case "$arg" in - -o|-o?*|--output|--output=*) - err "lds convert owns Pandoc output selection; use the second LDS path argument instead of $arg" - return 64 - ;; - esac - done -} - -_convert_run_pandoc() { - local -a args=("$@") - - if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then - ( - export MSYS_NO_PATHCONV=1 - export MSYS2_ARG_CONV_EXCL='*' - "$(bin_path docker)" run --rm --pull=missing --entrypoint pandoc "$_DOCUMENT_CONVERT_IMAGE" "${args[@]}" - ) - else - "$(bin_path docker)" run --rm --pull=missing --entrypoint pandoc "$_DOCUMENT_CONVERT_IMAGE" "${args[@]}" - fi -} - -cmd_convert() { - local force=0 input='' output='' input_abs output_abs existing_output_abs - local input_dir output_dir input_name output_name input_mount output_mount - local -a pandoc_args=() - - case "${1:-}" in - ""|-h|--help|help) - _convert_usage - return 0 - ;; - --version|--list-input-formats|--list-output-formats) - _convert_run_pandoc "$1" - return $? - ;; - --force) - force=1 - shift - ;; - esac - - input="${1:-}" - output="${2:-}" - [[ -n "$input" && -n "$output" ]] || { - _convert_usage >&2 - return 64 - } - shift 2 - - [[ "${1:-}" != "--" ]] || shift - pandoc_args=("$@") - _convert_reject_output_option "${pandoc_args[@]}" || return $? - - input_abs="$(_convert_abs_existing_file "$input")" || { - err "Input document not found or is not a regular file: $input" - return 66 - } - output_abs="$(_convert_abs_output "$output")" || { - err "Output directory does not exist: $(dirname -- "$output")" - return 66 - } - - [[ "$input_abs" != "$output_abs" ]] || { - err "Input and output must be different files" - return 64 - } - if [[ -e "$output_abs" ]]; then - existing_output_abs="$(_realpath "$output_abs" 2>/dev/null || true)" - [[ -z "$existing_output_abs" || "$existing_output_abs" != "$input_abs" ]] || { - err "Input and output must be different files" - return 64 - } - fi - if [[ -e "$output_abs" && "$force" -ne 1 ]]; then - err "Output already exists: $output_abs (use --force to replace it)" - return 73 - fi - - input_dir="$(dirname -- "$input_abs")" - output_dir="$(dirname -- "$output_abs")" - input_name="$(basename -- "$input_abs")" - output_name="$(basename -- "$output_abs")" - - input_mount="$(_convert_docker_mount_path "$input_dir")" || { - err "Unable to resolve input directory for Docker: $input_dir" - return 66 - } - output_mount="$(_convert_docker_mount_path "$output_dir")" || { - err "Unable to resolve output directory for Docker: $output_dir" - return 66 - } - - local -a run_args=( - run --rm --pull=missing - -v "$input_mount:/lds-input:ro" - -v "$output_mount:/lds-output" - -w /lds-input - --entrypoint pandoc - "$_DOCUMENT_CONVERT_IMAGE" - --resource-path=/lds-input - "./$input_name" - -o "/lds-output/$output_name" - ) - run_args+=("${pandoc_args[@]}") - - if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then - ( - export MSYS_NO_PATHCONV=1 - export MSYS2_ARG_CONV_EXCL='*' - "$(bin_path docker)" "${run_args[@]}" - ) - else - "$(bin_path docker)" "${run_args[@]}" - fi -} From dbc49518e191c8970a25d177e898b672a7cba35d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:20:38 +0600 Subject: [PATCH 30/84] hardening(convert): isolate conversion containers --- lib/conversion.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/conversion.sh b/lib/conversion.sh index 01eafd0b..1af31cfd 100644 --- a/lib/conversion.sh +++ b/lib/conversion.sh @@ -148,7 +148,7 @@ _convert_engine() { local entrypoint="${1:-}" shift || true [[ -n "$entrypoint" ]] || return 64 - _convert_docker run --rm --pull=missing --entrypoint "$entrypoint" "$_CONVERT_TOOLS_IMAGE" "$@" + _convert_docker run --rm --pull=missing --network none --entrypoint "$entrypoint" "$_CONVERT_TOOLS_IMAGE" "$@" } _convert_reject_pandoc_output_option() { @@ -216,7 +216,9 @@ _convert_docs() { _convert_prepare_paths "$input" "$output" "$force" || return $? run_args=( - run --rm --pull=missing + 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 @@ -271,7 +273,9 @@ _convert_image() { fi run_args=( - run --rm --pull=missing + 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 From c7d6fa09d4638f18dfeca5a75e1facb6ae64855b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:20:42 +0600 Subject: [PATCH 31/84] test(convert): move document conversion under docs namespace --- tests/document-convert-contract.sh | 37 ++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/document-convert-contract.sh b/tests/document-convert-contract.sh index b9973dd0..30850aeb 100644 --- a/tests/document-convert-contract.sh +++ b/tests/document-convert-contract.sh @@ -65,44 +65,47 @@ 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 "$input" "$output" --toc --standalone +DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$output" --toc --standalone -[[ -f "$output" ]] || fail "lds convert did not publish the host output file" +[[ -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 did not mount the input directory read-only" + fail "lds convert docs did not mount the input directory read-only" grep -Fq "<$tmp/Output Docs:/lds-output>" "$log" || - fail "lds convert did not mount the output directory writable" -grep -Fq '<--entrypoint>' "$log" || fail "lds convert did not use an explicit Pandoc entrypoint" -grep -Fq '' "$log" || fail "lds convert did not invoke Pandoc" + 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 did not preserve relative input resources" + 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 must not expose the Docker socket" + fail "lds convert docs must not expose the Docker socket" fi if grep -Fq '' "$log"; then - fail "lds convert must not require a running Compose stack" + 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 "$input" "$output" >/dev/null 2>"$tmp/existing.err" +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 --force "$input" "$output" --wrap=none +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 --force "$input" "$output" -- -oelsewhere.html >/dev/null 2>"$tmp/output-option.err" +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:]')" @@ -114,7 +117,7 @@ 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 --force "$input" "$tmp/Output Docs/input-link.md" >/dev/null 2>"$tmp/same.err" + "$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:]')" @@ -125,23 +128,23 @@ grep -Fq 'Input and output must be different files' "$tmp/same.err" || pass "document conversion blocks in-place and symlink overwrite" formats="$( - DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert --list-input-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 --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 "$tmp/missing.md" "$tmp/Output Docs/missing.html" >/dev/null 2>"$tmp/missing.err" +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 "$input" "$tmp/no-such-dir/output.html" >/dev/null 2>"$tmp/outdir.err" +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" From dca79467dda13b5cc2664a4f368dab6e9e5a3a94 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:21:20 +0600 Subject: [PATCH 32/84] test(convert): cover ImageMagick image conversion --- tests/image-convert-contract.sh | 119 ++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/image-convert-contract.sh diff --git a/tests/image-convert-contract.sh b/tests/image-convert-contract.sh new file mode 100644 index 00000000..08c00c11 --- /dev/null +++ b/tests/image-convert-contract.sh @@ -0,0 +1,119 @@ +#!/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" +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" From c7c21f551cd659dff91cfedc6470b661fd922664 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:21:38 +0600 Subject: [PATCH 33/84] ci(convert): validate docs and image conversion --- .github/workflows/check.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index bd9fa792..1b19d4f1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -49,8 +49,10 @@ jobs: - name: Container execution substrate contract run: tests/container-exec-contract.sh - - name: Document conversion contract - run: bash tests/document-convert-contract.sh + - name: Conversion contracts + run: | + bash tests/document-convert-contract.sh + bash tests/image-convert-contract.sh - name: Environment contract run: tests/env-contract.sh @@ -192,9 +194,9 @@ jobs: 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 "$tmp/input/source.md" "$tmp/output/source.html" --standalone + ./lds convert docs "$tmp/input/source.md" "$tmp/output/source.html" --standalone grep -Fq ' Date: Thu, 24 Sep 2026 12:21:41 +0600 Subject: [PATCH 34/84] test(windows): follow unified conversion helper --- tests/windows-bridge.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1 index 7d7f1a2c..d641e2f8 100644 --- a/tests/windows-bridge.ps1 +++ b/tests/windows-bridge.ps1 @@ -54,16 +54,16 @@ Write-Host "PASS: Windows bridge quoting/discovery contract" $ldsPath = Join-Path $root "lds" $execPath = Join-Path $root "lib/container-exec.sh" -$documentsPath = Join-Path $root "lib/documents.sh" +$conversionPath = Join-Path $root "lib/conversion.sh" if (-not (Test-Path $execPath)) { throw "shared container execution helper not found" } -if (-not (Test-Path $documentsPath)) { - throw "document conversion 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 -$documentsContent = Get-Content -Raw -Path $documentsPath +$conversionContent = Get-Content -Raw -Path $conversionPath foreach ($needle in @( 'source "$DIR/lib/container-exec.sh"', '_is_public_lds_command()' @@ -89,8 +89,8 @@ foreach ($needle in @( 'MSYS_NO_PATHCONV=1', "MSYS2_ARG_CONV_EXCL='*'" )) { - if (-not $documentsContent.Contains($needle)) { - throw "document conversion is missing Windows/Git Bash contract: $needle" + if (-not $conversionContent.Contains($needle)) { + throw "conversion is missing Windows/Git Bash contract: $needle" } } Write-Host "PASS: document conversion uses MSYS-safe host mounts" From fef62f1f0461cd9076cb61aca458963e2d946387 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:21:46 +0600 Subject: [PATCH 35/84] test(cli): expose convert namespace --- tests/cli-contract.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index d131afe1..47f1ee2d 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -67,9 +67,11 @@ 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 [--force] " -assert_contains "$markdown_output" "lds convert [--force] " -assert_contains "$markdown_output" "lds convert --list-input-formats" +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" pass "document conversion is exposed in embedded help" graphify_log="$(mktemp)" From 6bd6e8536beaa16cf37dba3626ed6294c1fee508 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:22:12 +0600 Subject: [PATCH 36/84] docs(convert): namespace docs and image conversion --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5e7bce41..95d16302 100644 --- a/README.md +++ b/README.md @@ -230,15 +230,18 @@ Pandoc is available from the Tools image without installing it on the host or st the LocalDevStack services: ```bash -lds convert README.md README.html -lds convert docs/guide.rst guide.docx --toc -lds convert report.docx report.md --wrap=none -lds convert book.md book.epub --toc -lds convert --list-input-formats -lds convert --list-output-formats +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 ``` -The input directory is mounted read-only; only the output directory is writable. The +Documents use Pandoc; images use ImageMagick. 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. From 296dbde23bb9894b96742857705bcb6176a88e01 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:22:15 +0600 Subject: [PATCH 37/84] docs(convert): introduce docs and image namespaces --- docs/quickstart.rst | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index ffe8f8c8..fca8cdce 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -127,11 +127,16 @@ deliberately required. Document conversion is also available without starting the stack:: - lds convert README.md README.html + lds convert docs README.md README.html -Pandoc runs from the Tools image; use ``lds convert --list-output-formats`` to inspect the +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 + + Updating an Existing Installation --------------------------------- From 914593318c80afed4db6c5b68f995df153342def Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:22:19 +0600 Subject: [PATCH 38/84] docs(convert): document docs and image subcommands --- docs/reference/cli.rst | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 1bb24f10..7e4ded67 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -108,19 +108,27 @@ Configuration ``config show`` is redacted by default. -Document Conversion -------------------- +Conversion +---------- Pandoc-backed host conversion runs in a short-lived Tools container; the stack does not need to be running:: - lds convert [--force] [--] [pandoc-options...] - lds convert --list-input-formats - lds convert --list-output-formats - lds convert --version + 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. Certificates ------------ From 7d2a41ead39364792e7a01cb09ff89ef6cd98daa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:22:26 +0600 Subject: [PATCH 39/84] docs(convert): cover image conversion alongside docs --- docs/guides/document-conversion.rst | 36 +++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/guides/document-conversion.rst b/docs/guides/document-conversion.rst index ae235489..1f8ce23e 100644 --- a/docs/guides/document-conversion.rst +++ b/docs/guides/document-conversion.rst @@ -10,10 +10,10 @@ Basic Usage Convert one host file to another:: - lds convert README.md README.html - lds convert docs/guide.rst guide.docx - lds convert report.docx report.md - lds convert book.md book.epub --toc + 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 @@ -24,13 +24,13 @@ Pandoc Options Arguments after the output path are passed to Pandoc without shell flattening:: - lds convert README.md README.html --toc --standalone - lds convert report.docx report.md --wrap=none - lds convert book.md book.epub --metadata title="Developer Guide" + 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 README.md README.html -- --toc --standalone + 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. @@ -50,7 +50,7 @@ Overwrite Safety Existing output files are not replaced unless ``--force`` is supplied:: - lds convert --force README.md README.html + lds convert docs --force README.md README.html The input and output may not resolve to the same file. @@ -60,9 +60,9 @@ Format Discovery The supported readers and writers come from the Pandoc version currently shipped by the Tools image:: - lds convert --list-input-formats - lds convert --list-output-formats - lds convert --version + 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 @@ -74,3 +74,15 @@ 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. From ceffd27c0c8993202a3c29c5208cf3a49a6ada8f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:23:00 +0600 Subject: [PATCH 40/84] test(docs): lock docs and image conversion namespaces --- tests/docs-contract.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index c8c728a7..b39dbf08 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -29,12 +29,17 @@ done assert_file_contains "$index" 'guides/local-ai' assert_file_contains "$index" 'guides/databases-and-clients' assert_file_contains "$index" 'guides/document-conversion' -assert_file_contains "$documents" 'lds convert README.md README.html' -assert_file_contains "$documents" '--list-output-formats' +assert_file_contains "$documents" 'lds convert docs README.md README.html' +assert_file_contains "$documents" 'lds convert docs --list-output-formats' +assert_file_contains "$documents" 'lds convert image photo.jpg photo.png' +assert_file_contains "$documents" 'lds convert image --formats' assert_file_contains "$documents" 'no Docker socket' -assert_file_contains "$cli" 'lds convert [--force] ' -assert_file_contains "$quick" 'lds convert README.md README.html' -assert_file_contains "$readme" 'lds convert docs/guide.rst guide.docx --toc' +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 "$index" 'guides/operations-and-support' assert_file_contains "$index" 'guides/ad-hoc-runner' assert_file_contains "$index" 'reference/cli' From 17ad2fd1e29b4715515d4d210b73334e55e60a84 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:23:57 +0600 Subject: [PATCH 41/84] docs(convert): rename guide for docs and images --- docs/guides/conversion.rst | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/guides/conversion.rst diff --git a/docs/guides/conversion.rst b/docs/guides/conversion.rst new file mode 100644 index 00000000..37941de2 --- /dev/null +++ b/docs/guides/conversion.rst @@ -0,0 +1,88 @@ +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. From a086ccd9e9e9f8d263e2d1a4070d2c7dfa819f7c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:24:01 +0600 Subject: [PATCH 42/84] docs(convert): remove document-only guide path --- docs/guides/document-conversion.rst | 88 ----------------------------- 1 file changed, 88 deletions(-) delete mode 100644 docs/guides/document-conversion.rst diff --git a/docs/guides/document-conversion.rst b/docs/guides/document-conversion.rst deleted file mode 100644 index 1f8ce23e..00000000 --- a/docs/guides/document-conversion.rst +++ /dev/null @@ -1,88 +0,0 @@ -Document 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. From 5a34ff3d0bf27a28b4aa6b42b3f781294082d9b2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:24:14 +0600 Subject: [PATCH 43/84] docs(convert): correct ImageMagick option examples --- lib/conversion.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/conversion.sh b/lib/conversion.sh index 1af31cfd..61c70aba 100644 --- a/lib/conversion.sh +++ b/lib/conversion.sh @@ -22,7 +22,7 @@ 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 photo.png photo.webp -- -quality 82 lds convert image animation.gif animation.webp EOF } @@ -46,7 +46,7 @@ Usage: Examples: lds convert image photo.jpg photo.png - lds convert image photo.png photo.webp --quality 82 + lds convert image photo.png photo.webp -- -quality 82 lds convert image animation.gif animation.webp lds convert image animation.gif preview.jpg From 0d6ac13ba355ca5ab1dc96ebd5622589d08bd864 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:24:18 +0600 Subject: [PATCH 44/84] docs(convert): point to unified conversion guide --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 6bc58b61..378aec29 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,7 +28,7 @@ LocalDevStack is designed for trusted developer workstations, not production dep guides/domain-setup guides/databases-and-clients - guides/document-conversion + guides/conversion guides/tls-and-certificates guides/local-ai guides/operations-and-support From 1b703d69c29c0615a2087a953c43127cba401589 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:24:22 +0600 Subject: [PATCH 45/84] test(docs): follow unified conversion guide --- tests/docs-contract.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index b39dbf08..b89b1bc1 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -15,7 +15,7 @@ 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" -documents="$ROOT/docs/guides/document-conversion.rst" +documents="$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" @@ -28,7 +28,7 @@ done assert_file_contains "$index" 'guides/local-ai' assert_file_contains "$index" 'guides/databases-and-clients' -assert_file_contains "$index" 'guides/document-conversion' +assert_file_contains "$index" 'guides/conversion' assert_file_contains "$documents" 'lds convert docs README.md README.html' assert_file_contains "$documents" 'lds convert docs --list-output-formats' assert_file_contains "$documents" 'lds convert image photo.jpg photo.png' From ad0137ae02395cf1f5b04d871d6a099408201e4f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:24:26 +0600 Subject: [PATCH 46/84] docs(convert): generalize conversion heading --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 95d16302..8bb61601 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ lds es ... See `docs/guides/databases-and-clients.rst` for the profile/client map. -## Document conversion +## File conversion Pandoc is available from the Tools image without installing it on the host or starting the LocalDevStack services: From 75d5495fdf0b6491f701a8858e600d3042bd1f9b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:24:41 +0600 Subject: [PATCH 47/84] docs(convert): generalize quickstart conversion wording --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index fca8cdce..32cf4056 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -125,7 +125,7 @@ qualified target when names collide. ``lds config show`` is redacted by default. Use ``--raw`` only when unredacted output is deliberately required. -Document conversion is also available without starting the stack:: +File conversion is also available without starting the stack:: lds convert docs README.md README.html From 0d2a2711c65a15f75a4b526ebee1faf123edebee Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:30:05 +0600 Subject: [PATCH 48/84] refine(convert): limit forced first-frame outputs --- lib/conversion.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/conversion.sh b/lib/conversion.sh index 61c70aba..7c816268 100644 --- a/lib/conversion.sh +++ b/lib/conversion.sh @@ -179,7 +179,7 @@ _convert_image_static_output() { local ext="${1##*.}" ext="${ext,,}" case "$ext" in - jpg|jpeg|jpe|png|bmp|tif|tiff|ico|avif|heic|heif) return 0 ;; + jpg|jpeg|jpe|png|bmp|ico) return 0 ;; esac return 1 } From 365263f9c6ff7d3326497e1668b246445d892ed8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:31:53 +0600 Subject: [PATCH 49/84] test(convert): isolate animated WebP assertion --- tests/image-convert-contract.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/image-convert-contract.sh b/tests/image-convert-contract.sh index 08c00c11..1abb5aa2 100644 --- a/tests/image-convert-contract.sh +++ b/tests/image-convert-contract.sh @@ -89,6 +89,7 @@ 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 From 8ec8c74b55907b520f6cf6cecbbc8102e43f499f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:32:59 +0600 Subject: [PATCH 50/84] test(cli): name unified conversion namespace --- tests/cli-contract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index 47f1ee2d..a071fbe6 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -72,7 +72,7 @@ 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" -pass "document conversion is exposed in embedded help" +pass "docs and image conversion are exposed in embedded help" graphify_log="$(mktemp)" cat >"$tmpbin/graphify" <<'SH' From dd5c10e830a9195ef6f9daa49b2d53c3508f0a2c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:33:03 +0600 Subject: [PATCH 51/84] test(windows): name unified conversion contract --- tests/windows-bridge.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1 index d641e2f8..82c9a67d 100644 --- a/tests/windows-bridge.ps1 +++ b/tests/windows-bridge.ps1 @@ -93,4 +93,4 @@ foreach ($needle in @( throw "conversion is missing Windows/Git Bash contract: $needle" } } -Write-Host "PASS: document conversion uses MSYS-safe host mounts" +Write-Host "PASS: conversion uses MSYS-safe host mounts" From 789688491ac706192afba83b8f64e75d1b1f5d09 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:33:07 +0600 Subject: [PATCH 52/84] test(docs): name unified conversion guide --- tests/docs-contract.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index b89b1bc1..0bc9c1af 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -15,25 +15,25 @@ 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" -documents="$ROOT/docs/guides/conversion.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" "$documents" "$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 "$documents" 'lds convert docs README.md README.html' -assert_file_contains "$documents" 'lds convert docs --list-output-formats' -assert_file_contains "$documents" 'lds convert image photo.jpg photo.png' -assert_file_contains "$documents" 'lds convert image --formats' -assert_file_contains "$documents" 'no Docker socket' +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" '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' From cb24b869cc20bb81f6854e7357d1f8d7c1d17242 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:34:31 +0600 Subject: [PATCH 53/84] test(convert): avoid format-list broken pipe --- .github/workflows/check.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1b19d4f1..6f021361 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -196,7 +196,8 @@ jobs: 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 ' Date: Thu, 24 Sep 2026 12:46:41 +0600 Subject: [PATCH 54/84] fix(tools): preserve stdin and Tools runtime env --- bin/tool-runner | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/bin/tool-runner b/bin/tool-runner index fe32d71a..7d330e69 100755 --- a/bin/tool-runner +++ b/bin/tool-runner @@ -66,6 +66,26 @@ server_tools_env_value() { | awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/, ""); print; exit }' } +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)" + [[ -n "$value" ]] && target+=(-e "$key=$value") + done +} + resolve_workspace() { local workdir git_root @@ -100,15 +120,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 +138,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 From fd9a10a07fae1645b5d5a2a2f66d6039dbba4748 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:47:11 +0600 Subject: [PATCH 55/84] feat(tools): expose curated Toolset utility namespace --- lib/services.sh | 78 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/services.sh b/lib/services.sh index 66869d7d..4c741ec4 100644 --- a/lib/services.sh +++ b/lib/services.sh @@ -826,12 +826,83 @@ 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 + +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 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 +936,8 @@ cmd_tools() { ' sh "$path" ;; *) - die "tools " + err "Usage: lds tools " + return 64 ;; esac } From 5dc9cbe9d5c10260ca558f1f0f03516e8a3f8414 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:48:10 +0600 Subject: [PATCH 56/84] test(tools): cover catalog and temporary runner routing --- tests/execution-contract.sh | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/execution-contract.sh b/tests/execution-contract.sh index 0aa45635..3b75ae7c 100755 --- a/tests/execution-contract.sh +++ b/tests/execution-contract.sh @@ -731,6 +731,54 @@ 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' +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 From e8024e3a2f48d8322e832491d9a49a7eb37e81d0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:48:15 +0600 Subject: [PATCH 57/84] test(tools): lock stdin and runtime env propagation --- tests/wrappers-contract.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/wrappers-contract.sh b/tests/wrappers-contract.sh index 771364e9..5dcb876b 100755 --- a/tests/wrappers-contract.sh +++ b/tests/wrappers-contract.sh @@ -20,12 +20,17 @@ 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" From 9f7a61027135f4a85e42e9e1c4a4f8d0803f8f97 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:49:25 +0600 Subject: [PATCH 58/84] docs(tools): expose curated Tools catalog and runner --- lds | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lds b/lds index d9dd8bb0..fd348b05 100755 --- a/lds +++ b/lds @@ -783,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`. @@ -879,7 +883,9 @@ ${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 From 78a8d24ce494bbf891ed3c1d44fd8f4045d9395c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:49:29 +0600 Subject: [PATCH 59/84] docs(tools): document temporary utility runner --- docs/reference/cli.rst | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 7e4ded67..cab61738 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -234,13 +234,26 @@ 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. 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 ------- From cd0ea378f8929a2874c1ef7e8de7746e3fa54249 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:49:34 +0600 Subject: [PATCH 60/84] docs(tools): add workspace Toolset examples --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 8bb61601..5e0753d7 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,29 @@ lds es ... See `docs/guides/databases-and-clients.rst` for the profile/client map. +## Tools and Toolset utilities + +The Tools image also provides developer utilities through an explicit workspace-aware +runner: + +```bash +lds tools list +lds tools gitx status +lds tools gitx worklog HEAD~20..HEAD +lds tools sqlitex --db app.db tables +cat app.log | lds tools chromacat --log +lds tools netx route show +lds tools jq --version +lds tools shellcheck script.sh +lds tools ui +``` + +`lds tools ` 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. 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 From 2c8cf3f656985912757c9faad7d45de924355948 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:49:39 +0600 Subject: [PATCH 61/84] docs(tools): make bundled utility runner discoverable --- docs/quickstart.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 32cf4056..6c8a2994 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 ------------------ From 965512b31fe1f1dd5d0c5aa5987690aec6380ad5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:50:25 +0600 Subject: [PATCH 62/84] test(cli): expose Tools utility runner in help --- tests/cli-contract.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index a071fbe6..d690e70f 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -74,6 +74,13 @@ assert_contains "$markdown_output" "lds convert image [--force] assert_contains "$markdown_output" "lds convert image --formats" pass "docs and image 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 From f44e8663d9236c08fe5bbc9dc0e6c6fd1ee2d563 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:50:29 +0600 Subject: [PATCH 63/84] test(docs): lock Tools utility catalog and runner --- tests/docs-contract.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 0bc9c1af..5b975da1 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -40,6 +40,12 @@ 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 "$index" 'guides/operations-and-support' assert_file_contains "$index" 'guides/ad-hoc-runner' assert_file_contains "$index" 'reference/cli' @@ -173,7 +179,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" @@ -187,6 +193,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' From edeb2512087887b766f1aded6613e525b3c355d6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:51:02 +0600 Subject: [PATCH 64/84] test(tools): exercise piped temporary utility runner --- tests/wrappers-contract.sh | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/wrappers-contract.sh b/tests/wrappers-contract.sh index 5dcb876b..cae90f54 100755 --- a/tests/wrappers-contract.sh +++ b/tests/wrappers-contract.sh @@ -34,6 +34,79 @@ 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" + +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 +) + +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()' From c2efa2e6c9a39394f9131e638e8b4b04f4449166 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:52:42 +0600 Subject: [PATCH 65/84] test(cli): follow expanded Tools help wording --- tests/cli-contract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index d690e70f..f6166585 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)" From 1fedda2773250ce0b205420e468cf764d59bc4f6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:57:48 +0600 Subject: [PATCH 66/84] fix(tools): avoid env-inspection pipe truncation --- bin/tool-runner | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bin/tool-runner b/bin/tool-runner index 7d330e69..91911205 100755 --- a/bin/tool-runner +++ b/bin/tool-runner @@ -63,7 +63,14 @@ 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() { From d5df4dc560d2ba0ee866819c86031be921561132 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:57:52 +0600 Subject: [PATCH 67/84] test(tools): report temporary runner failures clearly --- tests/wrappers-contract.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/wrappers-contract.sh b/tests/wrappers-contract.sh index cae90f54..2f7f1816 100755 --- a/tests/wrappers-contract.sh +++ b/tests/wrappers-contract.sh @@ -85,6 +85,8 @@ 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" \ @@ -92,7 +94,13 @@ printf '%s\n' 'stream payload' | ( 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> ' From 7d76b2e6f612863d62250d40e108162ab549a55f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 12:59:56 +0600 Subject: [PATCH 68/84] fix(tools): tolerate optional runtime env gaps --- bin/tool-runner | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/tool-runner b/bin/tool-runner index 91911205..bdb8a8cf 100755 --- a/bin/tool-runner +++ b/bin/tool-runner @@ -89,8 +89,12 @@ append_server_tools_env() { LDS_AI_MAX_REQUEST_BYTES LDS_AI_MAX_RESPONSE_BYTES do value="$(server_tools_env_value "$key" || true)" - [[ -n "$value" ]] && target+=(-e "$key=$value") + if [[ -n "$value" ]]; then + target+=(-e "$key=$value") + fi done + + return 0 } resolve_workspace() { From 1755df9e0f8dcacf73a018bf600d642babd8ed5e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:02:21 +0600 Subject: [PATCH 69/84] docs(tools): state privileged runner boundary --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5e0753d7..f55643f3 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,12 @@ lds tools ui `lds tools ` 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. 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. +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 From 2d1452edb8dabaf3b540698d0cd2c71070f45a6d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:02:27 +0600 Subject: [PATCH 70/84] docs(tools): document privileged runner boundary --- docs/reference/cli.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index cab61738..4062ee2c 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -246,7 +246,10 @@ The older execution surfaces remain compatible during migration:: ``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. This is the preferred surface for Toolset utilities such as ``gitx``, +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``. From 3ed5e40fa46364a65b88228df56f7fd4a34e7cf6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:02:32 +0600 Subject: [PATCH 71/84] test(docs): lock Tools privilege boundary --- tests/docs-contract.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 5b975da1..69351587 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -46,6 +46,9 @@ 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' From 10229a6b747ac0e1bc5bcf4c9aea905f06e1bb00 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:12:59 +0600 Subject: [PATCH 72/84] feat(convert): add FFmpeg audio and video conversion --- lib/conversion.sh | 120 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/lib/conversion.sh b/lib/conversion.sh index 7c816268..b6ccd167 100644 --- a/lib/conversion.sh +++ b/lib/conversion.sh @@ -17,6 +17,10 @@ Usage: 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 @@ -24,6 +28,8 @@ 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 audio recording.wav recording.mp3 + lds convert video recording.mov recording.mp4 -- -c:v libx264 -crf 23 -c:a aac EOF } @@ -55,6 +61,115 @@ 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 @@ -298,11 +413,14 @@ cmd_convert() { image) _convert_image "$@" ;; + audio | video) + _convert_media "${kind,,}" "$@" + ;; ''|-h|--help|help) _convert_usage ;; *) - err "Unknown conversion type: $kind (expected docs or image)" + err "Unknown conversion type: $kind (expected docs, image, audio, or video)" _convert_usage >&2 return 64 ;; From d32985f8021b907b8c36fbf6a3d8fd81d2209d42 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:13:39 +0600 Subject: [PATCH 73/84] test(convert): cover FFmpeg audio and video conversion --- tests/media-convert-contract.sh | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/media-convert-contract.sh 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" From 73ca564d25276dca66745da176c75daa0ff8a747 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:13:58 +0600 Subject: [PATCH 74/84] docs(cli): expose audio and video conversion --- lds | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lds b/lds index fd348b05..a53d9f69 100755 --- a/lds +++ b/lds @@ -803,6 +803,9 @@ cmd_help() { - `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...]` @@ -895,6 +898,8 @@ ${CYAN}Conversion:${NC} 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 From 02e4ceedf889e736c95bad0e82c2f2351b24f8ae Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:14:15 +0600 Subject: [PATCH 75/84] feat(tools): add media utilities to curated catalog --- lib/services.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/services.sh b/lib/services.sh index 4c741ec4..af936d46 100644 --- a/lib/services.sh +++ b/lib/services.sh @@ -836,6 +836,18 @@ Toolset: 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 @@ -862,6 +874,9 @@ Examples: 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 From e46d86bd095dfa969a58fd6d78cb917733301d1d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:14:28 +0600 Subject: [PATCH 76/84] ci(convert): run audio and video conversion contract --- .github/workflows/check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6f021361..02e12285 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -53,6 +53,7 @@ jobs: 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 From 1bd98bccbe9326c56982c3b6b04333d583140046 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:15:16 +0600 Subject: [PATCH 77/84] docs(media): document audio video conversion and tools --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f55643f3..a2e87ef9 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,10 @@ lds tools gitx worklog HEAD~20..HEAD 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 mkvinfo media.mkv lds tools jq --version lds tools shellcheck script.sh lds tools ui @@ -265,9 +269,13 @@ 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. The input mount is read-only and only the output mount is writable. The +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. From c854f01b099582ddf198c256a460521936432bac Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:15:38 +0600 Subject: [PATCH 78/84] docs(media): add audio video conversion guide --- docs/guides/conversion.rst | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/guides/conversion.rst b/docs/guides/conversion.rst index 37941de2..cefd4bb6 100644 --- a/docs/guides/conversion.rst +++ b/docs/guides/conversion.rst @@ -86,3 +86,46 @@ Raster/image conversion uses ImageMagick from the same Tools image:: 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. From 0a52e48ad4e50f5d9291ae67d66e543e4b6ddc1a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:15:54 +0600 Subject: [PATCH 79/84] docs(media): document FFmpeg conversion surface --- docs/reference/cli.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 4062ee2c..b0310e73 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -129,6 +129,19 @@ Image conversion uses ImageMagick:: 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 ------------ From b1272ca6501d0bc24b7a763923a2e38dc8c77b3f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:16:14 +0600 Subject: [PATCH 80/84] docs(media): add audio video quickstart examples --- docs/quickstart.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 6c8a2994..5f632273 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -136,6 +136,10 @@ 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 From 7673bb0f44e5951fce81c42c32e417474a9506cc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:16:32 +0600 Subject: [PATCH 81/84] test(cli): expose audio and video conversion help --- tests/cli-contract.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh index f6166585..b8cbb4c0 100755 --- a/tests/cli-contract.sh +++ b/tests/cli-contract.sh @@ -72,7 +72,11 @@ 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" -pass "docs and image conversion are exposed in embedded help" +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...]" From 7bd2ed3368ce08ea12204cbefecee51fbda5ff99 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:16:36 +0600 Subject: [PATCH 82/84] test(docs): lock media conversion and tools guidance --- tests/docs-contract.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh index 69351587..afb961e3 100644 --- a/tests/docs-contract.sh +++ b/tests/docs-contract.sh @@ -33,6 +33,17 @@ 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] ' From fee0886ee745bfdc8eaadc8aeb407cf906594ef2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:19:46 +0600 Subject: [PATCH 83/84] test(tools): lock media utility catalog --- tests/execution-contract.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/execution-contract.sh b/tests/execution-contract.sh index 3b75ae7c..ad6ef236 100755 --- a/tests/execution-contract.sh +++ b/tests/execution-contract.sh @@ -741,6 +741,16 @@ 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() { From ea73aca897e5d9d310c3a29d6328720612afb393 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 24 Sep 2026 13:20:44 +0600 Subject: [PATCH 84/84] docs(quickstart): separate image and media literal blocks --- docs/quickstart.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 5f632273..0f243525 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -136,6 +136,7 @@ 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