# Justfile for cc-libs
# Run `just ci` for full local verification.

# List available recipes.
default:
    @just --list

# Install local development tooling.
install: install-git-hooks check-install npm-install generate-env

# Remove build caches (tsc / eslint) for the mcp-bridge tool.
clean:
    npm run clean --prefix tools/mcp-bridge

# Remove generated local environment files (e.g. .env with tokens).
clean-env:
    rm -f .env

# Recreate local generated files and development tooling.
reinstall: clean install

# Install Git hooks for this repository.
install-git-hooks:
    @mkdir -p .git/hooks
    @printf '%s\n' '#!/bin/sh' '' 'just check test' > .git/hooks/pre-commit
    @chmod +x .git/hooks/pre-commit
    @printf '%s\n' 'Installed .git/hooks/pre-commit'
    @printf '%s\n' '#!/bin/sh' '' 'just ci' > .git/hooks/pre-push
    @chmod +x .git/hooks/pre-push
    @printf '%s\n' 'Installed .git/hooks/pre-push'

# Verify the CraftOS-PC harness is installed and recent enough.
check-craftos:
    @command -v craftos >/dev/null 2>&1 || { \
        printf '%s\n' 'craftos not found on $PATH. See docs/install-craftos-pc.md.' >&2; \
        exit 1; \
    }
    @version="$(craftos --version)"; \
    number="${version##* v}"; \
    case "$number" in \
        *.*.*) \
            ;; \
        *) \
            printf '%s\n' "$version"; \
            printf '%s\n' 'Could not parse CraftOS-PC version. See docs/install-craftos-pc.md.' >&2; \
            exit 1; \
            ;; \
    esac; \
    major="${number%%.*}"; \
    rest="${number#*.}"; \
    minor="${rest%%.*}"; \
    patch="${rest#*.}"; \
    patch="${patch%%[^0-9]*}"; \
    printf '%s\n' "$version"; \
    case "$major.$minor.$patch" in \
        *[!0-9.]*|.*|*..*|*.) \
            printf '%s\n' 'Could not parse CraftOS-PC version. See docs/install-craftos-pc.md.' >&2; \
            exit 1; \
            ;; \
    esac; \
    if ! { [ "${major:-0}" -gt 2 ] || \
        { [ "${major:-0}" -eq 2 ] && [ "${minor:-0}" -gt 8 ]; } || \
        { [ "${major:-0}" -eq 2 ] && [ "${minor:-0}" -eq 8 ] && [ "${patch:-0}" -ge 3 ]; }; }; then \
        printf '%s\n' 'CraftOS-PC v2.8.3 or newer is required. See docs/install-craftos-pc.md.' >&2; \
        exit 1; \
    fi

# Verify jq is installed.
check-jq:
    @command -v jq >/dev/null 2>&1 || { \
        printf '%s\n' 'jq not found on $PATH. See DEVELOPMENT.md.' >&2; \
        exit 1; \
    }

# Verify luacheck is installed.
check-luacheck:
    @command -v luacheck >/dev/null 2>&1 || { \
        printf '%s\n' 'luacheck not found on $PATH. See DEVELOPMENT.md.' >&2; \
        exit 1; \
    }

# Verify openssl is installed.
check-openssl:
    @command -v openssl >/dev/null 2>&1 || { \
        printf '%s\n' 'openssl not found on $PATH. See DEVELOPMENT.md.' >&2; \
        exit 1; \
    }

# Verify lychee is installed.
check-lychee:
    @command -v lychee >/dev/null 2>&1 || { \
        printf '%s\n' 'lychee not found on $PATH. See DEVELOPMENT.md.' >&2; \
        exit 1; \
    }

# Verify tools needed for local installation and CraftOS-PC launch recipes.
check-install: check-craftos check-jq check-luacheck check-openssl check-lychee

# Generate local secrets on first install.
generate-env:
    #!/usr/bin/env bash
    set -euo pipefail
    if [ -f .env ]; then
        exit 0
    fi
    password="$(openssl rand -hex 32)"
    while IFS= read -r line || [ -n "$line" ]; do
        case "$line" in
            OPENCODE_SERVER_PASSWORD=*)
                printf '%s\n' "OPENCODE_SERVER_PASSWORD=$password"
                ;;
            *)
                printf '%s\n' "$line"
                ;;
        esac
    done < .env.test > .env
    printf '%s\n' 'Generated .env'

# Install Node dependencies for repository tools.
npm-install:
    npm install --prefix tools/mcp-bridge

# Build Node-based repository tools.
npm-build:
    npm run build --prefix tools/mcp-bridge

# Check Node-based repository tools.
npm-check:
    npm run check --prefix tools/mcp-bridge

# Run Node-based tool tests.
npm-test:
    npm test --prefix tools/mcp-bridge

# Run Node-based tool integration tests.
npm-test-integration:
    npm run test:integration --prefix tools/mcp-bridge

# Run Node-based tool CI.
npm-ci:
    npm run test:ci --prefix tools/mcp-bridge

# Build generated artifacts.
build: npm-build

# Pass args through to `craftos`. Prefer `just trapos-exec '<lua>'` for
# automated probes that must not hang the terminal.
# Launch the TrapOS dev environment in CraftOS-PC with repo-local data
# (.craftos/) and read-only repo mounts. See ADR-0005.
[positional-arguments]
trapos *args: check-install
    #!/usr/bin/env bash
    set -euo pipefail
    repo='{{justfile_directory()}}'
    argv=(--directory "$repo/.craftos")
    if [ "$(uname -s)" = "Darwin" ]; then
        argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
    fi
    argv+=(--mount-ro "/trapos=$repo")
    for dir in apis programs servers startup tests; do
        if [ -d "$repo/$dir" ]; then
            argv+=(--mount-ro "/$dir=$repo/$dir")
        fi
    done
    exec craftos "${argv[@]}" "$@"

# Pass args through to a fresh, vanilla `craftos` with no TrapOS mounts.
# Persistent state lives under .craftos-vanilla/. Useful for probes that
# should not see TrapOS files (e.g. verifying upstream CC:Tweaked behavior)
# and as the base for `just trapos-install`. See ADR-0005.
[positional-arguments]
craftos *args: check-install
    #!/usr/bin/env bash
    set -euo pipefail
    repo='{{justfile_directory()}}'
    argv=(--directory "$repo/.craftos-vanilla")
    if [ "$(uname -s)" = "Darwin" ]; then
        argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
    fi
    exec craftos "${argv[@]}" "$@"

# Safely run a Lua snippet in the TrapOS dev environment. The wrapper always
# shuts the machine down after normal completion or Lua errors, while the host
# watchdog catches snippets that block before reaching shutdown.
[positional-arguments]
trapos-exec code:
    #!/usr/bin/env bash
    set -uo pipefail
    repo='{{justfile_directory()}}'
    timeout_seconds="${TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS:-10}"
    case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
    if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi
    rom_arg=()
    if [ "$(uname -s)" = "Darwin" ]; then
        rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
    fi
    data_dir="$(mktemp -d)"
    stage_dir="$(mktemp -d)"
    tmp="$(mktemp)"
    output_path="$data_dir/computer/0/headless-output"
    status_path="$data_dir/computer/0/headless-status"
    runner="$stage_dir/exec.lua"
    {
        printf '%s\n' 'local output = fs.open("/headless-output", "w")'
        printf '%s\n' 'local function emitLine(...)'
        printf '%s\n' '  for i = 1, select("#", ...) do'
        printf '%s\n' '    if i > 1 then output.write("\t") end'
        printf '%s\n' '    output.write(tostring(select(i, ...)))'
        printf '%s\n' '  end'
        printf '%s\n' '  output.write("\n")'
        printf '%s\n' 'end'
        printf '%s\n' 'print = emitLine'
        printf '%s\n' 'write = function(value) output.write(tostring(value)); end'
        printf '%s\n' 'local function main()'
        printf '%s\n' "$1"
        printf '%s\n' 'end'
        printf '%s\n' 'local ok, err = xpcall(main, debug.traceback)'
        printf '%s\n' 'if not ok then'
        printf '%s\n' '  output.writeLine(err)'
        printf '%s\n' 'end'
        printf '%s\n' 'output.close()'
        printf '%s\n' 'local status = fs.open("/headless-status", "w")'
        printf '%s\n' 'status.writeLine(ok and "OK" or "FAIL")'
        printf '%s\n' 'status.close()'
        printf '%s\n' 'os.shutdown()'
    } > "$runner"
    mount_arg=(--mount-ro "/trapos=$repo" --mount-ro "/apis=$repo/apis" --mount-ro "/programs=$repo/programs" --mount-ro "/servers=$repo/servers" --mount-ro "/startup=$repo/startup" --mount-ro "/tests=$repo/tests" --mount-ro "/headless=$stage_dir")
    craftos --directory "$data_dir" --headless "${rom_arg[@]}" "${mount_arg[@]}" --exec "shell.run('/headless/exec.lua')" >"$tmp" 2>&1 &
    pid="$!"
    ( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
    watchdog="$!"
    wait "$pid" >/dev/null 2>&1
    status="$?"
    kill "$watchdog" >/dev/null 2>&1 || true
    wait "$watchdog" >/dev/null 2>&1 || true
    red=$(printf '\033[31m'); reset=$(printf '\033[0m')
    if [ -f "$status_path" ] && grep -q '^OK$' "$status_path"; then
        if [ -f "$output_path" ]; then cat "$output_path"; fi
        rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
    else
        if [ "$status" -eq 143 ]; then
            printf '%s\n' "${red}FAIL${reset} TrapOS headless exec timed out after ${timeout_seconds}s" >&2
        else
            printf '%s\n' "${red}FAIL${reset} TrapOS headless exec failed" >&2
        fi
        if [ -f "$output_path" ]; then
            cat "$output_path" >&2
        else
            cat "$tmp" >&2
        fi
        rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
        exit 1
    fi

# Safely run a Lua snippet in vanilla CraftOS-PC with no TrapOS mounts.
[positional-arguments]
craftos-exec code:
    #!/usr/bin/env bash
    set -uo pipefail
    repo='{{justfile_directory()}}'
    timeout_seconds="${TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS:-10}"
    case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
    if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_HEADLESS_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi
    rom_arg=()
    if [ "$(uname -s)" = "Darwin" ]; then
        rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
    fi
    data_dir="$(mktemp -d)"
    stage_dir="$(mktemp -d)"
    tmp="$(mktemp)"
    output_path="$data_dir/computer/0/headless-output"
    status_path="$data_dir/computer/0/headless-status"
    runner="$stage_dir/exec.lua"
    {
        printf '%s\n' 'local output = fs.open("/headless-output", "w")'
        printf '%s\n' 'local function emitLine(...)'
        printf '%s\n' '  for i = 1, select("#", ...) do'
        printf '%s\n' '    if i > 1 then output.write("\t") end'
        printf '%s\n' '    output.write(tostring(select(i, ...)))'
        printf '%s\n' '  end'
        printf '%s\n' '  output.write("\n")'
        printf '%s\n' 'end'
        printf '%s\n' 'print = emitLine'
        printf '%s\n' 'write = function(value) output.write(tostring(value)); end'
        printf '%s\n' 'local function main()'
        printf '%s\n' "$1"
        printf '%s\n' 'end'
        printf '%s\n' 'local ok, err = xpcall(main, debug.traceback)'
        printf '%s\n' 'if not ok then'
        printf '%s\n' '  output.writeLine(err)'
        printf '%s\n' 'end'
        printf '%s\n' 'output.close()'
        printf '%s\n' 'local status = fs.open("/headless-status", "w")'
        printf '%s\n' 'status.writeLine(ok and "OK" or "FAIL")'
        printf '%s\n' 'status.close()'
        printf '%s\n' 'os.shutdown()'
    } > "$runner"
    craftos --directory "$data_dir" --headless "${rom_arg[@]}" --mount-ro "/headless=$stage_dir" --exec "shell.run('/headless/exec.lua')" >"$tmp" 2>&1 &
    pid="$!"
    ( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
    watchdog="$!"
    wait "$pid" >/dev/null 2>&1
    status="$?"
    kill "$watchdog" >/dev/null 2>&1 || true
    wait "$watchdog" >/dev/null 2>&1 || true
    red=$(printf '\033[31m'); reset=$(printf '\033[0m')
    if [ -f "$status_path" ] && grep -q '^OK$' "$status_path"; then
        if [ -f "$output_path" ]; then cat "$output_path"; fi
        rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
    else
        if [ "$status" -eq 143 ]; then
            printf '%s\n' "${red}FAIL${reset} CraftOS headless exec timed out after ${timeout_seconds}s" >&2
        else
            printf '%s\n' "${red}FAIL${reset} CraftOS headless exec failed" >&2
        fi
        if [ -f "$output_path" ]; then
            cat "$output_path" >&2
        else
            cat "$tmp" >&2
        fi
        rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
        exit 1
    fi

# End-to-end install probe: drive the real ccpm bootstrap
# (install-ccpm.lua -> `ccpm update` -> `ccpm install trapos`) on a fresh,
# ephemeral CraftOS-PC state. Reflects the currently checked-out git branch:
# `master` -> --stable, `next` -> --beta (confirmation stubbed). Other
# branches are rejected because install-ccpm only knows master/next.
# Network-dependent and slower than `just test`, so not part of `just ci`.
# Override timeout with TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS (default 60).
# See ADR-0005.
trapos-install: check-install
    #!/usr/bin/env bash
    set -uo pipefail
    repo='{{justfile_directory()}}'
    timeout_seconds="${TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS:-60}"
    case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_INSTALL_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
    branch="$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
    case "$branch" in
        master)
            install_flag='--stable'
            stub_read=''
            ;;
        next)
            install_flag='--beta'
            stub_read="_G.read = function() return 'y' end; "
            ;;
        *)
            printf '%s\n' "trapos-install only supports the master or next branch (current: ${branch:-unknown}). install-ccpm.lua does not accept other branches." >&2
            exit 1
            ;;
    esac
    printf '%s\n' "trapos-install: branch=$branch flag=$install_flag"
    rom_arg=()
    if [ "$(uname -s)" = "Darwin" ]; then
        rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
    fi
    data_dir="$(mktemp -d)"
    stage_dir="$(mktemp -d)"
    cp "$repo/install-ccpm.lua" "$stage_dir/install-ccpm.lua"
    tmp="$(mktemp)"
    exec_code="${stub_read}shell.run('/staging/install-ccpm', '$install_flag'); shell.run('/programs/ccpm', 'update'); local ok = shell.run('/programs/ccpm', 'install', 'trapos'); if ok then print('__TRAPOS_INSTALL_OK__') end; os.shutdown()"
    craftos --directory "$data_dir" --headless "${rom_arg[@]}" --mount-ro "/staging=$stage_dir" --exec "$exec_code" >"$tmp" 2>&1 &
    pid="$!"
    ( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
    watchdog="$!"
    wait "$pid" >/dev/null 2>&1
    status="$?"
    kill "$watchdog" >/dev/null 2>&1 || true
    wait "$watchdog" >/dev/null 2>&1 || true
    red=$(printf '\033[31m'); reset=$(printf '\033[0m')
    if grep -q __TRAPOS_INSTALL_OK__ "$tmp"; then
        rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
        printf '%s\n' 'OK: trapos installed end-to-end on fresh CraftOS-PC'
    else
        if [ "$status" -eq 143 ]; then
            printf '%s\n' "${red}FAIL${reset} trapos-install timed out after ${timeout_seconds}s" >&2
        else
            printf '%s\n' "${red}FAIL${reset} trapos-install did not print __TRAPOS_INSTALL_OK__ (status=$status)" >&2
        fi
        cat "$tmp" >&2
        rm -f "$tmp"; rm -rf "$data_dir"; rm -rf "$stage_dir"
        exit 1
    fi

# Human-only interactive REPL. LLM agents must not execute this command.
repl:
    @just trapos --cli

# Serve opencode for remote/browser attachment using repo-local .env secrets.
[positional-arguments]
opencode-serve *args:
    #!/usr/bin/env bash
    set -euo pipefail
    repo='{{justfile_directory()}}'
    if [ -f "$repo/.env" ]; then set -a; . "$repo/.env"; set +a; fi
    if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then
        printf '%s\n' 'Missing OPENCODE_SERVER_PASSWORD in .env' >&2
        exit 1
    fi
    export OPENCODE_SERVER_PASSWORD
    hostname="${TRAP_CCLIBS_OPENCODE_HOSTNAME:-0.0.0.0}"
    port="${TRAP_CCLIBS_OPENCODE_PORT:-4242}"
    exec opencode serve --hostname "$hostname" --port "$port" "$@"

# Attach to the local opencode server. Pass a URL first to override the default.
[positional-arguments]
opencode-attach *args:
    #!/usr/bin/env bash
    set -euo pipefail
    repo='{{justfile_directory()}}'
    if [ -f "$repo/.env" ]; then set -a; . "$repo/.env"; set +a; fi
    if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then
        printf '%s\n' 'Missing OPENCODE_SERVER_PASSWORD in .env' >&2
        exit 1
    fi
    export OPENCODE_SERVER_PASSWORD
    port="${TRAP_CCLIBS_OPENCODE_PORT:-4242}"
    target="http://127.0.0.1:$port"
    if [ "${1:-}" != "" ]; then
        case "$1" in http://*|https://*) target="$1"; shift ;; esac
    fi
    exec opencode attach "$target" "$@"

# Local CI entry point used by Git hooks. Pass args through to CraftOS tests.
ci *args: check-craftos check check-packages npm-build npm-test
    @just _craftos-test {{args}}
    @just test-integration

# Run all standard tests. Pass `--pretty` for grouped CraftOS output.
[positional-arguments]
test *args: build npm-test
    @just _craftos-test {{args}}

# Run integration and harness tests that are too broad for unit test targets.
test-integration: npm-test-integration test-timeout

# Run CraftOS-PC headless integration tests. Pass `--pretty` for grouped output.
[positional-arguments]
_craftos-test *args:
    #!/usr/bin/env bash
    set -uo pipefail

    if [ -f .env.test ]; then set -a; . ./.env.test; set +a; fi

    pretty=0
    has_output=0
    timeout_seconds="${TRAP_CCLIBS_TEST_TIMEOUT_SECONDS:-3}"
    case "$timeout_seconds" in ''|*[!0-9]*) printf '%s\n' 'TRAP_CCLIBS_TEST_TIMEOUT_SECONDS must be a positive integer' >&2; exit 1 ;; esac
    if [ "$timeout_seconds" -lt 1 ]; then printf '%s\n' 'TRAP_CCLIBS_TEST_TIMEOUT_SECONDS must be >= 1' >&2; exit 1; fi

    runtest_args=("$@")
    for a in "$@"; do
        case "$a" in
            --pretty|--verbose|-v) pretty=1 ;;
            --output) has_output=1 ;;
        esac
    done
    if [ "$pretty" -eq 1 ] && [ "$has_output" -eq 0 ]; then
        runtest_args+=(--output /trapos-test-output)
    fi
    runtest_args+=(--shutdown)

    lua_quote() {
        local value="$1"
        value="${value//\\/\\\\}"
        value="${value//\'/\\\'}"
        printf "'%s'" "$value"
    }

    exec_code="shell.run('/programs/runtest.lua'"
    for arg in "${runtest_args[@]}"; do
        exec_code+=", $(lua_quote "$arg")"
    done
    exec_code+=")"

    rom_arg=()
    if [ "$(uname -s)" = "Darwin" ]; then
        rom_arg=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
    fi
    repo='{{justfile_directory()}}'
    mount_arg=(--mount-ro "/trapos=$repo" --mount-ro "/apis=$repo/apis" --mount-ro "/programs=$repo/programs" --mount-ro "/startup=$repo/startup" --mount-ro "/tests=$repo/tests")
    tmp="$(mktemp)"
    data_dir="$(mktemp -d)"
    output_path="$data_dir/computer/0/trapos-test-output"

    craftos --directory "$data_dir" --headless "${rom_arg[@]}" "${mount_arg[@]}" --exec "$exec_code" >"$tmp" 2>&1 &
    pid="$!"
    ( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
    watchdog="$!"
    wait "$pid" >/dev/null 2>&1
    status="$?"
    kill "$watchdog" >/dev/null 2>&1 || true
    wait "$watchdog" >/dev/null 2>&1 || true
    if grep -q __TRAPOS_TEST_OK__ "$tmp"; then
        if [ "$pretty" -eq 1 ] && [ -f "$output_path" ]; then cat "$output_path"; fi
        rm -f "$tmp"
        rm -rf "$data_dir"
    else
        red=$(printf '\033[31m'); reset=$(printf '\033[0m')
        if [ "$status" -eq 143 ]; then
            printf '%s\n' "${red}FAIL${reset} CraftOS integration tests timed out after ${timeout_seconds}s" >&2
        else
            printf '%s\n' "${red}FAIL${reset} CraftOS integration tests did not print __TRAPOS_TEST_OK__" >&2
        fi
        if [ -f "$output_path" ]; then cat "$output_path" >&2; fi
        cat "$tmp" >&2
        rm -f "$tmp"
        rm -rf "$data_dir"
        exit 1
    fi
    printf '%s\n' 'OK: CraftOS integration tests passed'

# Harness self-test: run a tests/harness fixture and assert which timeout layer
# caught it. `expect` is "lua" (libtest cancels the case) or "shell" (the shell
# watchdog kills the whole process). Not part of `ci`/`test`: these exercise the
# failure paths on purpose.
_timeout-fixture script shell_timeout extra_flag expect: check-install
    #!/usr/bin/env bash
    set -uo pipefail
    repo='{{justfile_directory()}}'
    if [ -f "$repo/.env.test" ]; then set -a; . "$repo/.env.test"; set +a; fi
    rom_arg=""
    if [ "$(uname -s)" = "Darwin" ]; then
        rom_arg="--rom /Applications/CraftOS-PC.app/Contents/Resources"
    fi
    mount_arg="--mount-ro /apis=$repo/apis --mount-ro /programs=$repo/programs --mount-ro /tests=$repo/tests"
    tmp="$(mktemp)"
    data_dir="$(mktemp -d)"
    output_path="$data_dir/computer/0/trapos-test-output"
    exec_code="shell.run('/programs/runtest.lua', '{{script}}', '--verbose', '--output', '/trapos-test-output', {{extra_flag}} '--shutdown')"
    shell_timeout="{{shell_timeout}}"
    craftos --directory "$data_dir" --headless $rom_arg $mount_arg --exec "$exec_code" >"$tmp" 2>&1 &
    pid="$!"
    ( sleep "$shell_timeout"; kill -TERM "$pid" >/dev/null 2>&1 ) &
    watchdog="$!"
    wait "$pid" >/dev/null 2>&1
    status="$?"
    kill "$watchdog" >/dev/null 2>&1 || true
    wait "$watchdog" >/dev/null 2>&1 || true
    combined="$(cat "$tmp"; [ -f "$output_path" ] && cat "$output_path")"
    red=$(printf '\033[31m'); green=$(printf '\033[32m'); reset=$(printf '\033[0m')
    rc=0
    case "{{expect}}" in
        lua)
            if printf '%s\n' "$combined" | grep -q 'libtest timeout' && [ "$status" -ne 143 ]; then
                printf '%s\n' "${green}OK${reset} libtest cancelled the case (shell watchdog not needed)"; \
            else
                printf '%s\n' "${red}FAIL${reset} expected a libtest timeout before the shell watchdog (status=$status)" >&2
                rc=1
            fi
            ;;
        shell)
            if [ "$status" -eq 143 ]; then
                printf '%s\n' "${green}OK${reset} shell watchdog killed the run after ${shell_timeout}s (status 143; libtest timeout bypassed)"; \
            else
                printf '%s\n' "${red}FAIL${reset} expected the shell watchdog to kill the run (status=$status)" >&2
                rc=1
            fi
            ;;
        *)
            printf '%s\n' "${red}FAIL${reset} unknown expectation '{{expect}}'" >&2
            rc=1
            ;;
    esac
    if [ "$rc" -ne 0 ]; then printf '%s\n' "$combined" >&2; fi
    rm -f "$tmp"
    rm -rf "$data_dir"
    exit "$rc"

# Prove the libtest (Lua) timeout layer: libtest cancels the slow case quickly,
# before the shell watchdog backstop can fire.
test-timeout-lua: (_timeout-fixture "/tests/harness/slow-case.lua" "${TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS:-1}" "'--timeout', '0'," "lua")

# Prove the shell watchdog backstop: the slow case runs with the libtest timeout
# bypassed (--no-timeout), so the shell watchdog kills the whole process.
test-timeout-shell: (_timeout-fixture "/tests/harness/slow-case.lua" "${TRAP_CCLIBS_TEST_TIMEOUT_WATCHDOG_SECONDS:-1}" "'--no-timeout'," "shell")

# Fast regression guard for both timeout layers. Wired into `ci`.
test-timeout: test-timeout-lua test-timeout-shell

# Lint Lua/TypeScript source and validate markdown links.
check: check-luacheck check-lychee npm-check
    luacheck --quiet .
    @just lint-markdown

# Validate package descriptors and require version bumps for changed package files.
check-packages: check-jq
    #!/usr/bin/env bash
    set -euo pipefail
    repo='{{justfile_directory()}}'
    cd "$repo"

    semver_gt() {
        local a_major a_minor a_patch b_major b_minor b_patch
        IFS=. read -r a_major a_minor a_patch <<<"$1"
        IFS=. read -r b_major b_minor b_patch <<<"$2"
        if [ "${a_major:-0}" -gt "${b_major:-0}" ]; then return 0; fi
        if [ "${a_major:-0}" -lt "${b_major:-0}" ]; then return 1; fi
        if [ "${a_minor:-0}" -gt "${b_minor:-0}" ]; then return 0; fi
        if [ "${a_minor:-0}" -lt "${b_minor:-0}" ]; then return 1; fi
        [ "${a_patch:-0}" -gt "${b_patch:-0}" ]
    }

    fail=0
    packages=()
    while IFS= read -r name; do packages+=("$name"); done < <(jq -r '.packages | keys[]' packages/index.json | sort)

    for name in "${packages[@]}"; do
        desc="packages/$name/ccpm.json"
        if [ ! -f "$desc" ]; then
            printf '%s\n' "FAIL: packages/index.json lists missing descriptor $desc" >&2
            fail=1
            continue
        fi

        desc_name="$(jq -r '.name // empty' "$desc")"
        desc_version="$(jq -r '.version // empty' "$desc")"
        index_version="$(jq -r --arg name "$name" '.packages[$name] // empty' packages/index.json)"
        if [ "$desc_name" != "$name" ]; then
            printf '%s\n' "FAIL: $desc has name '$desc_name', expected '$name'" >&2
            fail=1
        fi
        if [ "$desc_version" != "$index_version" ]; then
            printf '%s\n' "FAIL: $name version differs between descriptor ($desc_version) and packages/index.json ($index_version)" >&2
            fail=1
        fi

        last_desc_commit="$(git log -n 1 --format=%H -- "$desc" || true)"
        if [ -z "$last_desc_commit" ]; then
            continue
        fi
        old_version="$(git show "$last_desc_commit:$desc" | jq -r '.version // empty')"

        files=()
        while IFS= read -r file; do files+=("$file"); done < <(jq -r '.files[]?' "$desc")
        if [ "${#files[@]}" -gt 0 ] && ! git diff --quiet "$last_desc_commit" -- "${files[@]}"; then
            if ! semver_gt "$desc_version" "$old_version"; then
                printf '%s\n' "FAIL: $name package files changed since $desc was last bumped ($old_version); bump $desc and packages/index.json" >&2
                git diff --name-only "$last_desc_commit" -- "${files[@]}" >&2
                fail=1
            fi
        fi
    done

    for desc in packages/*/ccpm.json; do
        [ -e "$desc" ] || continue
        name="$(jq -r '.name // empty' "$desc")"
        if ! jq -e --arg name "$name" '.packages[$name] != null' packages/index.json >/dev/null; then
            printf '%s\n' "FAIL: descriptor $desc is missing from packages/index.json" >&2
            fail=1
        fi
    done

    trapos_desc='packages/trapos/ccpm.json'
    trapos_commit="$(git log -n 1 --format=%H -- "$trapos_desc" || true)"
    if [ -n "$trapos_commit" ]; then
        trapos_version="$(jq -r '.version // empty' "$trapos_desc")"
        old_trapos_version="$(git show "$trapos_commit:$trapos_desc" | jq -r '.version // empty')"
        deps=()
        while IFS= read -r dep; do deps+=("packages/$dep/ccpm.json"); done < <(jq -r '.dependencies[]?' "$trapos_desc")
        if [ "${#deps[@]}" -gt 0 ] && ! git diff --quiet "$trapos_commit" -- "${deps[@]}"; then
            if ! semver_gt "$trapos_version" "$old_trapos_version"; then
                printf '%s\n' "FAIL: trapos dependencies changed since trapos was last bumped ($old_trapos_version); bump $trapos_desc and packages/index.json" >&2
                git diff --name-only "$trapos_commit" -- "${deps[@]}" >&2
                fail=1
            fi
        fi
    fi

    if [ "$fail" -ne 0 ]; then exit 1; fi
    printf '%s\n' 'OK: package versions aligned'

# Validate local markdown links and heading anchors with lychee.
lint-markdown: check-lychee
    lychee --config lychee.toml .
