chore(just): split recipes by concern

This commit is contained in:
Guillaume ARM 2026-06-11 06:07:30 +02:00
parent 316d561f4b
commit ea0eb24a1f
10 changed files with 722 additions and 674 deletions

682
Justfile
View File

@ -1,680 +1,14 @@
# Justfile for cc-libs # Justfile for cc-libs
# Run `just ci` for full local verification. # 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. # List available recipes.
default: default:
@just --list @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 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 .

View File

@ -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-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-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-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. 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.

View File

@ -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.

95
just/check.just Normal file
View File

@ -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 .

244
just/craftos.just Normal file
View File

@ -0,0 +1,244 @@
# 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

66
just/deps.just Normal file
View File

@ -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

43
just/install.just Normal file
View File

@ -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'

26
just/npm.just Normal file
View File

@ -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

34
just/opencode.just Normal file
View File

@ -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" "$@"

159
just/test.just Normal file
View File

@ -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