# 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 generate-env # Remove generated local environment files. clean: 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' # Pass args through to `craftos`, for example: # just trapos --headless --exec 'print("__TRAPOS_TEST_OK__"); os.shutdown()' # Launch the TrapOS dev environment in CraftOS-PC with repo-local data # (.craftos/) and read-only repo mounts. See ADR-0005 and ADR-0012. [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-0012. [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[@]}" "$@" # 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-0012. 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 `test`. ci *args: check-craftos check check-packages @just test {{args}} @just test-timeout # Run CraftOS-PC headless integration tests. Pass `--pretty` for grouped output. [positional-arguments] 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 all Lua source with luacheck and validate markdown links. check: check-luacheck check-lychee 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 .