From ea0eb24a1f8cd7e3773251d9e1ef87c2b5a1eaf1 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 06:07:30 +0200 Subject: [PATCH] chore(just): split recipes by concern --- Justfile | 682 +------------------- docs/adrs/README.md | 1 + docs/adrs/adr-0018-justfile-organization.md | 46 ++ just/check.just | 95 +++ just/craftos.just | 244 +++++++ just/deps.just | 66 ++ just/install.just | 43 ++ just/npm.just | 26 + just/opencode.just | 34 + just/test.just | 159 +++++ 10 files changed, 722 insertions(+), 674 deletions(-) create mode 100644 docs/adrs/adr-0018-justfile-organization.md create mode 100644 just/check.just create mode 100644 just/craftos.just create mode 100644 just/deps.just create mode 100644 just/install.just create mode 100644 just/npm.just create mode 100644 just/opencode.just create mode 100644 just/test.just diff --git a/Justfile b/Justfile index b7788bf..0d4830f 100644 --- a/Justfile +++ b/Justfile @@ -1,680 +1,14 @@ # Justfile for cc-libs # Run `just ci` for full local verification. +import 'just/deps.just' +import 'just/install.just' +import 'just/npm.just' +import 'just/craftos.just' +import 'just/opencode.just' +import 'just/test.just' +import 'just/check.just' + # 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 ''` 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 end-to-end tests that span the MCP bridge and headless CraftOS-PC. -e2e: npm-test-integration - -# Run integration and harness tests that are too broad for unit test targets. -test-integration: e2e 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 . diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 18fdda5..ae61282 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -16,5 +16,6 @@ Future ADRs can reuse the shape of the existing files when it is useful. - [`adr-0011-repo-conventions.md`](adr-0011-repo-conventions.md) — Git hooks own commit/push verification; markdown link syntax for cross-references. - [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split. - [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers. +- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names. Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above. diff --git a/docs/adrs/adr-0018-justfile-organization.md b/docs/adrs/adr-0018-justfile-organization.md new file mode 100644 index 0000000..48b39e3 --- /dev/null +++ b/docs/adrs/adr-0018-justfile-organization.md @@ -0,0 +1,46 @@ +# ADR 0018: Justfile Organization + +## Status + +Accepted + +## Date + +2026-06-11 + +## Context + +The root `Justfile` had grown into a single large file containing unrelated repository operations: local installation, dependency checks, Node tooling, CraftOS-PC launch wrappers, headless probes, test orchestration, package validation, markdown linting, and opencode helpers. + +That made the file harder to scan and edit, even though the public recipe surface is useful as a flat command list. Existing documentation, Git hooks, and developer habits refer to top-level commands such as `just check`, `just test`, `just ci`, and `just trapos-exec`. + +`just` supports both imports and modules. Imports include another justfile into the current namespace, so imported recipes keep their top-level names. Modules create namespaced subcommands and isolate recipe/variable definitions between modules. That namespace is useful for new command families, but it would change the invocation shape of existing repository recipes. + +## Decision + +Keep the root `Justfile` as the canonical entrypoint, but make it only import concern-specific recipe files under `just/` and define the default listing recipe. + +Use imports for the existing recipe split so all public recipe names remain unchanged. Organize imported files by operational concern: + +- `just/deps.just` for dependency/tool availability checks. +- `just/install.just` for local install, cleanup, Git hooks, and generated environment setup. +- `just/npm.just` for Node-based repository tooling. +- `just/craftos.just` for CraftOS-PC launchers, headless Lua probes, install probe, and REPL wrapper. +- `just/opencode.just` for opencode server/client helpers. +- `just/test.just` for CI, standard tests, integration tests, and timeout harness checks. +- `just/check.just` for source checks, package validation, and markdown link validation. + +Reserve `mod` for future commands that are intentionally namespaced from the start. Do not use modules for this migration because preserving the existing flat command API is more important than introducing module isolation. + +## Consequences + +- Existing commands keep working without aliases or compatibility wrappers. +- `just --list` remains the public command catalogue for the repository. +- Recipe bodies are easier to locate by concern while cross-file dependencies still work through the shared imported namespace. +- Duplicate recipe names remain a repository-level concern because all imported recipes share one namespace. +- Future recipes should be added to the concern file that owns their lifecycle rather than growing the root `Justfile` again. + +## Future Work + +- Consider modules for new namespaced tool families if the desired invocation is explicitly something like `just tool subcommand` or `just tool::subcommand`. +- Revisit the grouping if any imported justfile grows large enough to recreate the original navigation problem. diff --git a/just/check.just b/just/check.just new file mode 100644 index 0000000..ff2248b --- /dev/null +++ b/just/check.just @@ -0,0 +1,95 @@ +# 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 . diff --git a/just/craftos.just b/just/craftos.just new file mode 100644 index 0000000..9421b84 --- /dev/null +++ b/just/craftos.just @@ -0,0 +1,244 @@ +# Pass args through to `craftos`. Prefer `just trapos-exec ''` 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 diff --git a/just/deps.just b/just/deps.just new file mode 100644 index 0000000..532f75b --- /dev/null +++ b/just/deps.just @@ -0,0 +1,66 @@ +# 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 diff --git a/just/install.just b/just/install.just new file mode 100644 index 0000000..b393029 --- /dev/null +++ b/just/install.just @@ -0,0 +1,43 @@ +# 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' + +# 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' diff --git a/just/npm.just b/just/npm.just new file mode 100644 index 0000000..dd5d523 --- /dev/null +++ b/just/npm.just @@ -0,0 +1,26 @@ +# 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 diff --git a/just/opencode.just b/just/opencode.just new file mode 100644 index 0000000..104faf1 --- /dev/null +++ b/just/opencode.just @@ -0,0 +1,34 @@ +# 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" "$@" diff --git a/just/test.just b/just/test.just new file mode 100644 index 0000000..832861b --- /dev/null +++ b/just/test.just @@ -0,0 +1,159 @@ +# 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 end-to-end tests that span the MCP bridge and headless CraftOS-PC. +e2e: npm-test-integration + +# Run integration and harness tests that are too broad for unit test targets. +test-integration: e2e 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